diff --git a/docs/VK_CLIENT.md b/docs/VK_CLIENT.md index 801eadd..c3944c8 100644 --- a/docs/VK_CLIENT.md +++ b/docs/VK_CLIENT.md @@ -1,6 +1,6 @@ -# VK Client Architecture & Integration Guide +# VK Client Architecture & Official API Specification -The VK Integration layer is structured into modular, decoupled components located under `src/integrations/vk/`. +The VK Integration layer is structured into decoupled components located under `src/integrations/vk/`. --- @@ -25,31 +25,51 @@ The VK Integration layer is structured into modular, decoupled components locate [VK API] ``` -### Core Components +--- -1. **`VkClient` (`src/integrations/vk/vk-client.ts`)**: - - Centralizes low-level HTTP communication with VK API. - - Enforces default API version `5.199`. - - Manages timeouts with `AbortController` (default 15s). - - Coordinates outbound rate limiting and retry backoff. - - Provides universal pagination helper `fetchPaginatedVk`. +## Verified VK API Specifications (v5.199) -2. **`VkAuthContext` (`src/integrations/vk/vk-auth.ts`)**: - - Represents typed access tokens (`SERVICE`, `USER`, `COMMUNITY`). - - Ensures tokens are never leaked into logs, error messages, or persistent audit records. +### 1. `wall.getById` +- **Official Docs**: `https://dev.vk.com/ru/method/wall.getById` +- **Method**: POST/GET `https://api.vk.com/method/wall.getById` +- **Parameters**: `posts` (e.g. `"-100_12345"`), `extended=1`. +- **Response**: `{ items: VkWallPost[], profiles?: VkUserProfile[], groups?: VkGroupProfile[] }`. +- **Behavior**: Returns empty `items: []` or error 210 if post is deleted or wall is private. -3. **`VkRateLimiter` (`src/integrations/vk/vk-rate-limit.ts`)**: - - Throttles outbound requests according to VK API thresholds (default: 10 req/sec configurable). +### 2. `likes.getList` +- **Official Docs**: `https://dev.vk.com/ru/method/likes.getList` +- **Method**: POST/GET `https://api.vk.com/method/likes.getList` +- **Parameters**: `type="post"`, `owner_id`, `item_id`, `filter="likes"`, `extended=1`, `count` (max 100), `offset`. +- **Response**: `{ count: number, items: VkUserProfile[] }`. -4. **`executeWithRetry` (`src/integrations/vk/vk-retry.ts`)**: - - Handles exponential backoff with full jitter for retryable transient errors (5xx server errors, rate limits, network timeouts). - - Fast-fails non-retryable errors (auth errors, permissions, private resources, validation). +### 3. `wall.getComments` +- **Official Docs**: `https://dev.vk.com/ru/method/wall.getComments` +- **Method**: POST/GET `https://api.vk.com/method/wall.getComments` +- **Parameters**: `owner_id`, `post_id`, `extended=1`, `count` (max 100), `offset`, `fields="photo_100,photo_200,screen_name"`. +- **Response**: `{ count: number, items: VkCommentItem[], profiles?: VkUserProfile[] }`. + +### 4. `groups.isMember` +- **Official Docs**: `https://dev.vk.com/ru/method/groups.isMember` +- **Method**: POST/GET `https://api.vk.com/method/groups.isMember` +- **Parameters**: `group_id`, `user_ids` (comma-separated list of IDs up to **500 max** per batch call). +- **Response**: `Array<{ user_id: number, member: 1 | 0 }>`. + +### 5. Reposts Limitation `[CONFIRMED_LIMITATION]` +- **Official Status**: VK API does **not** provide a public method to list all users who reposted an arbitrary third-party post due to user privacy settings. `wall.getReposts` only works for community managers on their own wall posts. +- **Provider Flag**: `capabilities.reposts = false`. + +### 6. Admin Detection `[UNVERIFIED]` +- **Official Status**: Checking if a user is an administrator of a target community requires `groups.getMembers` with `filter=managers`, which requires community admin rights. +- **Provider Flag**: `capabilities.adminDetection = false`. --- -## Pagination & Scalability +## Cancellation vs Timeout Lifecycle -- **No Artificial Caps**: Previous limits (e.g. 5,000 likes or 1,000 comments) have been completely removed. -- **Likes**: Uses `likes.getList` with `filter=likes&extended=1` in batches of 100 up to the total post likes count. -- **Comments**: Uses `wall.getComments` with `extended=1` and profile enrichment. -- **Subscription Checks**: Batches up to 500 user IDs per `groups.isMember` call. +| Failure Mode | Error Class | Retryable? | Behavior | +|---|---|---|---| +| Caller `AbortSignal` fires | `VkCancelledError` | **No** | Request aborted immediately; retry engine halts without retry. | +| Client timeout timer expires | `VkTimeoutError` | **Yes** | Attempt aborted; backoff delay computed and retry initiated up to `maxRetries`. | +| HTTP 429 Too Many Requests | `VkRateLimitError` | **Yes** | Retryable with backoff. | +| HTTP 500..504 Server Error | `VkTemporaryError` | **Yes** | Retryable with backoff. | +| HTTP 400/401/403/404 | `VkClientError` subclasses | **No** | Fast fail. | diff --git a/docs/VK_METHOD_CAPABILITIES.md b/docs/VK_METHOD_CAPABILITIES.md new file mode 100644 index 0000000..aa9a39c --- /dev/null +++ b/docs/VK_METHOD_CAPABILITIES.md @@ -0,0 +1,41 @@ +# VK Method Capabilities Matrix + +This document defines the verified VK API method capabilities, token scopes, and known platform constraints according to the official **`VKCOM/vk-api-schema`** (API version 5.199). + +--- + +## Verified VK Method Matrix + +| VK Method | Allowed Token Types (Schema) | Required Parameters | Max Batch / Count | Known Privacy & Policy Limitations | Verification Status | +|---|---|---|---|---|---| +| **`wall.getById`** | `service`, `user`, `group`, `open` | `posts` (e.g. `"-123_456"`) | Max 100 posts per call | Cannot access posts on private user walls or restricted groups without user/group authorization. | **`VERIFIED`** | +| **`likes.getList`** | `service`, `user`, `group`, `open` | `type="post"`, `owner_id`, `item_id` | Max 100 with `extended=1` (profiles), max 1000 with IDs only | If a post is from a closed community, requires membership/access. Profiles with deleted accounts are returned with `deactivated` tag. | **`VERIFIED`** | +| **`wall.getComments`** | `service`, `user`, `group`, `open` | `owner_id`, `post_id` | Max 100 comments per request | Nested replies require recursive traversal or standard chronological fetch. Closed comments return error 210. | **`VERIFIED`** | +| **`groups.isMember`** | `service`, `user`, `group`, `open` | `group_id`, `user_ids` | Max **500** `user_ids` per batch call | Closed groups return `member=0` for non-members even if user has pending join request (unless request status inspected). | **`VERIFIED`** | +| **`wall.getReposts`** | `user`, `group` | `owner_id`, `post_id` | Max 100 | **Cannot enumerate all reposters** on arbitrary public posts due to user profile privacy restrictions. Only available to group managers for their own posts. | **`CONFIRMED_LIMITATION`** | +| **`groups.getMembers`** (Managers) | `user`, `group` | `group_id`, `filter="managers"` | Max 1000 | Requires administrative rights in the target community. Not available via standalone public Service Token. | **`CONFIRMED_LIMITATION`** | + +--- + +## Token Type Definitions (`VKCOM/vk-api-schema`) + +1. **`service` (Service Token)**: + - Application access token obtained from VK Developer Console. + - Strictly read-only for public methods. + - Never expires, but cannot act on behalf of a user. + +2. **`user` (User Access Token)**: + - Obtained via modern **VK ID Web SDK** (OAuth 2.1 with PKCE). + - Can access user-authorized data, private groups user belongs to, and perform user actions. + +3. **`group` (VK Group Access Token)**: + - Configured in VK Community Settings (referred to internally as `COMMUNITY` token in Randomayzer). + - Scoped strictly to the managing group/public page. + +--- + +## Authentication Architecture for Phase 2.2 + +- **Protocol**: OAuth 2.1 + PKCE (`code_verifier`, `code_challenge` SHA-256 base64url). +- **State Security**: Cryptographically secure single-use `state` with TTL, validated on OAuth callback. +- **Endpoints**: Modern `id.vk.com` / `vk.ru` VK ID Web SDK flow (legacy implicit token flow is deprecated). diff --git a/src/integrations/vk/vk-client.ts b/src/integrations/vk/vk-client.ts index 9388698..93df967 100644 --- a/src/integrations/vk/vk-client.ts +++ b/src/integrations/vk/vk-client.ts @@ -6,11 +6,14 @@ import { } from './vk-types'; import { mapVkApiError, + mapHttpStatusError, VkTimeoutError, + VkCancelledError, VkNetworkError, - VkValidationError + VkValidationError, + VkPaginationLimitError } from './vk-errors'; -import { validateAuthContext, redactToken } from './vk-auth'; +import { validateAuthContext } from './vk-auth'; import { IVkRateLimiter, defaultVkRateLimiter } from './vk-rate-limit'; import { executeWithRetry } from './vk-retry'; @@ -48,7 +51,7 @@ export class VkClient implements IVkClient { } /** - * Executes a single low-level HTTP call to VK API with timeout and error handling. + * Executes a single low-level HTTP call to VK API with explicit separate timeout/cancellation tracking. */ private async executeSingleCall( method: string, @@ -58,26 +61,42 @@ export class VkClient implements IVkClient { ): Promise { validateAuthContext(authContext); - // Acquire rate limit slot + // 1. Check if caller already aborted before execution starts + if (options?.signal?.aborted) { + throw new VkCancelledError(`VK request to "${method}" was cancelled by caller before execution`, { + method, + }); + } + + // 2. Acquire rate limit slot await this.rateLimiter.acquire(); + if (options?.signal?.aborted) { + throw new VkCancelledError(`VK request to "${method}" was cancelled by caller while waiting for rate limiter`, { + method, + }); + } + const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs; const controller = new AbortController(); + let timedOut = false; + let callerCancelled = false; let timeoutId: NodeJS.Timeout | null = null; if (timeoutMs > 0) { timeoutId = setTimeout(() => { + timedOut = true; controller.abort(); }, timeoutMs); } - // Merge caller signal if provided + const onCallerAbort = () => { + callerCancelled = true; + controller.abort(); + }; + if (options?.signal) { - if (options.signal.aborted) { - if (timeoutId) clearTimeout(timeoutId); - throw new Error('VK request aborted by caller signal'); - } - options.signal.addEventListener('abort', () => controller.abort(), { once: true }); + options.signal.addEventListener('abort', onCallerAbort, { once: true }); } const url = `${this.baseUrl}${method}`; @@ -103,13 +122,8 @@ export class VkClient implements IVkClient { signal: controller.signal, }); - if (timeoutId) clearTimeout(timeoutId); - if (!response.ok) { - throw new VkNetworkError( - `VK API HTTP error ${response.status}: ${response.statusText}`, - { method, errorCode: response.status } - ); + throw mapHttpStatusError(response.status, response.statusText, method); } const text = await response.text(); @@ -137,26 +151,42 @@ export class VkClient implements IVkClient { return json.response; } catch (err: unknown) { - if (timeoutId) clearTimeout(timeoutId); + // Precise error classification: + if (callerCancelled || options?.signal?.aborted) { + throw new VkCancelledError(`VK API call to "${method}" was cancelled by the caller`, { + method, + }); + } - if (controller.signal.aborted) { + if (timedOut) { throw new VkTimeoutError(`VK API call to "${method}" timed out after ${timeoutMs}ms`, { method, }); } if (err instanceof Error && err.name === 'AbortError') { - throw new VkTimeoutError(`VK API call to "${method}" was aborted`, { method }); + if (options?.signal?.aborted) { + throw new VkCancelledError(`VK API call to "${method}" was aborted by caller signal`, { + method, + }); + } + throw new VkTimeoutError(`VK API call to "${method}" was aborted by timeout`, { method }); } - if (err instanceof Error && !(err instanceof VkNetworkError) && !(err as any).category) { - // Unhandled fetch network error + if (err instanceof Error && !(err as any).category) { throw new VkNetworkError(`VK API network error on "${method}": ${err.message}`, { method, }); } throw err; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options?.signal) { + options.signal.removeEventListener('abort', onCallerAbort); + } } } @@ -185,20 +215,25 @@ export class VkClient implements IVkClient { * Universal pagination abstraction for VK API methods (offset/count based). */ export async function fetchPaginatedVk( - options: VkPaginationOptions + options: VkPaginationOptions & { throwOnTruncation?: boolean } ): Promise { const pageSize = options.pageSize ?? 100; const maxPages = options.maxPages ?? 10000; + const throwOnTruncation = options.throwOnTruncation ?? true; const allItems: TItem[] = []; let offset = 0; let page = 0; + let recordedTotalCount: number | undefined; while (page < maxPages) { if (options.signal?.aborted) { - break; + throw new VkCancelledError('VK pagination cancelled by caller signal'); } const { items, totalCount } = await options.fetchPage(offset, pageSize, options.signal); + if (totalCount !== undefined) { + recordedTotalCount = totalCount; + } if (!items || items.length === 0) { break; @@ -221,6 +256,20 @@ export async function fetchPaginatedVk( } } + // Safety check against silent truncation + if ( + page >= maxPages && + recordedTotalCount !== undefined && + allItems.length < recordedTotalCount + ) { + if (throwOnTruncation) { + throw new VkPaginationLimitError( + `VK pagination reached maxPages safety ceiling (${maxPages} pages) with only ${allItems.length}/${recordedTotalCount} items loaded. Truncation detected.`, + { details: { loaded: allItems.length, total: recordedTotalCount, maxPages } } + ); + } + } + return allItems; } diff --git a/src/integrations/vk/vk-errors.ts b/src/integrations/vk/vk-errors.ts index 0fd3b92..c416161 100644 --- a/src/integrations/vk/vk-errors.ts +++ b/src/integrations/vk/vk-errors.ts @@ -22,7 +22,7 @@ export abstract class VkClientError extends Error { } /** - * VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28) + * VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28, HTTP 401) */ export class VkAuthError extends VkClientError { readonly isRetryable = false; @@ -30,7 +30,7 @@ export class VkAuthError extends VkClientError { } /** - * VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260) + * VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260, HTTP 403) */ export class VkPermissionError extends VkClientError { readonly isRetryable = false; @@ -38,7 +38,7 @@ export class VkPermissionError extends VkClientError { } /** - * VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9) + * VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9, 29, HTTP 429) */ export class VkRateLimitError extends VkClientError { readonly isRetryable = true; @@ -54,7 +54,7 @@ export class VkPrivateResourceError extends VkClientError { } /** - * VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214) + * VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214, HTTP 404) */ export class VkNotFoundError extends VkClientError { readonly isRetryable = false; @@ -62,7 +62,7 @@ export class VkNotFoundError extends VkClientError { } /** - * VK Validation Error: Malformed parameters or bad request (VK error codes: 100, 113, 150) + * VK Validation Error: Malformed parameters or bad request (VK error codes: 8, 100, 113, 150, HTTP 400) */ export class VkValidationError extends VkClientError { readonly isRetryable = false; @@ -70,7 +70,7 @@ export class VkValidationError extends VkClientError { } /** - * VK Temporary Error: Unknown error, internal server error, or 5xx response from VK API (VK error codes: 1, 10, 500, 502, 503, 504) + * VK Temporary Error: Unknown error or internal server error from VK API (VK error codes: 1, 10, HTTP 500, 502, 503, 504) */ export class VkTemporaryError extends VkClientError { readonly isRetryable = true; @@ -86,13 +86,32 @@ export class VkNetworkError extends VkClientError { } /** - * VK Timeout Error: Request was aborted due to client timeout + * VK Timeout Error: Request was aborted due to internal client timeout or VK method timeout (VK error code: 36) */ export class VkTimeoutError extends VkClientError { readonly isRetryable = true; readonly category = 'TIMEOUT'; } +/** + * VK Cancelled Error: Request was cancelled by the caller via AbortSignal (NEVER retryable) + */ +export class VkCancelledError extends VkClientError { + readonly isRetryable = false; + readonly category = 'CANCELLED'; +} + +/** + * VK Pagination Limit Error: Pagination hit maxPages safety threshold before fetching all items + */ +export class VkPaginationLimitError extends VkClientError { + readonly isRetryable = false; + readonly category = 'PAGINATION_LIMIT_REACHED'; +} + +// Alias for backwards compatibility +export const VkPaginationTruncatedError = VkPaginationLimitError; + /** * Sanitizes request params returned by VK to redact any sensitive token values. */ @@ -107,7 +126,7 @@ function sanitizeRequestParams(params?: Array<{ key: string; value: string }>): } /** - * Maps raw VK API error_code according to official VK API documentation. + * Maps raw VK API error_code strictly according to official VKCOM/vk-api-schema. */ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError { const code = raw.error_code; @@ -115,6 +134,10 @@ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError const msg = raw.error_msg || raw.error_text || `VK API Error (${code})`; switch (code) { + case 1: // Unknown error occurred + case 10: // Internal server error + return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); + case 4: // Incorrect signature case 5: // User authorization failed case 28: // Application authorization failed @@ -129,30 +152,54 @@ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError case 260: // Access to the group is denied return new VkPermissionError(`VK Permission Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); + case 8: // Invalid request + case 100: // One of the parameters specified was missing or invalid + case 113: // Invalid user id + case 150: // Invalid timestamp + return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); + case 15: // Access denied (private object) case 30: // This profile is private case 203: // Access to the group is denied return new VkPrivateResourceError(`VK Private Resource Access Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); - case 100: // One of the parameters specified was missing or invalid - case 113: // Invalid user id - case 150: // Invalid timestamp - return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); + case 36: // Compile or method execution timeout on VK server side + return new VkTimeoutError(`VK Method Execution Timeout (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); case 104: // Not found case 210: // Access to wall's post denied or post not found case 214: // Access to adding post denied / not found return new VkNotFoundError(`VK Resource Not Found (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); - case 1: // Unknown error occurred - case 10: // Internal server error - case 500: - case 502: - case 503: - case 504: - return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); - default: return new VkValidationError(`VK API Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams }); } } + +/** + * Maps HTTP status codes into typed VK client errors with proper retry classification. + */ +export function mapHttpStatusError(status: number, statusText: string, method: string): VkClientError { + const msg = `VK API HTTP error ${status}: ${statusText || 'Unknown'}`; + + if (status === 429) { + return new VkRateLimitError(msg, { errorCode: status, method }); + } + if (status >= 500 && status <= 504) { + return new VkTemporaryError(msg, { errorCode: status, method }); + } + if (status === 401) { + return new VkAuthError(msg, { errorCode: status, method }); + } + if (status === 403) { + return new VkPermissionError(msg, { errorCode: status, method }); + } + if (status === 404) { + return new VkNotFoundError(msg, { errorCode: status, method }); + } + if (status >= 400 && status < 500) { + return new VkValidationError(msg, { errorCode: status, method }); + } + + return new VkNetworkError(msg, { errorCode: status, method }); +} diff --git a/src/integrations/vk/vk-retry.ts b/src/integrations/vk/vk-retry.ts index 6f76d18..3656cbc 100644 --- a/src/integrations/vk/vk-retry.ts +++ b/src/integrations/vk/vk-retry.ts @@ -1,4 +1,4 @@ -import { VkClientError } from './vk-errors'; +import { VkClientError, VkCancelledError } from './vk-errors'; export interface VkRetryOptions { maxRetries?: number; @@ -56,7 +56,7 @@ export async function executeWithRetry( for (let attempt = 0; attempt <= config.maxRetries; attempt++) { if (signal?.aborted) { - throw new Error('Operation aborted'); + throw new VkCancelledError('Operation aborted by caller signal before attempt'); } try { @@ -71,16 +71,19 @@ export async function executeWithRetry( const delay = calculateBackoffDelay(attempt, config); if (delay > 0) { await new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, delay); + let timeoutId: NodeJS.Timeout | null = null; + const onAbort = () => { + if (timeoutId) clearTimeout(timeoutId); + reject(new VkCancelledError('Operation aborted by caller signal during retry backoff')); + }; + + timeoutId = setTimeout(() => { + if (signal) signal.removeEventListener('abort', onAbort); + resolve(true); + }, delay); + if (signal) { - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new Error('Operation aborted during retry backoff')); - }, - { once: true } - ); + signal.addEventListener('abort', onAbort, { once: true }); } }); } diff --git a/tests/vk-correctness-gate.test.ts b/tests/vk-correctness-gate.test.ts new file mode 100644 index 0000000..47ed92f --- /dev/null +++ b/tests/vk-correctness-gate.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { VkClient, fetchPaginatedVk } from '../src/integrations/vk/vk-client'; +import { createServiceAuth, redactToken } from '../src/integrations/vk/vk-auth'; +import { + VkCancelledError, + VkTimeoutError, + VkRateLimitError, + VkTemporaryError, + VkAuthError, + VkPermissionError, + VkPrivateResourceError, + VkNotFoundError, + VkValidationError, + VkPaginationLimitError +} from '../src/integrations/vk/vk-errors'; +import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit'; + +class ImmediateRateLimiter implements IVkRateLimiter { + async acquire(): Promise {} + reset(): void {} +} + +describe('Phase 2.1.1 VK Client Correctness Gate & Official Schema Verification', () => { + const token = 'vk1.a.correctness_gate_secret_token_12345'; + const auth = createServiceAuth(token); + let client: VkClient; + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + client = new VkClient({ + rateLimiter: new ImmediateRateLimiter(), + defaultTimeoutMs: 500, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockFetchVkError(errorCode: number, errorMsg: string) { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify({ error: { error_code: errorCode, error_msg: errorMsg } }), + json: async () => ({ error: { error_code: errorCode, error_msg: errorMsg } }), + }); + } + + // --- 1. Cancellation vs Timeout Separation --- + + it('caller already aborted before call immediately throws VkCancelledError with 0 retries', async () => { + const controller = new AbortController(); + controller.abort(); + + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + return { ok: true, status: 200, text: async () => '{"response": 1}' }; + }); + + await expect( + client.call('wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3 }) + ).rejects.toThrow(VkCancelledError); + + expect(fetchCount).toBe(0); + }); + + it('caller abort during active request throws VkCancelledError and NEVER retries', async () => { + const controller = new AbortController(); + let fetchCount = 0; + + (global.fetch as ReturnType).mockImplementation(async (_url, init) => { + fetchCount++; + return new Promise((_, reject) => { + init.signal.addEventListener('abort', () => { + reject(new Error('AbortError')); + }); + }); + }); + + const callPromise = client.call('wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3 }); + + setTimeout(() => controller.abort(), 10); + + await expect(callPromise).rejects.toThrow(VkCancelledError); + expect(fetchCount).toBe(1); + }); + + it('internal timeout throws VkTimeoutError and retries according to policy', async () => { + const timeoutClient = new VkClient({ + rateLimiter: new ImmediateRateLimiter(), + defaultTimeoutMs: 25, + }); + + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + if (fetchCount < 3) { + return new Promise(resolve => setTimeout(resolve, 80)); + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: { success: 1 } }), + }; + }); + + const result = await timeoutClient.call<{ success: number }>( + 'wall.getById', + {}, + auth, + { maxRetries: 3, retryInitialDelayMs: 5 } + ); + + expect(result.success).toBe(1); + expect(fetchCount).toBe(3); + }); + + it('caller abort during retry backoff terminates immediately with VkCancelledError', async () => { + const controller = new AbortController(); + let fetchCount = 0; + + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + return { + ok: false, + status: 500, + statusText: 'Internal Server Error', + }; + }); + + const callPromise = client.call( + 'wall.getById', + {}, + auth, + { signal: controller.signal, maxRetries: 3, retryInitialDelayMs: 200 } + ); + + setTimeout(() => controller.abort(), 30); + + await expect(callPromise).rejects.toThrow(VkCancelledError); + expect(fetchCount).toBe(1); + }); + + // --- 2. HTTP Status Code Handling --- + + it('HTTP 429 Too Many Requests throws VkRateLimitError and retries', async () => { + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + if (fetchCount < 2) { + return { ok: false, status: 429, statusText: 'Too Many Requests' }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: { ok: true } }), + }; + }); + + const res = await client.call<{ ok: boolean }>('likes.getList', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); + expect(res.ok).toBe(true); + expect(fetchCount).toBe(2); + }); + + it('HTTP 500, 502, 503 throw VkTemporaryError and retry', async () => { + for (const status of [500, 502, 503]) { + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + if (fetchCount < 2) { + return { ok: false, status, statusText: 'Server Error' }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: { ok: true } }), + }; + }); + + const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); + expect(res.ok).toBe(true); + expect(fetchCount).toBe(2); + } + }); + + it('HTTP 400 throws VkValidationError and does NOT retry', async () => { + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + return { ok: false, status: 400, statusText: 'Bad Request' }; + }); + + await expect(client.call('wall.getById', {}, auth, { maxRetries: 3 })).rejects.toThrow(VkValidationError); + expect(fetchCount).toBe(1); + }); + + // --- 3. Official VK API Error Codes --- + + it('VK error 5 (user auth) & 28 (app auth) throw VkAuthError without retry', async () => { + mockFetchVkError(5, 'User authorization failed'); + await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkAuthError); + + mockFetchVkError(28, 'Application authorization failed'); + await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkAuthError); + }); + + it('VK error 6 (too many req/s), 9 (flood control), 29 (rate limit) throw VkRateLimitError and retry', async () => { + for (const code of [6, 9, 29]) { + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + if (fetchCount < 2) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ error: { error_code: code, error_msg: 'Rate limited' } }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: { ok: true } }), + }; + }); + + const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); + expect(res.ok).toBe(true); + expect(fetchCount).toBe(2); + } + }); + + it('VK error 10 (internal server error) throws VkTemporaryError and retries', async () => { + let fetchCount = 0; + (global.fetch as ReturnType).mockImplementation(async () => { + fetchCount++; + if (fetchCount < 2) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ error: { error_code: 10, error_msg: 'Internal error' } }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ response: { ok: true } }), + }; + }); + + const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 }); + expect(res.ok).toBe(true); + expect(fetchCount).toBe(2); + }); + + it('VK error 7 (permission denied) & 15 (access denied) & 30 (private profile) throw non-retryable errors', async () => { + mockFetchVkError(7, 'Permission denied'); + await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPermissionError); + + mockFetchVkError(15, 'Access denied'); + await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPrivateResourceError); + + mockFetchVkError(30, 'Private profile'); + await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPrivateResourceError); + }); + + it('VK error 100 (missing/invalid param) throws VkValidationError without retry', async () => { + mockFetchVkError(100, 'One of the parameters specified was missing or invalid'); + await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkValidationError); + }); + + // --- 4. Pagination Truncation Safety --- + + it('pagination throws VkPaginationLimitError when maxPages is hit before totalCount is loaded', async () => { + const fetchPage = vi.fn(async () => { + return { items: [1, 2], totalCount: 10 }; + }); + + await expect( + fetchPaginatedVk({ + pageSize: 2, + maxPages: 2, + fetchPage, + throwOnTruncation: true, + }) + ).rejects.toThrow(VkPaginationLimitError); + }); + + // --- 5. Malformed Response & Missing Response --- + + it('throws VkValidationError on malformed non-JSON response', async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + text: async () => '{ broken_json ', + }); + + await expect(client.call('wall.getById', {}, auth, { maxRetries: 0 })).rejects.toThrow(VkValidationError); + }); + + it('throws VkValidationError on missing response field in VK JSON', async () => { + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({}), + }); + + await expect(client.call('wall.getById', {}, auth, { maxRetries: 0 })).rejects.toThrow(VkValidationError); + }); + + // --- 6. Token Redaction Verification --- + + it('verifies token is never exposed in error message or error details', async () => { + const secretToken = 'vk1.a.ultra_secret_token_999999999'; + const authSecret = createServiceAuth(secretToken); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + error: { + error_code: 5, + error_msg: 'User authorization failed: invalid access_token.', + request_params: [ + { key: 'oauth', value: '1' }, + { key: 'access_token', value: secretToken }, + ], + }, + }), + }); + + try { + await client.call('wall.getById', {}, authSecret, { maxRetries: 0 }); + expect.unreachable('Should have thrown VkAuthError'); + } catch (err: any) { + expect(err.message).not.toContain(secretToken); + const detailsStr = JSON.stringify(err.details); + expect(detailsStr).not.toContain(secretToken); + expect(detailsStr).toContain('[REDACTED]'); + } + }); +}); diff --git a/tests/vk-errors.test.ts b/tests/vk-errors.test.ts index 8fde647..b2d676b 100644 --- a/tests/vk-errors.test.ts +++ b/tests/vk-errors.test.ts @@ -8,14 +8,26 @@ import { VkPrivateResourceError, VkNotFoundError, VkValidationError, + VkTemporaryError, VkNetworkError } from '../src/integrations/vk/vk-errors'; +import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit'; + +class NoopRateLimiter implements IVkRateLimiter { + async acquire(): Promise {} + reset(): void {} +} describe('VK API error handling', () => { const token = 'vk1.a.test-service-token'; + let client: VkClient; beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); + client = new VkClient({ + rateLimiter: new NoopRateLimiter(), + defaultTimeoutMs: 100, + }); }); afterEach(() => { @@ -38,29 +50,27 @@ describe('VK API error handling', () => { it('throws on invalid token (error_code 5)', async () => { mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkAuthError); }); it('throws on expired token (error_code 5)', async () => { mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkAuthError); }); it('throws on access denied (error_code 15)', async () => { mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError); }); it('throws on rate limit per second (error_code 6)', async () => { mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } }); - // Use VkClient with 0 retries to observe immediate error - const client = new VkClient(); const provider = new VkProvider(token, client); await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkRateLimitError); @@ -68,70 +78,70 @@ describe('VK API error handling', () => { it('throws on daily rate limit (error_code 29)', async () => { mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(VkRateLimitError); }); it('throws on private profile (error_code 30)', async () => { mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkPrivateResourceError); }); it('throws on deleted or banned user (error_code 18)', async () => { mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkValidationError); }); it('throws on deleted post (error_code 100 with post not found semantics)', async () => { mockFetchJson({ error: { error_code: 100, error_msg: 'One of the parameters specified was missing or invalid' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(VkValidationError); }); it('throws when post is not found in response', async () => { mockFetchJson({ response: { items: [] } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkNotFoundError); }); it('throws on unavailable community (error_code 203)', async () => { mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError); }); it('throws on empty response body', async () => { mockFetchJson({}); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkValidationError); }); it('throws on HTTP 500 from VK', async () => { mockFetchJson('Internal Server Error', 500); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); - await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError); + await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkTemporaryError); }); it('throws on network timeout / failure', async () => { mockFetchNetworkError('fetch failed'); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError); }); it('does not leak the service token in thrown error messages', async () => { mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } }); - const provider = new VkProvider(token); + const provider = new VkProvider(token, client); try { await provider.fetchPost('https://vk.com/wall-1_1');