diff --git a/messages/en/errors.json b/messages/en/errors.json index da374c2b3..0e9d3a4ac 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -30,6 +30,9 @@ "PERMISSION_DENIED": "Permission denied", "TOKEN_REQUIRED": "Authentication token required", "INVALID_TOKEN": "Invalid authentication token", + "PROXY_INVALID_API_KEY": "Invalid API key. The provided key does not exist or has been deleted.", + "PROXY_API_KEY_DISABLED": "This API key has been disabled. Please contact your administrator to re-enable it, or use a different key.", + "PROXY_API_KEY_EXPIRED": "This API key has expired. Please contact your administrator to renew it or rotate to a new key.", "INTERNAL_ERROR": "Internal server error, please try again later", "DATABASE_ERROR": "Database error", diff --git a/messages/ja/errors.json b/messages/ja/errors.json index 5758e6fc5..b951cd5bb 100644 --- a/messages/ja/errors.json +++ b/messages/ja/errors.json @@ -30,6 +30,9 @@ "PERMISSION_DENIED": "アクセス権限がありません", "TOKEN_REQUIRED": "認証トークンが必要です", "INVALID_TOKEN": "無効な認証トークン", + "PROXY_INVALID_API_KEY": "API キーが無効です。指定されたキーは存在しないか、削除されています。", + "PROXY_API_KEY_DISABLED": "この API キーは無効化されています。管理者に再有効化を依頼するか、別のキーをご使用ください。", + "PROXY_API_KEY_EXPIRED": "この API キーは期限切れです。管理者に更新を依頼するか、新しいキーへ切り替えてください。", "INTERNAL_ERROR": "内部サーバーエラー、後でもう一度お試しください", "DATABASE_ERROR": "データベースエラー", diff --git a/messages/ru/errors.json b/messages/ru/errors.json index 7e4f2502a..87562a3ef 100644 --- a/messages/ru/errors.json +++ b/messages/ru/errors.json @@ -30,6 +30,9 @@ "PERMISSION_DENIED": "Доступ запрещен", "TOKEN_REQUIRED": "Требуется токен аутентификации", "INVALID_TOKEN": "Недействительный токен аутентификации", + "PROXY_INVALID_API_KEY": "Неверный API-ключ. Указанный ключ не существует или был удалён.", + "PROXY_API_KEY_DISABLED": "Этот API-ключ отключён. Обратитесь к администратору, чтобы повторно включить его, или используйте другой ключ.", + "PROXY_API_KEY_EXPIRED": "Срок действия этого API-ключа истёк. Обратитесь к администратору, чтобы продлить срок, или замените ключ.", "INTERNAL_ERROR": "Внутренняя ошибка сервера, попробуйте позже", "DATABASE_ERROR": "Ошибка базы данных", diff --git a/messages/zh-CN/errors.json b/messages/zh-CN/errors.json index 42dc25433..b8d037487 100644 --- a/messages/zh-CN/errors.json +++ b/messages/zh-CN/errors.json @@ -30,6 +30,9 @@ "PERMISSION_DENIED": "权限不足", "TOKEN_REQUIRED": "需要提供认证令牌", "INVALID_TOKEN": "无效的认证令牌", + "PROXY_INVALID_API_KEY": "API 密钥无效。提供的密钥不存在或已被删除。", + "PROXY_API_KEY_DISABLED": "API 密钥已被禁用。请联系管理员重新启用,或使用其他可用密钥。", + "PROXY_API_KEY_EXPIRED": "API 密钥已过期。请联系管理员续期或更换密钥。", "INTERNAL_ERROR": "系统内部错误,请稍后重试", "DATABASE_ERROR": "数据库错误", diff --git a/messages/zh-TW/errors.json b/messages/zh-TW/errors.json index 538e56655..07d104c72 100644 --- a/messages/zh-TW/errors.json +++ b/messages/zh-TW/errors.json @@ -30,6 +30,9 @@ "PERMISSION_DENIED": "權限不足", "TOKEN_REQUIRED": "需要提供認證令牌", "INVALID_TOKEN": "無效的認證令牌", + "PROXY_INVALID_API_KEY": "API 金鑰無效。提供的金鑰不存在或已被刪除。", + "PROXY_API_KEY_DISABLED": "API 金鑰已被停用。請聯絡管理員重新啟用,或使用其他可用金鑰。", + "PROXY_API_KEY_EXPIRED": "API 金鑰已過期。請聯絡管理員續期或更換金鑰。", "INTERNAL_ERROR": "系統內部錯誤,請稍後重試", "DATABASE_ERROR": "資料庫錯誤", diff --git a/src/app/v1/_lib/models/available-models.ts b/src/app/v1/_lib/models/available-models.ts index 7361f9db0..2c62196ea 100644 --- a/src/app/v1/_lib/models/available-models.ts +++ b/src/app/v1/_lib/models/available-models.ts @@ -3,9 +3,10 @@ import { request as undiciRequest } from "undici"; import { normalizeAllowedModelRules } from "@/lib/allowed-model-rules"; import { logger } from "@/lib/logger"; import { createProxyAgentForProvider } from "@/lib/proxy-agent"; +import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages"; import { isProviderActiveNow } from "@/lib/utils/provider-schedule"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; -import { validateApiKeyAndGetUser } from "@/repository/key"; +import { resolveApiKeyAuthOutcome } from "@/repository/key"; import { findAllProviders } from "@/repository/provider"; import type { AnthropicModelsResponse, @@ -60,12 +61,52 @@ async function authenticateRequest(c: Context): Promise<{ throw c.json({ error: { message: "未提供认证凭据", type: "authentication_error" } }, 401); } - const authResult = await validateApiKeyAndGetUser(apiKey); - if (!authResult) { - throw c.json({ error: { message: "API 密钥无效", type: "invalid_api_key" } }, 401); + const outcome = await resolveApiKeyAuthOutcome(apiKey); + if (!outcome.ok) { + // Exhaustive switch: see auth-guard.ts for rationale. Adding a new + // ApiKeyAuthFailureReason will produce a TypeScript error on the + // exhaustiveness fallthrough until this branch is handled explicitly. + const { getLocale } = await import("next-intl/server"); + const locale = await getLocale(); + switch (outcome.reason) { + case "key_disabled": + throw c.json( + { + error: { + message: await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_DISABLED), + type: "key_disabled", + }, + }, + 401 + ); + case "key_expired": + throw c.json( + { + error: { + message: await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_EXPIRED), + type: "key_expired", + }, + }, + 401 + ); + case "not_found": + throw c.json( + { + error: { + message: await getErrorMessageServer(locale, ERROR_CODES.PROXY_INVALID_API_KEY), + type: "invalid_api_key", + }, + }, + 401 + ); + default: { + const _exhaustive: never = outcome.reason; + throw new Error(`Unhandled auth outcome reason: ${JSON.stringify(_exhaustive)}`); + } + } } - const { user, key } = authResult; + const { user, key } = outcome; if (!user.isEnabled) { throw c.json({ error: { message: "用户账户已被禁用", type: "user_disabled" } }, 401); diff --git a/src/app/v1/_lib/proxy/auth-guard.ts b/src/app/v1/_lib/proxy/auth-guard.ts index 061e80404..6698c2c06 100644 --- a/src/app/v1/_lib/proxy/auth-guard.ts +++ b/src/app/v1/_lib/proxy/auth-guard.ts @@ -2,11 +2,51 @@ import { extractApiKeyFromHeaders as sharedExtractApiKeyFromHeaders } from "@/li import { getClientIpWithFreshSettings } from "@/lib/ip"; import { logger } from "@/lib/logger"; import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy"; -import { validateApiKeyAndGetUser } from "@/repository/key"; +import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages"; +import { resolveApiKeyAuthOutcome } from "@/repository/key"; import { markUserExpired } from "@/repository/user"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; import { ProxyResponses } from "./responses"; -import type { AuthState, ProxySession } from "./session"; +import type { AuthFailureKind, AuthState, ProxySession } from "./session"; + +async function getRequestLocale(): Promise { + // Match the rate-limit-guard pattern: read next-intl's request locale + // (Accept-Language or cookie) lazily inside the guard so the proxy hot + // path does not pay the import cost on every cold start. + const { getLocale } = await import("next-intl/server"); + return await getLocale(); +} + +/** + * Build an auth failure AuthState. The factory exists so every call site is + * forced (by the function signature) to supply both `failureKind` and an + * `errorResponse` — preventing the footgun where a new failure branch could + * forget to tag itself and silently get classified as a credentials failure + * by the brute-force rate limiter. + */ +function buildAuthFailure(params: { + failureKind: AuthFailureKind; + errorResponse: Response; + apiKey?: string | null; +}): AuthState { + return { + user: null, + key: null, + apiKey: params.apiKey ?? null, + success: false, + failureKind: params.failureKind, + errorResponse: params.errorResponse, + }; +} + +/** + * Exhaustiveness helper. Calling this from a "should be unreachable" branch + * gives a compile-time error when a new union member is added without an + * explicit handler. + */ +function assertNever(value: never, context: string): never { + throw new Error(`Unhandled discriminant in ${context}: ${JSON.stringify(value)}`); +} /** * Pre-auth rate limiter: throttles repeated authentication failures per IP @@ -73,8 +113,13 @@ export class ProxyAuthenticator { return null; } - // Record failure for rate limiting - proxyAuthPolicy.recordFailure(clientIp, authState.apiKey ?? candidateApiKey ?? undefined); + // Only `credentials` failures should feed the brute-force rate limiter. + // `account_state` failures (key/user disabled or expired) match a real + // record, so recording them would let an admin lock themselves out by + // simply disabling a key and watching the owner retry. + if (authState.failureKind !== "account_state") { + proxyAuthPolicy.recordFailure(clientIp, authState.apiKey ?? candidateApiKey ?? undefined); + } // 返回详细的错误信息,帮助用户快速定位问题 return authState.errorResponse ?? ProxyResponses.buildError(401, "认证失败"); @@ -128,17 +173,14 @@ export class ProxyAuthenticator { hasGeminiApiKeyHeader: !!headers.geminiApiKeyHeader, hasGeminiApiKeyQuery: !!headers.geminiApiKeyQuery, }); - return { - user: null, - key: null, - apiKey: null, - success: false, + return buildAuthFailure({ + failureKind: "credentials", errorResponse: ProxyResponses.buildError( 401, "未提供认证凭据。请在 Authorization 头部、x-api-key 头部或 x-goog-api-key 头部中包含 API 密钥。", "authentication_error" ), - }; + }); } const [firstKey] = providedKeys; @@ -148,43 +190,78 @@ export class ProxyAuthenticator { logger.warn("[ProxyAuthenticator] Multiple conflicting API keys provided", { keyCount: providedKeys.length, }); - return { - user: null, - key: null, - apiKey: null, - success: false, + return buildAuthFailure({ + failureKind: "credentials", errorResponse: ProxyResponses.buildError( 401, "提供了多个冲突的 API 密钥。请仅使用一种认证方式。", "authentication_error" ), - }; + }); } const apiKey = firstKey; - const authResult = await validateApiKeyAndGetUser(apiKey); + const outcome = await resolveApiKeyAuthOutcome(apiKey); - if (!authResult) { - logger.debug("[ProxyAuthenticator] API key validation failed", { - apiKeyLength: apiKey.length, - fromHeader: !!headers.authHeader || !!headers.apiKeyHeader || !!headers.geminiApiKeyHeader, - fromQuery: !!headers.geminiApiKeyQuery, - }); - return { - user: null, - key: null, - apiKey, - success: false, - errorResponse: ProxyResponses.buildError( - 401, - "API 密钥无效。提供的密钥不存在、已被删除、已被禁用或已过期。", - "invalid_api_key" - ), - }; + if (!outcome.ok) { + // Exhaustive switch: adding a new ApiKeyAuthFailureReason in the + // repository layer will trigger a compile error in assertNever below + // until this guard is updated, preventing a new variant from silently + // falling into the wrong branch. + const locale = await getRequestLocale(); + switch (outcome.reason) { + case "not_found": + logger.debug("[ProxyAuthenticator] API key validation failed: not found", { + apiKeyLength: apiKey.length, + fromHeader: + !!headers.authHeader || !!headers.apiKeyHeader || !!headers.geminiApiKeyHeader, + fromQuery: !!headers.geminiApiKeyQuery, + }); + return buildAuthFailure({ + apiKey, + failureKind: "credentials", + errorResponse: ProxyResponses.buildError( + 401, + await getErrorMessageServer(locale, ERROR_CODES.PROXY_INVALID_API_KEY), + "invalid_api_key" + ), + }); + + case "key_disabled": + logger.warn("[ProxyAuthenticator] API key is disabled", { + apiKeyLength: apiKey.length, + }); + return buildAuthFailure({ + apiKey, + failureKind: "account_state", + errorResponse: ProxyResponses.buildError( + 401, + await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_DISABLED), + "key_disabled" + ), + }); + + case "key_expired": + logger.warn("[ProxyAuthenticator] API key has expired", { + apiKeyLength: apiKey.length, + }); + return buildAuthFailure({ + apiKey, + failureKind: "account_state", + errorResponse: ProxyResponses.buildError( + 401, + await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_EXPIRED), + "key_expired" + ), + }); + + default: + assertNever(outcome.reason, "ProxyAuthenticator.validate outcome.reason"); + } } // Check user status and expiration - const { user } = authResult; + const { user } = outcome; // 1. Check if user is disabled if (!user.isEnabled) { @@ -192,17 +269,15 @@ export class ProxyAuthenticator { userId: user.id, userName: user.name, }); - return { - user: null, - key: null, + return buildAuthFailure({ apiKey, - success: false, + failureKind: "account_state", errorResponse: ProxyResponses.buildError( 401, "用户账户已被禁用。请联系管理员。", "user_disabled" ), - }; + }); } // 2. Check if user is expired (lazy expiration check) @@ -219,26 +294,24 @@ export class ProxyAuthenticator { error: error instanceof Error ? error.message : String(error), }); }); - return { - user: null, - key: null, + return buildAuthFailure({ apiKey, - success: false, + failureKind: "account_state", errorResponse: ProxyResponses.buildError( 401, `用户账户已于 ${user.expiresAt.toISOString().split("T")[0]} 过期。请续费订阅。`, "user_expired" ), - }; + }); } logger.debug("[ProxyAuthenticator] Authentication successful", { - userId: authResult.user.id, - userName: authResult.user.name, - keyName: authResult.key.name, + userId: outcome.user.id, + userName: outcome.user.name, + keyName: outcome.key.name, }); - return { user: authResult.user, key: authResult.key, apiKey, success: true }; + return { user: outcome.user, key: outcome.key, apiKey, success: true }; } private static extractKeyFromAuthorization(authHeader?: string): string | null { diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index dc2578ce3..b6dfda24e 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -30,12 +30,31 @@ import { parseOpenAIImageMultipartMetadata, } from "./openai-image-compat"; +/** + * Classification of an auth failure, used to decide whether to record the + * failure against the brute-force rate limiter. + * + * - `credentials`: the request did not present a valid key (missing, + * malformed, multiple conflicting keys, or the key does not match any + * record). These look like brute-force probes — record the failure. + * - `account_state`: the credentials matched a real record but the + * key/user is disabled, expired, or otherwise administratively rejected. + * Recording these as failures would lock out legitimate operators whose + * keys were disabled by an admin. + */ +export type AuthFailureKind = "credentials" | "account_state"; + export interface AuthState { user: User | null; key: Key | null; apiKey: string | null; success: boolean; errorResponse?: Response; // 认证失败时的详细错误响应 + /** + * Set when `success` is false. Determines whether the proxy auth guard + * records the failure against the IP/key rate-limiter. + */ + failureKind?: AuthFailureKind; } export interface MessageContext { diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 4d5e6f0bd..8e0560b0f 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -45,6 +45,29 @@ const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE = 400; const FINALIZED_REQUEST_OUTCOME_ALIAS = "successRateOutcome" as const; const FINALIZED_REQUEST_OUTCOME_SQL = sql.raw(`"${FINALIZED_REQUEST_OUTCOME_ALIAS}"`); const COUNTABLE_REQUEST_OUTCOME_SQL = sql`${FINALIZED_REQUEST_OUTCOME_SQL} IN ('success', 'failure')`; + +/** + * Provider-chain `reason` values that, when present on the last chain entry, + * indicate the message-request has reached a terminal state. + * + * Mirrors the list inside `fn_is_message_request_finalized` (drizzle/0095_*.sql) + * and `fn_compute_message_request_success_rate_outcome` — keep in sync. + */ +const FINALIZED_PROVIDER_CHAIN_REASONS = [ + "request_success", + "retry_success", + "retry_failed", + "system_error", + "resource_not_found", + "client_error_non_retryable", + "concurrent_limit_failed", + "hedge_winner", + "hedge_loser_cancelled", + "client_abort", +] as const; +const FINALIZED_PROVIDER_CHAIN_REASONS_SQL = sql.raw( + FINALIZED_PROVIDER_CHAIN_REASONS.map((reason) => `'${reason}'`).join(", ") +); // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. // It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. export const MAX_BUCKETS_HARD_LIMIT = 100; @@ -62,18 +85,61 @@ export class AvailabilityQueryValidationError extends Error { } /** - * 当前版本把“已终态”收敛为 `statusCode` 已落库。 + * "Finalized request" predicate used in the availability CTE WHERE clause. * - * 已知限制:在当前异步写入/丢 patch 的极端场景,或未来新增了 `durationMs` / `errorMessage` - * 已落库、但 `statusCode` 仍为空且已稳定结束的写路径时,这些记录会被当前可用性统计排除。 - * 届时应引入独立的 finalized 谓词,而不是直接放宽为 `durationMs IS NOT NULL`。 + * SEMANTICALLY EQUIVALENT to `fn_is_message_request_finalized(blocked_by, + * status_code, provider_chain, error_message)` defined in drizzle/0095_*.sql + * (re-affirmed in 0097_*.sql / 0098_*.sql). It is intentionally inlined here + * because PostgreSQL does NOT inline PL/pgSQL functions, which means calling + * the function in the WHERE clause makes the predicate opaque to the + * planner. That hides the dominant `status_code IS NOT NULL` branch and + * prevents the planner from using the partial index + * `idx_message_request_provider_created_at_finalized_active` + * (predicate: `status_code IS NOT NULL AND deleted_at IS NULL`), which + * collapses the dashboard query into a sequential scan. + * + * KEEP IN SYNC with `fn_is_message_request_finalized` in + * drizzle/0095_young_lily_hollister.sql; the trigger and the row-level + * outcome function still call the SQL function (per-row write path, not + * latency critical), so the canonical definition stays in PL/pgSQL. */ function buildAvailabilityFinalizedCondition() { - return sql`fn_is_message_request_finalized( - ${messageRequest.blockedBy}, - ${messageRequest.statusCode}, - ${messageRequest.providerChain}, - ${messageRequest.errorMessage} + // The `IS NOT NULL` checks below are individually SARGable. Listing + // status_code first encourages the planner to scan the partial index. + // + // The provider_chain branch wraps each jsonb-array operation in a CASE + // because PostgreSQL does NOT guarantee left-to-right short-circuit of + // AND / OR (see PG docs on Logical Operators). Without CASE, an + // observed-rare-but-legal historical row where `provider_chain` is a + // non-array jsonb value (object, scalar, or json null) would make + // `jsonb_array_length(...)` raise `cannot get array length of a non-array` + // and crash the dashboard query. + // + // The `?` JSONB key-existence operator on the last line is correct under + // the `pg` driver Drizzle uses today (parameterized via `$N`). If we ever + // swap drivers (e.g. `postgres.js`) bare `?` may be reinterpreted as a + // positional placeholder; either change the driver or use + // `jsonb_exists(..., 'statusCode')` at that point. + return sql`( + ${messageRequest.statusCode} IS NOT NULL + OR ${messageRequest.blockedBy} IS NOT NULL + OR COALESCE(${messageRequest.errorMessage}, '') <> '' + OR ( + CASE + WHEN ${messageRequest.providerChain} IS NULL THEN FALSE + WHEN jsonb_typeof(${messageRequest.providerChain}) <> 'array' THEN FALSE + WHEN jsonb_array_length(${messageRequest.providerChain}) = 0 THEN FALSE + WHEN jsonb_typeof(${messageRequest.providerChain} -> -1) <> 'object' THEN FALSE + ELSE ( + (${messageRequest.providerChain} -> -1 ->> 'reason') IN (${FINALIZED_PROVIDER_CHAIN_REASONS_SQL}) + OR ( + (${messageRequest.providerChain} -> -1 ? 'statusCode') + AND jsonb_typeof(${messageRequest.providerChain} -> -1 -> 'statusCode') = 'number' + ) + OR COALESCE(${messageRequest.providerChain} -> -1 ->> 'errorMessage', '') <> '' + ) + END + ) )`; } diff --git a/src/lib/public-status/config-snapshot.ts b/src/lib/public-status/config-snapshot.ts index 6a76582d2..c40b9736a 100644 --- a/src/lib/public-status/config-snapshot.ts +++ b/src/lib/public-status/config-snapshot.ts @@ -8,6 +8,27 @@ import { export const DEFAULT_PUBLIC_STATUS_SITE_DESCRIPTION = "Request-derived public status"; +/** + * TTL (seconds) applied to *versioned* config snapshot keys written by this + * module — i.e. `public-status:v1:config:` and the internal variant. + * + * 30 days matches `GENERATION_PROJECTION_TTL_SECONDS` in `rebuild-worker.ts`, + * which already governs the manifest / series / snapshot keys for this + * feature. These keys accumulate forever otherwise, since every config + * publication mints a new version (provider/group/system-settings changes). + * + * The three *current-pointer* keys + * (`buildPublicStatusConfigSnapshotKey()` with no argument, + * `buildPublicStatusInternalConfigSnapshotKey()` with no argument, and + * `buildPublicStatusConfigVersionPointerKey()`) are intentionally written + * WITHOUT a TTL — they always reference the latest config and are + * overwritten atomically on every publish, so they never accumulate. Giving + * them a TTL would dark out the public status page on any deployment that + * goes longer than the TTL without publishing (e.g. an idle staging env or + * a stable production whose config does not change for a month). + */ +export const PUBLIC_STATUS_CONFIG_TTL_SECONDS = 60 * 60 * 24 * 30; + export interface PublicStatusModelSnapshot { publicModelKey: string; label: string; @@ -67,6 +88,10 @@ interface BuildPublicStatusConfigSnapshotInput { } interface RedisWriter { + // ioredis supports both bare `set(key, value)` and the EX-variant + // `set(key, value, "EX", seconds)`. Widening the type here lets us pass + // an explicit TTL on every write — see PUBLIC_STATUS_CONFIG_TTL_SECONDS. + set(key: string, value: string, mode: "EX", seconds: number): Promise | unknown; set(key: string, value: string): Promise | unknown; get?(key: string): Promise | string | null; eval?(script: string, numKeys: number, ...args: string[]): Promise | unknown; @@ -205,8 +230,11 @@ export async function publishPublicStatusConfigSnapshot(input: { const redis = input.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); if (redis) { - await redis.set(key, JSON.stringify(snapshot)); + // Versioned snapshot key: TTL'd so old versions get cleaned up. + await redis.set(key, JSON.stringify(snapshot), "EX", PUBLIC_STATUS_CONFIG_TTL_SECONDS); if (input.setCurrentPointer !== false) { + // Current-pointer key: no TTL. It is overwritten on every publish and + // is the only entry-point the read path uses — see TTL constant above. await redis.set( buildPublicStatusConfigSnapshotKey(), JSON.stringify({ key, configVersion: snapshot.configVersion }) @@ -242,8 +270,10 @@ export async function publishInternalPublicStatusConfigSnapshot(input: { const redis = input.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); if (redis) { - await redis.set(key, JSON.stringify(input.snapshot)); + // Versioned snapshot key: TTL'd so old versions get cleaned up. + await redis.set(key, JSON.stringify(input.snapshot), "EX", PUBLIC_STATUS_CONFIG_TTL_SECONDS); if (input.setCurrentPointer !== false) { + // Current-pointer key: no TTL — see TTL constant rationale above. await redis.set( buildPublicStatusInternalConfigSnapshotKey(), JSON.stringify({ key, configVersion: input.snapshot.configVersion }) @@ -268,6 +298,10 @@ export async function publishCurrentPublicStatusConfigPointers(input: { } const pointerKey = buildPublicStatusConfigVersionPointerKey(); + // This is a current-pointer key: do NOT apply a TTL. It is overwritten on + // every successful publish; an idle deployment with no config change for + // longer than any TTL we would pick would otherwise lose the pointer and + // dark out the public status page. if (typeof redis.eval === "function") { const luaScript = ` local current = redis.call('GET', KEYS[1]) diff --git a/src/lib/utils/error-messages.ts b/src/lib/utils/error-messages.ts index c44868025..176141041 100644 --- a/src/lib/utils/error-messages.ts +++ b/src/lib/utils/error-messages.ts @@ -64,6 +64,10 @@ export const AUTH_ERRORS = { PERMISSION_DENIED: "PERMISSION_DENIED", TOKEN_REQUIRED: "TOKEN_REQUIRED", INVALID_TOKEN: "INVALID_TOKEN", + // Proxy-side API key auth outcomes — surfaced to /v1/* and /v1/models clients. + PROXY_INVALID_API_KEY: "PROXY_INVALID_API_KEY", + PROXY_API_KEY_DISABLED: "PROXY_API_KEY_DISABLED", + PROXY_API_KEY_EXPIRED: "PROXY_API_KEY_EXPIRED", } as const; // Server Error Codes diff --git a/src/repository/index.ts b/src/repository/index.ts index 299768236..3a41b23d0 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -5,6 +5,7 @@ import "server-only"; * 提供所有数据访问接口的统一入口 */ +export type { ApiKeyAuthFailureReason, ApiKeyAuthOutcome } from "./key"; // Key related exports export { countActiveKeysByUser, @@ -18,6 +19,7 @@ export { findKeysWithStatisticsBatch, findKeyUsageToday, findKeyUsageTodayBatch, + resolveApiKeyAuthOutcome, updateKey, validateApiKeyAndGetUser, } from "./key"; diff --git a/src/repository/key.ts b/src/repository/key.ts index 8cd19c788..029573145 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -541,13 +541,35 @@ export async function findActiveKeyByKeyString(keyString: string): Promise { +/** + * Failure reasons surfaced by {@link resolveApiKeyAuthOutcome}. + * + * - `not_found`: key string does not exist (or matches a soft-deleted row, or + * the owning user was soft-deleted). Treated as a potential brute-force + * signal by the proxy auth guard. + * - `key_disabled` / `key_expired`: the key exists and the requester proved + * knowledge of it, but the key itself is no longer valid. These are NOT + * brute-force signals — they should return a specific error to the caller + * without incrementing the auth-failure rate limiter. + */ +export type ApiKeyAuthFailureReason = "not_found" | "key_disabled" | "key_expired"; + +export type ApiKeyAuthOutcome = + | { ok: true; user: User; key: Key } + | { ok: false; reason: ApiKeyAuthFailureReason }; + +/** + * Look up an API key and report a specific outcome so callers can distinguish + * "key never existed" from "key exists but disabled/expired". User-level + * status (disabled / expired) is intentionally NOT folded into this result — + * the caller inspects `user.isEnabled` / `user.expiresAt` directly so it can + * apply consistent semantics across the proxy and UI auth paths. + */ +export async function resolveApiKeyAuthOutcome(keyString: string): Promise { const vfSaysMissing = apiKeyVacuumFilter.isDefinitelyNotPresent(keyString) === true; // 默认鉴权链路:Vacuum Filter -> Redis -> DB + // Redis 缓存只保存活跃 key,命中即代表 ok=true。 const cachedKey = await getCachedActiveKey(keyString); if (cachedKey) { // 多实例一致性:若 Vacuum Filter 判定缺失但 Redis 命中,说明本机 filter 可能滞后。 @@ -558,7 +580,7 @@ export async function validateApiKeyAndGetUser( const cachedUser = await getCachedUser(cachedKey.userId); if (cachedUser) { - return { user: cachedUser, key: cachedKey }; + return { ok: true, user: cachedUser, key: cachedKey }; } // user 缓存 miss:仅补齐 user(相较 join 更轻量) @@ -596,20 +618,22 @@ export async function validateApiKeyAndGetUser( if (!userRow) { // join 语义:用户被删除则 key 无效;顺带清理 key 缓存避免重复 miss invalidateCachedKey(keyString).catch(() => {}); - return null; + return { ok: false, reason: "not_found" }; } const user = toUser(userRow); cacheUser(user).catch(() => {}); - return { user, key: cachedKey }; + return { ok: true, user, key: cachedKey }; } - // Vacuum Filter 负向短路:肯定不存在则直接返回 null,避免打 DB + // Vacuum Filter 负向短路:肯定不存在则直接返回 not_found,避免打 DB // 注意:此处必须放在 Redis 读取之后,避免多实例环境中新建 key 的短暂误拒绝窗口。 if (vfSaysMissing) { - return null; + return { ok: false, reason: "not_found" }; } + // 注意:此处放宽 WHERE 条件,不再过滤 isEnabled / expiresAt,由后续逻辑分类失败原因。 + // 用户软删除仍直接折叠成 not_found(用户不存在的语义与 key 不存在等价)。 const result = await db .select({ // Key fields @@ -663,21 +687,32 @@ export async function validateApiKeyAndGetUser( }) .from(keys) .innerJoin(users, eq(keys.userId, users.id)) - .where( - and( - eq(keys.key, keyString), - isNull(keys.deletedAt), - eq(keys.isEnabled, true), - or(isNull(keys.expiresAt), gt(keys.expiresAt, new Date())), - isNull(users.deletedAt) - ) - ); + .where(and(eq(keys.key, keyString), isNull(keys.deletedAt), isNull(users.deletedAt))); if (result.length === 0) { - return null; + return { ok: false, reason: "not_found" }; + } + + // `keys.key` is not unique, so multiple rows can match a single key string. + // Picking `result[0]` would be non-deterministic and could mis-classify an + // active duplicate as `key_disabled` if a disabled row sorted first. Prefer + // the most favourable status across all matching rows: ok > expired > disabled. + const now = Date.now(); + const activeRow = result.find( + (candidate) => + candidate.keyIsEnabled === true && + (!candidate.keyExpiresAt || candidate.keyExpiresAt.getTime() > now) + ); + + if (!activeRow) { + const expiredRow = result.find((candidate) => candidate.keyIsEnabled === true); + if (expiredRow) { + return { ok: false, reason: "key_expired" }; + } + return { ok: false, reason: "key_disabled" }; } - const row = result[0]; + const row = activeRow; const user: User = toUser({ id: row.userId, @@ -733,7 +768,20 @@ export async function validateApiKeyAndGetUser( // 最佳努力:写入 Redis 缓存(不影响正确性) cacheAuthResult(keyString, { user, key }).catch(() => {}); - return { user, key }; + return { ok: true, user, key }; +} + +/** + * Backwards-compatible wrapper around {@link resolveApiKeyAuthOutcome}: returns + * `null` on any lookup failure. Callers that need to distinguish failure + * reasons (e.g. the proxy auth guard) should call `resolveApiKeyAuthOutcome` + * directly. + */ +export async function validateApiKeyAndGetUser( + keyString: string +): Promise<{ user: User; key: Key } | null> { + const outcome = await resolveApiKeyAuthOutcome(keyString); + return outcome.ok ? { user: outcome.user, key: outcome.key } : null; } /** diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index ac14b0013..8a8d38a8e 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -309,7 +309,20 @@ describe("availability-service", () => { const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); - expect(finalizedRequestsSql).toContain("fn_is_message_request_finalized"); + // The "finalized" predicate is inlined as a SARGable expression (not a + // function call) so the planner can use the partial index on + // status_code IS NOT NULL. + expect(finalizedRequestsSql).not.toContain("fn_is_message_request_finalized"); + expect(finalizedRequestsSql).toContain(`"status_code" is not null`); + expect(finalizedRequestsSql).toContain(`"blocked_by" is not null`); + expect(finalizedRequestsSql).toContain(`"provider_chain" -> -1 ->> 'reason'`); + // The provider_chain branch must wrap jsonb operations in a CASE so the + // dashboard query does not crash on a non-array historical row + // (PostgreSQL does not guarantee AND short-circuit). + expect(finalizedRequestsSql).toContain("case"); + expect(finalizedRequestsSql).toContain( + `jsonb_typeof("message_request"."provider_chain") <> 'array'` + ); expect(queryText).toContain("group by"); expect(queryText).toContain("percentile_cont(0.95)"); expect(queryText).toContain("row_number() over"); @@ -482,7 +495,13 @@ describe("availability-service", () => { const finalizedRequestsSql = extractFinalizedRequestsSql( normalizeSql(executeMock.mock.calls[0]?.[0]) ); - expect(finalizedRequestsSql).toContain("fn_is_message_request_finalized"); + // The "finalized" predicate is inlined as a SARGable expression (not a + // function call) so the planner can use the partial index on + // status_code IS NOT NULL. + expect(finalizedRequestsSql).not.toContain("fn_is_message_request_finalized"); + expect(finalizedRequestsSql).toContain(`"status_code" is not null`); + expect(finalizedRequestsSql).toContain(`"blocked_by" is not null`); + expect(finalizedRequestsSql).toContain(`"provider_chain" -> -1 ->> 'reason'`); }); it("queryProviderAvailability 会保留 Gemini passthrough 终态(statusCode!=null 且 durationMs=null)", async () => { @@ -548,7 +567,13 @@ describe("availability-service", () => { const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); - expect(finalizedRequestsSql).toContain("fn_is_message_request_finalized"); + // The "finalized" predicate is inlined as a SARGable expression (not a + // function call) so the planner can use the partial index on + // status_code IS NOT NULL. + expect(finalizedRequestsSql).not.toContain("fn_is_message_request_finalized"); + expect(finalizedRequestsSql).toContain(`"status_code" is not null`); + expect(finalizedRequestsSql).toContain(`"blocked_by" is not null`); + expect(finalizedRequestsSql).toContain(`"provider_chain" -> -1 ->> 'reason'`); expect(queryText).toContain("fn_compute_message_request_success_rate_outcome"); expect(queryText).toContain(`"successrateoutcome" = 'failure'`); }); @@ -717,7 +742,11 @@ describe("availability-service", () => { ]); const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); - expect(queryText).toContain("fn_is_message_request_finalized"); + // Inlined finalized predicate (planner-transparent; see + // buildAvailabilityFinalizedCondition in availability-service.ts). + expect(queryText).not.toContain("fn_is_message_request_finalized"); + expect(queryText).toContain(`"status_code" is not null`); + expect(queryText).toContain(`"blocked_by" is not null`); expect(queryText).toContain(">= now() - (15 * interval '1 minute')"); expect(queryText).toContain("<= now()"); expect(queryText).toContain("count(*) filter"); diff --git a/tests/unit/models/available-models-auth-outcome.test.ts b/tests/unit/models/available-models-auth-outcome.test.ts new file mode 100644 index 000000000..1be8e287b --- /dev/null +++ b/tests/unit/models/available-models-auth-outcome.test.ts @@ -0,0 +1,181 @@ +/** + * Regression coverage for the `/v1/models` auth chain: every + * `ApiKeyAuthFailureReason` branch and every user-state branch must surface + * the correct 401 `error.type` so a downstream regression back to a generic + * `invalid_api_key` would be caught. + * + * Companion to `tests/unit/proxy/auth-guard-account-state.test.ts`, which + * covers the same matrix on the proxy auth guard. + */ + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/repository/key", () => ({ + resolveApiKeyAuthOutcome: vi.fn(), +})); + +vi.mock("@/lib/proxy-agent", () => ({ + createProxyAgentForProvider: vi.fn(), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("UTC"), +})); + +vi.mock("@/lib/utils/provider-schedule", () => ({ + isProviderActiveNow: vi.fn().mockReturnValue(true), +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + checkProviderGroupMatch: vi.fn().mockReturnValue(true), +})); + +vi.mock("@/repository/provider", () => ({ + findAllProviders: vi.fn().mockResolvedValue([]), +})); + +vi.mock("next-intl/server", () => ({ + getLocale: vi.fn().mockResolvedValue("en"), +})); + +vi.mock("@/lib/utils/error-messages", async () => { + const actual = await vi.importActual( + "@/lib/utils/error-messages" + ); + return { + ...actual, + getErrorMessageServer: vi.fn(async (_locale: string, code: string) => code), + }; +}); + +function makeContext(apiKey: string) { + let thrown: Response | null = null; + const ctx = { + req: { + path: "/v1/models", + url: "http://localhost/v1/models", + method: "GET", + header: (name: string) => { + const normalized = name.toLowerCase(); + if (normalized === "x-api-key") return apiKey; + if (normalized === "anthropic-version") return undefined; + return undefined; + }, + query: () => undefined, + }, + json: (body: unknown, status?: number) => { + thrown = new Response(JSON.stringify(body), { + status: status ?? 200, + headers: { "content-type": "application/json" }, + }); + return thrown; + }, + getResponse: () => thrown, + }; + return ctx; +} + +async function callAuthAndCaptureResponse(ctx: ReturnType): Promise { + // handleAvailableModels invokes authenticateRequest internally; both + // resolve through Hono's c.json(...) which the makeContext helper stashes. + // Because the auth helper throws the c.json response, we catch and inspect. + const { handleAvailableModels } = await import("@/app/v1/_lib/models/available-models"); + try { + await handleAvailableModels(ctx as never); + } catch (thrown) { + if (thrown instanceof Response) { + return thrown; + } + throw thrown; + } + // Fallthrough: ctx.json was called via authenticateRequest with throw, so + // the stashed response is what we want. + const response = ctx.getResponse(); + if (!response) { + throw new Error("Expected handleAvailableModels to throw an auth response"); + } + return response; +} + +async function readErrorBody(response: Response) { + const json = (await response.json()) as { error: { message: string; type: string } }; + return json.error; +} + +describe("handleAvailableModels auth outcomes", () => { + it("returns 401 key_disabled for a disabled key", async () => { + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ + ok: false, + reason: "key_disabled", + }); + + const response = await callAuthAndCaptureResponse(makeContext("sk-disabled")); + + expect(response.status).toBe(401); + const error = await readErrorBody(response); + expect(error.type).toBe("key_disabled"); + expect(error.message).toBe("PROXY_API_KEY_DISABLED"); + }); + + it("returns 401 key_expired for an expired key", async () => { + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ + ok: false, + reason: "key_expired", + }); + + const response = await callAuthAndCaptureResponse(makeContext("sk-expired")); + + expect(response.status).toBe(401); + const error = await readErrorBody(response); + expect(error.type).toBe("key_expired"); + expect(error.message).toBe("PROXY_API_KEY_EXPIRED"); + }); + + it("returns 401 invalid_api_key for an unknown key", async () => { + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ + ok: false, + reason: "not_found", + }); + + const response = await callAuthAndCaptureResponse(makeContext("sk-unknown")); + + expect(response.status).toBe(401); + const error = await readErrorBody(response); + expect(error.type).toBe("invalid_api_key"); + expect(error.message).toBe("PROXY_INVALID_API_KEY"); + }); + + it("returns 401 user_disabled when the user account is disabled", async () => { + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ + ok: true, + user: { id: 42, providerGroup: null, isEnabled: false, expiresAt: null }, + key: { providerGroup: null, name: "x" }, + } as never); + + const response = await callAuthAndCaptureResponse(makeContext("sk-userdisabled")); + + expect(response.status).toBe(401); + const error = await readErrorBody(response); + expect(error.type).toBe("user_disabled"); + }); + + it("returns 401 user_expired when the user account is expired", async () => { + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ + ok: true, + user: { id: 43, providerGroup: null, isEnabled: true, expiresAt: yesterday }, + key: { providerGroup: null, name: "x" }, + } as never); + + const response = await callAuthAndCaptureResponse(makeContext("sk-userexpired")); + + expect(response.status).toBe(401); + const error = await readErrorBody(response); + expect(error.type).toBe("user_expired"); + }); +}); diff --git a/tests/unit/models/available-models-gemini-key.test.ts b/tests/unit/models/available-models-gemini-key.test.ts index 640a930e5..22421d7cb 100644 --- a/tests/unit/models/available-models-gemini-key.test.ts +++ b/tests/unit/models/available-models-gemini-key.test.ts @@ -25,6 +25,11 @@ vi.mock("@/repository/key", () => { user: { id: 1, providerGroup: null, isEnabled: true, expiresAt: null }, key: { providerGroup: null, name: "test-key" }, })), + resolveApiKeyAuthOutcome: vi.fn(async () => ({ + ok: true, + user: { id: 1, providerGroup: null, isEnabled: true, expiresAt: null }, + key: { providerGroup: null, name: "test-key" }, + })), }; }); diff --git a/tests/unit/proxy/auth-guard-account-state.test.ts b/tests/unit/proxy/auth-guard-account-state.test.ts new file mode 100644 index 000000000..2f94b5704 --- /dev/null +++ b/tests/unit/proxy/auth-guard-account-state.test.ts @@ -0,0 +1,233 @@ +/** + * Regression coverage for the auth guard's handling of account-state failures: + * key disabled, key expired, user disabled, user expired. + * + * Each scenario must: + * - return HTTP 401 (NOT 429 — these are not rate-limit violations) + * - return a distinct, machine-readable error type + * - NOT call `recordFailure` on the brute-force rate limiter, since admin + * actions should not lock out the legitimate operator behind the key + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveApiKeyAuthOutcome = vi.hoisted(() => vi.fn()); +const policyCheck = vi.hoisted(() => vi.fn()); +const policyRecordSuccess = vi.hoisted(() => vi.fn()); +const policyRecordFailure = vi.hoisted(() => vi.fn()); +const markUserExpired = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("@/repository/key", () => ({ + resolveApiKeyAuthOutcome, +})); + +vi.mock("@/repository/user", () => ({ + markUserExpired, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/security/login-abuse-policy", () => ({ + LoginAbusePolicy: class { + check = policyCheck; + recordSuccess = policyRecordSuccess; + recordFailure = policyRecordFailure; + }, +})); + +// Bypass next-intl's request-context lookup so the proxy guard can run +// outside a real Next.js request. The mocked helper returns the i18n code +// itself so message assertions can pin on the key, not on translation +// content (which lives in messages//errors.json). +vi.mock("next-intl/server", () => ({ + getLocale: vi.fn().mockResolvedValue("en"), +})); + +vi.mock("@/lib/utils/error-messages", async () => { + const actual = await vi.importActual( + "@/lib/utils/error-messages" + ); + return { + ...actual, + getErrorMessageServer: vi.fn(async (_locale: string, code: string) => code), + }; +}); + +function makeSession(ip: string, apiKey: string) { + return { + headers: new Headers({ + "x-real-ip": ip, + "x-api-key": apiKey, + }), + requestUrl: new URL("http://localhost/v1/messages"), + clientIp: null as string | null, + authState: null as unknown, + setAuthState(state: unknown) { + this.authState = state; + }, + }; +} + +async function readErrorBody(response: Response) { + const json = (await response.clone().json()) as { + error: { message: string; type: string; code: string }; + }; + return json.error; +} + +describe("ProxyAuthenticator account-state failures", () => { + beforeEach(() => { + vi.resetModules(); + resolveApiKeyAuthOutcome.mockReset(); + policyCheck.mockReset().mockReturnValue({ allowed: true }); + policyRecordSuccess.mockReset(); + policyRecordFailure.mockReset(); + // vitest config sets mockReset: true globally, which wipes the + // mockResolvedValue from the hoisted setup. Re-apply per-test. + markUserExpired.mockReset().mockResolvedValue(undefined); + }); + + it("disabled key returns 401 key_disabled and does NOT count toward rate limit", async () => { + resolveApiKeyAuthOutcome.mockResolvedValue({ ok: false, reason: "key_disabled" }); + + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + const session = makeSession("203.0.113.20", "sk-disabled"); + const response = await ProxyAuthenticator.ensure(session as never); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(401); + + const error = await readErrorBody(response as Response); + expect(error.type).toBe("key_disabled"); + expect(error.code).toBe("key_disabled"); + // Message is the i18n key (mocked); the actual localized text lives in + // messages//errors.json under this key. + expect(error.message).toBe("PROXY_API_KEY_DISABLED"); + + expect(policyRecordFailure).not.toHaveBeenCalled(); + expect(policyRecordSuccess).not.toHaveBeenCalled(); + }); + + it("expired key returns 401 key_expired and does NOT count toward rate limit", async () => { + resolveApiKeyAuthOutcome.mockResolvedValue({ ok: false, reason: "key_expired" }); + + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + const session = makeSession("203.0.113.21", "sk-expired"); + const response = await ProxyAuthenticator.ensure(session as never); + + expect(response?.status).toBe(401); + + const error = await readErrorBody(response as Response); + expect(error.type).toBe("key_expired"); + expect(error.code).toBe("key_expired"); + expect(error.message).toBe("PROXY_API_KEY_EXPIRED"); + + expect(policyRecordFailure).not.toHaveBeenCalled(); + }); + + it("disabled user returns 401 user_disabled and does NOT count toward rate limit", async () => { + resolveApiKeyAuthOutcome.mockResolvedValue({ + ok: true, + user: { id: 7, name: "bob", isEnabled: false, expiresAt: null }, + key: { name: "bobs-key" }, + }); + + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + const session = makeSession("203.0.113.22", "sk-userdisabled"); + const response = await ProxyAuthenticator.ensure(session as never); + + expect(response?.status).toBe(401); + + const error = await readErrorBody(response as Response); + expect(error.type).toBe("user_disabled"); + expect(error.message).toMatch(/账户已被禁用/); + + expect(policyRecordFailure).not.toHaveBeenCalled(); + }); + + it("expired user returns 401 user_expired, marks the user expired, and does NOT count toward rate limit", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + resolveApiKeyAuthOutcome.mockResolvedValue({ + ok: true, + user: { id: 8, name: "carol", isEnabled: true, expiresAt: yesterday }, + key: { name: "carols-key" }, + }); + + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + const session = makeSession("203.0.113.23", "sk-userexpired"); + const response = await ProxyAuthenticator.ensure(session as never); + + expect(response?.status).toBe(401); + + const error = await readErrorBody(response as Response); + expect(error.type).toBe("user_expired"); + expect(error.message).toMatch(/已于.*过期/); + + expect(markUserExpired).toHaveBeenCalledWith(8); + expect(policyRecordFailure).not.toHaveBeenCalled(); + }); + + it("unknown key still records failure (genuine brute-force signal)", async () => { + resolveApiKeyAuthOutcome.mockResolvedValue({ ok: false, reason: "not_found" }); + + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + const session = makeSession("203.0.113.24", "sk-doesnotexist"); + const response = await ProxyAuthenticator.ensure(session as never); + + expect(response?.status).toBe(401); + + const error = await readErrorBody(response as Response); + expect(error.type).toBe("invalid_api_key"); + expect(error.message).toBe("PROXY_INVALID_API_KEY"); + + expect(policyRecordFailure).toHaveBeenCalledWith("203.0.113.24", "sk-doesnotexist"); + }); + + it("missing credentials returns 401 authentication_error and records failure", async () => { + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + const session = { + headers: new Headers({ "x-real-ip": "203.0.113.25" }), + requestUrl: new URL("http://localhost/v1/messages"), + clientIp: null as string | null, + authState: null as unknown, + setAuthState(state: unknown) { + this.authState = state; + }, + }; + const response = await ProxyAuthenticator.ensure(session as never); + + expect(response?.status).toBe(401); + + const error = await readErrorBody(response as Response); + expect(error.type).toBe("authentication_error"); + + expect(resolveApiKeyAuthOutcome).not.toHaveBeenCalled(); + expect(policyRecordFailure).toHaveBeenCalledWith("203.0.113.25", undefined); + }); + + it("repeated disabled-key attempts never trip the 429 lockout", async () => { + // Simulate the bug scenario: the same disabled key is hit 25 times in a + // row. Before the fix, the 20th attempt would trip the rate limiter and + // start returning 429s. After the fix, every attempt should return 401 + // key_disabled and the rate-limiter counter must remain untouched. + resolveApiKeyAuthOutcome.mockResolvedValue({ ok: false, reason: "key_disabled" }); + + const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); + + for (let attempt = 0; attempt < 25; attempt++) { + const session = makeSession("203.0.113.30", "sk-perma-disabled"); + const response = await ProxyAuthenticator.ensure(session as never); + expect(response?.status).toBe(401); + const error = await readErrorBody(response as Response); + expect(error.type).toBe("key_disabled"); + } + + expect(policyRecordFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/proxy/auth-guard-precheck.test.ts b/tests/unit/proxy/auth-guard-precheck.test.ts index 66a526f84..b1d2278f2 100644 --- a/tests/unit/proxy/auth-guard-precheck.test.ts +++ b/tests/unit/proxy/auth-guard-precheck.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const validateApiKeyAndGetUser = vi.hoisted(() => vi.fn()); +const resolveApiKeyAuthOutcome = vi.hoisted(() => vi.fn()); const policyCheck = vi.hoisted(() => vi.fn()); const policyRecordSuccess = vi.hoisted(() => vi.fn()); const policyRecordFailure = vi.hoisted(() => vi.fn()); vi.mock("@/repository/key", () => ({ - validateApiKeyAndGetUser, + resolveApiKeyAuthOutcome, })); vi.mock("@/repository/user", () => ({ @@ -29,6 +29,23 @@ vi.mock("@/lib/security/login-abuse-policy", () => ({ }, })); +// The auth guard now looks up the request locale to localize 401 messages +// (see ERROR_CODES.PROXY_*). Mock both helpers so unit tests can run outside +// a Next.js request context. +vi.mock("next-intl/server", () => ({ + getLocale: vi.fn().mockResolvedValue("en"), +})); + +vi.mock("@/lib/utils/error-messages", async () => { + const actual = await vi.importActual( + "@/lib/utils/error-messages" + ); + return { + ...actual, + getErrorMessageServer: vi.fn(async (_locale: string, code: string) => code), + }; +}); + function makeSession(ip: string, apiKey: string) { return { headers: new Headers({ @@ -47,7 +64,7 @@ function makeSession(ip: string, apiKey: string) { describe("ProxyAuthenticator pre-auth candidate key lockout", () => { beforeEach(() => { vi.resetModules(); - validateApiKeyAndGetUser.mockReset(); + resolveApiKeyAuthOutcome.mockReset(); policyCheck.mockReset(); policyRecordSuccess.mockReset(); policyRecordFailure.mockReset(); @@ -65,14 +82,15 @@ describe("ProxyAuthenticator pre-auth candidate key lockout", () => { const response = await ProxyAuthenticator.ensure(session as never); expect(response?.status).toBe(429); - expect(validateApiKeyAndGetUser).not.toHaveBeenCalled(); + expect(resolveApiKeyAuthOutcome).not.toHaveBeenCalled(); expect(policyCheck).toHaveBeenCalledWith("198.51.100.77", "sk-shared"); expect(session.clientIp).toBe("198.51.100.77"); }); it("resets both IP and key scopes on successful authentication", async () => { policyCheck.mockReturnValue({ allowed: true }); - validateApiKeyAndGetUser.mockResolvedValue({ + resolveApiKeyAuthOutcome.mockResolvedValue({ + ok: true, user: { id: 1, name: "alice", isEnabled: true, expiresAt: null }, key: { name: "primary-key" }, }); @@ -86,9 +104,9 @@ describe("ProxyAuthenticator pre-auth candidate key lockout", () => { expect(policyRecordFailure).not.toHaveBeenCalled(); }); - it("records failures against both IP and candidate key", async () => { + it("records failures against both IP and candidate key for unknown keys", async () => { policyCheck.mockReturnValue({ allowed: true }); - validateApiKeyAndGetUser.mockResolvedValue(null); + resolveApiKeyAuthOutcome.mockResolvedValue({ ok: false, reason: "not_found" }); const { ProxyAuthenticator } = await import("@/app/v1/_lib/proxy/auth-guard"); const session = makeSession("203.0.113.11", "sk-invalid"); diff --git a/tests/unit/proxy/available-models.test.ts b/tests/unit/proxy/available-models.test.ts index 51abfc7ac..86ce62ab2 100644 --- a/tests/unit/proxy/available-models.test.ts +++ b/tests/unit/proxy/available-models.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/proxy-agent", () => ({ vi.mock("@/repository/key", () => ({ validateApiKeyAndGetUser: vi.fn(), + resolveApiKeyAuthOutcome: vi.fn(), })); vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ diff --git a/tests/unit/public-status/config-snapshot.test.ts b/tests/unit/public-status/config-snapshot.test.ts index f8e57c7cf..ea2bcc7b3 100644 --- a/tests/unit/public-status/config-snapshot.test.ts +++ b/tests/unit/public-status/config-snapshot.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; interface ConfigSnapshotModule { + PUBLIC_STATUS_CONFIG_TTL_SECONDS: number; buildPublicStatusConfigSnapshot(input: { configVersion: string; siteTitle: string; @@ -43,11 +44,44 @@ interface ConfigSnapshotModule { get: (key: string) => Promise; }; }): Promise<{ siteTitle: string; siteDescription: string } | null>; + publishPublicStatusConfigSnapshot(input: { + reason: string; + snapshot?: { + configVersion: string; + generatedAt: string; + siteTitle: string; + siteDescription: string; + timeZone: string | null; + defaultIntervalMinutes: number; + defaultRangeHours: number; + groups: unknown[]; + }; + redis: { + set: (...args: unknown[]) => Promise; + }; + setCurrentPointer?: boolean; + }): Promise<{ configVersion: string; key: string; written: boolean }>; + publishInternalPublicStatusConfigSnapshot(input: { + snapshot: { + configVersion: string; + generatedAt: string; + siteTitle: string; + siteDescription: string; + timeZone: string | null; + defaultIntervalMinutes: number; + defaultRangeHours: number; + groups: unknown[]; + }; + redis: { + set: (...args: unknown[]) => Promise; + }; + setCurrentPointer?: boolean; + }): Promise<{ configVersion: string; key: string; written: boolean }>; publishCurrentPublicStatusConfigPointers(input: { configVersion: string; redis: { - set: (key: string, value: string) => Promise; - eval: (script: string, numKeys: number, ...args: string[]) => Promise; + set: (...args: unknown[]) => Promise; + eval?: (script: string, numKeys: number, ...args: string[]) => Promise; }; }): Promise; } @@ -151,4 +185,133 @@ describe("public-status config snapshot", () => { ).resolves.toBe(false); expect(redis.set).not.toHaveBeenCalled(); }); + + // Regression: before this guard, every config publish wrote Redis keys + // without TTL, so versioned snapshot keys accumulated forever as + // provider/group/system-settings changes minted new config versions. + describe("Redis TTL strategy: only versioned snapshot keys get a TTL", () => { + // Versioned keys (`public-status:v1:config:` and the internal + // variant) accumulate forever as configs are republished and MUST expire. + // The three "current pointer" keys are overwritten on every publish, so + // they MUST NOT carry a TTL — otherwise an idle deployment that goes + // longer than the TTL without a config change would lose its pointer and + // the public status page would silently go dark. + + it("publishPublicStatusConfigSnapshot TTLs the versioned key but leaves the pointer untouched", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + const ttl = mod.PUBLIC_STATUS_CONFIG_TTL_SECONDS; + expect(ttl).toBeGreaterThan(0); + + const redis = { set: vi.fn().mockResolvedValue("OK") }; + + await mod.publishPublicStatusConfigSnapshot({ + reason: "test", + snapshot: { + configVersion: "cfg-2", + generatedAt: new Date().toISOString(), + siteTitle: "Test", + siteDescription: "Test", + timeZone: null, + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [], + }, + redis, + }); + + expect(redis.set).toHaveBeenCalledTimes(2); + // First call: versioned key — MUST have EX TTL. + const [versionedKey, , versionedMode, versionedTtl] = redis.set.mock.calls[0]; + expect(versionedKey).toMatch(/:config:cfg-2$/); + expect(versionedMode).toBe("EX"); + expect(versionedTtl).toBe(ttl); + // Second call: current pointer key — MUST be bare set with no TTL. + const pointerCall = redis.set.mock.calls[1]; + expect(pointerCall).toHaveLength(2); + expect(pointerCall[0]).toMatch(/:config:current$/); + }); + + it("publishInternalPublicStatusConfigSnapshot TTLs the versioned key but leaves the pointer untouched", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + const ttl = mod.PUBLIC_STATUS_CONFIG_TTL_SECONDS; + + const redis = { set: vi.fn().mockResolvedValue("OK") }; + + await mod.publishInternalPublicStatusConfigSnapshot({ + snapshot: { + configVersion: "cfg-3", + generatedAt: new Date().toISOString(), + siteTitle: "Test", + siteDescription: "Test", + timeZone: null, + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [], + }, + redis, + }); + + expect(redis.set).toHaveBeenCalledTimes(2); + const [versionedKey, , versionedMode, versionedTtl] = redis.set.mock.calls[0]; + expect(versionedKey).toMatch(/:config-internal:cfg-3$/); + expect(versionedMode).toBe("EX"); + expect(versionedTtl).toBe(ttl); + const pointerCall = redis.set.mock.calls[1]; + expect(pointerCall).toHaveLength(2); + expect(pointerCall[0]).toMatch(/:config-internal:current$/); + }); + + it("publishCurrentPublicStatusConfigPointers (eval path) writes the pointer without TTL", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + + const evalMock = vi.fn().mockResolvedValue(1); + const redis = { + set: vi.fn().mockResolvedValue("OK"), + eval: evalMock, + }; + + await expect( + mod.publishCurrentPublicStatusConfigPointers({ configVersion: "cfg-99", redis }) + ).resolves.toBe(true); + + expect(evalMock).toHaveBeenCalledTimes(1); + const evalArgs = evalMock.mock.calls[0]; + const luaScript = evalArgs[0] as string; + // Pointer Lua MUST NOT apply an EX TTL — see TTL strategy above. + expect(luaScript).not.toMatch(/EX/); + // ARGV: [configVersion] only. + expect(evalArgs[3]).toBe("cfg-99"); + expect(evalArgs[4]).toBeUndefined(); + }); + + it("publishCurrentPublicStatusConfigPointers (non-eval fallback) writes the pointer without TTL", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + + const redis: { + set: ReturnType; + get: ReturnType; + } = { + set: vi.fn().mockResolvedValue("OK"), + get: vi.fn().mockResolvedValue(null), + }; + + await expect( + mod.publishCurrentPublicStatusConfigPointers({ configVersion: "cfg-100", redis }) + ).resolves.toBe(true); + + expect(redis.set).toHaveBeenCalledTimes(1); + const setArgs = redis.set.mock.calls[0]; + // Bare two-arg set — no EX, no TTL. + expect(setArgs).toHaveLength(2); + expect(setArgs[1]).toBe("cfg-100"); + }); + }); }); diff --git a/tests/unit/repository/key-resolve-outcome.test.ts b/tests/unit/repository/key-resolve-outcome.test.ts new file mode 100644 index 000000000..8aa2b3c1a --- /dev/null +++ b/tests/unit/repository/key-resolve-outcome.test.ts @@ -0,0 +1,301 @@ +/** + * Regression coverage for `resolveApiKeyAuthOutcome`: the discriminated union + * lookup must distinguish "key never existed" from "key exists but + * disabled/expired" so the proxy auth guard can return precise errors and + * skip the brute-force rate limiter for legitimate-but-rejected lookups. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const whereMock = vi.fn(); +const innerJoinMock = vi.fn(() => ({ where: whereMock })); +const fromMock = vi.fn(() => ({ innerJoin: innerJoinMock })); +const selectMock = vi.fn(() => ({ from: fromMock })); + +vi.mock("@/drizzle/db", () => ({ + db: { select: selectMock }, +})); + +vi.mock("@/drizzle/schema", () => ({ + keys: { + id: "keys.id", + userId: "keys.userId", + key: "keys.key", + name: "keys.name", + isEnabled: "keys.isEnabled", + expiresAt: "keys.expiresAt", + canLoginWebUi: "keys.canLoginWebUi", + limit5hUsd: "keys.limit5hUsd", + limitDailyUsd: "keys.limitDailyUsd", + dailyResetMode: "keys.dailyResetMode", + dailyResetTime: "keys.dailyResetTime", + limitWeeklyUsd: "keys.limitWeeklyUsd", + limitMonthlyUsd: "keys.limitMonthlyUsd", + limitTotalUsd: "keys.limitTotalUsd", + limitConcurrentSessions: "keys.limitConcurrentSessions", + providerGroup: "keys.providerGroup", + cacheTtlPreference: "keys.cacheTtlPreference", + createdAt: "keys.createdAt", + updatedAt: "keys.updatedAt", + deletedAt: "keys.deletedAt", + }, + users: { + id: "users.id", + name: "users.name", + description: "users.description", + role: "users.role", + rpmLimit: "users.rpmLimit", + dailyLimitUsd: "users.dailyLimitUsd", + providerGroup: "users.providerGroup", + limit5hUsd: "users.limit5hUsd", + limit5hResetMode: "users.limit5hResetMode", + limitWeeklyUsd: "users.limitWeeklyUsd", + limitMonthlyUsd: "users.limitMonthlyUsd", + limitTotalUsd: "users.limitTotalUsd", + costResetAt: "users.costResetAt", + limit5hCostResetAt: "users.limit5hCostResetAt", + limitConcurrentSessions: "users.limitConcurrentSessions", + dailyResetMode: "users.dailyResetMode", + dailyResetTime: "users.dailyResetTime", + isEnabled: "users.isEnabled", + expiresAt: "users.expiresAt", + allowedClients: "users.allowedClients", + allowedModels: "users.allowedModels", + createdAt: "users.createdAt", + updatedAt: "users.updatedAt", + deletedAt: "users.deletedAt", + }, + messageRequest: { + blockedBy: "messageRequest.blockedBy", + }, + usageLedger: { + blockedBy: "usageLedger.blockedBy", + endpoint: "usageLedger.endpoint", + }, + providers: { + id: "providers.id", + }, +})); + +vi.mock("drizzle-orm", () => { + const sqlMock = (...args: unknown[]) => args; + sqlMock.join = (...args: unknown[]) => args; + return { + and: (...args: unknown[]) => args, + or: (...args: unknown[]) => args, + eq: (...args: unknown[]) => args, + gt: (...args: unknown[]) => args, + gte: (...args: unknown[]) => args, + lt: (...args: unknown[]) => args, + isNull: (...args: unknown[]) => args, + count: (...args: unknown[]) => args, + desc: (...args: unknown[]) => args, + inArray: (...args: unknown[]) => args, + sql: sqlMock, + sum: (...args: unknown[]) => args, + }; +}); + +vi.mock("@/lib/security/api-key-auth-cache", () => ({ + getCachedActiveKey: vi.fn().mockResolvedValue(null), + getCachedUser: vi.fn().mockResolvedValue(null), + cacheActiveKey: vi.fn().mockResolvedValue(undefined), + cacheAuthResult: vi.fn().mockResolvedValue(undefined), + cacheUser: vi.fn().mockResolvedValue(undefined), + invalidateCachedKey: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/security/api-key-vacuum-filter", () => ({ + apiKeyVacuumFilter: { + isDefinitelyNotPresent: vi.fn().mockReturnValue(undefined), + noteExistingKey: vi.fn(), + }, +})); + +vi.mock("@/lib/redis/pubsub", () => ({ + CHANNEL_API_KEYS_UPDATED: "channel", + publishCacheInvalidation: vi.fn(), +})); + +function activeRow(overrides: Partial> = {}) { + const base = { + keyId: 1, + keyUserId: 2, + keyString: "sk-test", + keyName: "k1", + keyIsEnabled: true, + keyExpiresAt: null, + keyCanLoginWebUi: true, + keyLimit5hUsd: null, + keyLimit5hResetMode: "rolling", + keyLimitDailyUsd: null, + keyDailyResetMode: "fixed", + keyDailyResetTime: "00:00", + keyLimitWeeklyUsd: null, + keyLimitMonthlyUsd: null, + keyLimitTotalUsd: null, + keyCostResetAt: null, + keyLimitConcurrentSessions: 0, + keyProviderGroup: null, + keyCacheTtlPreference: null, + keyCreatedAt: new Date("2024-01-01T00:00:00.000Z"), + keyUpdatedAt: new Date("2024-01-01T00:00:00.000Z"), + keyDeletedAt: null, + userId: 2, + userName: "u1", + userDescription: "", + userRole: "user", + userRpm: null, + userDailyQuota: null, + userProviderGroup: null, + userLimit5hUsd: null, + userLimit5hResetMode: "rolling", + userLimitWeeklyUsd: null, + userLimitMonthlyUsd: null, + userLimitTotalUsd: null, + userCostResetAt: null, + userLimit5hCostResetAt: null, + userLimitConcurrentSessions: 0, + userDailyResetMode: "rolling", + userDailyResetTime: "00:00", + userIsEnabled: true, + userExpiresAt: null, + userAllowedClients: [], + userAllowedModels: [], + userCreatedAt: new Date("2024-01-01T00:00:00.000Z"), + userUpdatedAt: new Date("2024-01-01T00:00:00.000Z"), + userDeletedAt: null, + }; + return { ...base, ...overrides }; +} + +describe("repository/key resolveApiKeyAuthOutcome", () => { + beforeEach(async () => { + selectMock.mockClear(); + fromMock.mockClear(); + innerJoinMock.mockClear(); + whereMock.mockReset(); + + // vitest config sets `mockReset: true` globally, which strips the + // mockResolvedValue implementation off the hoisted cache mocks between + // tests. Re-apply it here so success-path tests don't trip on + // `cacheAuthResult(...).catch(...)` when the returned promise is gone. + const cache = await import("@/lib/security/api-key-auth-cache"); + vi.mocked(cache.getCachedActiveKey).mockResolvedValue(null); + vi.mocked(cache.getCachedUser).mockResolvedValue(null); + vi.mocked(cache.cacheActiveKey).mockResolvedValue(undefined); + vi.mocked(cache.cacheAuthResult).mockResolvedValue(undefined); + vi.mocked(cache.cacheUser).mockResolvedValue(undefined); + vi.mocked(cache.invalidateCachedKey).mockResolvedValue(undefined); + }); + + it("returns ok=true with hydrated user/key when the row is fully active", async () => { + whereMock.mockResolvedValueOnce([activeRow()]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-test"); + + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.user.id).toBe(2); + expect(outcome.key.id).toBe(1); + } + }); + + it("returns not_found when the row does not exist", async () => { + whereMock.mockResolvedValueOnce([]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-missing"); + + expect(outcome).toEqual({ ok: false, reason: "not_found" }); + }); + + it("returns key_disabled when the row exists but isEnabled=false", async () => { + whereMock.mockResolvedValueOnce([activeRow({ keyIsEnabled: false })]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-disabled"); + + expect(outcome).toEqual({ ok: false, reason: "key_disabled" }); + }); + + it("returns key_expired when expiresAt is in the past", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + whereMock.mockResolvedValueOnce([activeRow({ keyExpiresAt: yesterday })]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-expired"); + + expect(outcome).toEqual({ ok: false, reason: "key_expired" }); + }); + + it("prefers key_disabled over key_expired when both are true", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + whereMock.mockResolvedValueOnce([activeRow({ keyIsEnabled: false, keyExpiresAt: yesterday })]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-both"); + + expect(outcome).toEqual({ ok: false, reason: "key_disabled" }); + }); + + it("back-compat wrapper validateApiKeyAndGetUser returns null on any failure", async () => { + whereMock.mockResolvedValueOnce([activeRow({ keyIsEnabled: false })]); + + const { validateApiKeyAndGetUser } = await import("@/repository/key"); + const result = await validateApiKeyAndGetUser("sk-disabled"); + + expect(result).toBeNull(); + }); + + // `keys.key` has no unique constraint, so multiple non-deleted rows may + // share a key string. The classifier MUST prefer an active row to avoid + // mis-rejecting a valid credential as disabled/expired. + describe("deterministic classification across duplicate rows", () => { + it("returns ok=true when at least one duplicate row is active", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + whereMock.mockResolvedValueOnce([ + // A disabled duplicate sorted before an active one — pre-fix this + // would have returned key_disabled and locked the owner out. + activeRow({ keyId: 10, keyIsEnabled: false }), + activeRow({ keyId: 11, keyIsEnabled: true, keyExpiresAt: null }), + activeRow({ keyId: 12, keyIsEnabled: true, keyExpiresAt: yesterday }), + ]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-dup-mixed"); + + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.key.id).toBe(11); + } + }); + + it("returns key_expired when no row is active but at least one is enabled", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + whereMock.mockResolvedValueOnce([ + activeRow({ keyId: 20, keyIsEnabled: false }), + activeRow({ keyId: 21, keyIsEnabled: true, keyExpiresAt: yesterday }), + ]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-dup-expired"); + + expect(outcome).toEqual({ ok: false, reason: "key_expired" }); + }); + + it("returns key_disabled when every duplicate row is disabled", async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + whereMock.mockResolvedValueOnce([ + activeRow({ keyId: 30, keyIsEnabled: false }), + activeRow({ keyId: 31, keyIsEnabled: false, keyExpiresAt: yesterday }), + ]); + + const { resolveApiKeyAuthOutcome } = await import("@/repository/key"); + const outcome = await resolveApiKeyAuthOutcome("sk-dup-disabled"); + + expect(outcome).toEqual({ ok: false, reason: "key_disabled" }); + }); + }); +});