From 9aa27af10806cf98941599880c69ba7477800028 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 2 Aug 2026 17:34:31 +0800 Subject: [PATCH] chore(server): cleanup unused scripts --- apps/server/scripts/e2e-llm-router.ts | 132 --------- apps/server/tests/verifications/_harness.ts | 260 ------------------ .../account-deletion.integration.test.ts | 116 -------- .../admin-flux-grants.integration.test.ts | 154 ----------- .../flux-unbilled-exploit.integration.test.ts | 117 -------- 5 files changed, 779 deletions(-) delete mode 100644 apps/server/scripts/e2e-llm-router.ts delete mode 100644 apps/server/tests/verifications/_harness.ts delete mode 100644 apps/server/tests/verifications/account-deletion.integration.test.ts delete mode 100644 apps/server/tests/verifications/admin-flux-grants.integration.test.ts delete mode 100644 apps/server/tests/verifications/flux-unbilled-exploit.integration.test.ts diff --git a/apps/server/scripts/e2e-llm-router.ts b/apps/server/scripts/e2e-llm-router.ts deleted file mode 100644 index 457c4f435..000000000 --- a/apps/server/scripts/e2e-llm-router.ts +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env tsx -/** - * End-to-end test for U1-U7: hits a real OpenRouter API via the router - * service to prove envelope decrypt + config load + key rotation + upstream - * fetch all work together. - * - * Use when: - * - Verifying the gateway end-to-end after a fresh seed, without going - * through the HTTP auth chain. - * - * Expects: - * - `.env.local` provides REDIS_URL, LLM_ROUTER_MASTER_KEY. - * - `LLM_ROUTER_CONFIG` already seeded via - * `POST /api/admin/config/router` (see - * `docs/ai-context/verifications/llm-router.md` for the curl invocation). - * - * Returns: exit 0 with the assistant response printed; exit 1 on failure. - */ -import { env, exit } from 'node:process' - -import Redis from 'ioredis' - -import { parseEnv } from '../src/libs/env' -import { createConfigKVService } from '../src/services/adapters/config-kv' -import { createLlmRouterService } from '../src/services/domain/llm-router' -import { createEnvelopeCrypto } from '../src/utils/envelope-crypto' - -async function main() { - const parsedEnv = parseEnv(env) - if (!parsedEnv.LLM_ROUTER_MASTER_KEY) { - console.error('error: LLM_ROUTER_MASTER_KEY env var is required') - exit(1) - } - - const redis = new Redis(parsedEnv.REDIS_URL) - const configKV = createConfigKVService(redis) - const envelope = createEnvelopeCrypto({ - masterKey: parsedEnv.LLM_ROUTER_MASTER_KEY, - previousMasterKey: parsedEnv.LLM_ROUTER_MASTER_KEY_PREVIOUS, - }) - - // Debug wrapper: log every upstream request + response so we can see what - // the router is actually sending when E2E fails. Remove after E2E passes. - const debugFetch: typeof fetch = async (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - console.info(` fetch → POST ${url}`) - if (init?.headers) { - const hdrs = init.headers as Record - const auth = hdrs.authorization || hdrs.Authorization - // NOTICE: - // Never log credential substrings: a 30-char prefix of an OpenRouter - // key (`sk-or-v1-bb1a38505a7309...`) is enough to identify the account. - // Print presence only. Source: codex review 2026-05-15 #10. - console.info(` auth = ${auth ? '' : ''}`) - } - if (init?.body) { - console.info(` body = ${String(init.body).slice(0, 200)}`) - } - const res = await fetch(input as any, init as any) - if (!res.ok) { - const clone = res.clone() - const text = await clone.text().catch(() => '') - console.info(` ← ${res.status} body: ${text.slice(0, 300)}`) - } - return res - } - - const router = createLlmRouterService({ - configKV, - envelopeCrypto: envelope, - gatewayMetrics: null, - fetchImpl: debugFetch, - }) - - console.info('→ calling router.route() with model=chat-default') - const start = Date.now() - let response: Response - try { - response = await router.route({ - modelName: 'chat-default', - body: { - messages: [ - { role: 'user', content: 'Say "hello world" in exactly 3 words, no period.' }, - ], - max_tokens: 20, - }, - headers: {}, - }) - } - catch (err) { - console.error('router.route threw:', err) - await redis.quit() - exit(1) - } - - const elapsed = Date.now() - start - console.info(`← status ${response.status} (${elapsed}ms)`) - - if (!response.ok) { - const text = await response.text() - console.error('upstream non-2xx body:', text.slice(0, 500)) - await redis.quit() - exit(1) - } - - const payload = await response.json() as { - choices?: Array<{ message?: { content?: string } }> - usage?: { prompt_tokens?: number, completion_tokens?: number } - model?: string - } - const content = payload.choices?.[0]?.message?.content - console.info() - console.info('Assistant response:') - console.info(` model: ${payload.model ?? ''}`) - console.info(` text: ${JSON.stringify(content)}`) - console.info(` tokens: prompt=${payload.usage?.prompt_tokens ?? '?'} completion=${payload.usage?.completion_tokens ?? '?'}`) - - if (!content) { - console.error('error: response.choices[0].message.content was empty') - await redis.quit() - exit(1) - } - - console.info() - console.info('E2E PASS — router service successfully called OpenRouter and returned a usable response.') - await redis.quit() -} - -main().catch((err) => { - console.error('e2e failed:', err) - exit(1) -}) diff --git a/apps/server/tests/verifications/_harness.ts b/apps/server/tests/verifications/_harness.ts deleted file mode 100644 index d1f2f8c46..000000000 --- a/apps/server/tests/verifications/_harness.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { Database } from '../../src/libs/db' - -import { Buffer } from 'node:buffer' - -import { vi } from 'vitest' - -import { buildApp } from '../../src/app' -import { mockDB } from '../../src/libs/mock-db' -import { createAdminFluxGrantsService } from '../../src/services/domain/admin/flux-grants' -import { createBillingService } from '../../src/services/domain/billing/billing-service' -import { createFluxService } from '../../src/services/domain/flux' -import { createUserDeletionService } from '../../src/services/domain/user-deletion' -import { userFluxRedisKey } from '../../src/utils/redis-keys' - -import * as schema from '../../src/schemas' - -// NOTICE: -// drizzle-kit's `pushSchema` (called by `mockDB`) takes ~500ms per invocation. -// Vitest spawns a fresh worker per test file, so a module-level promise scopes -// the cache correctly: schema push runs once per file, every -// `startVerificationContext()` after that reuses the in-memory PGlite and -// only truncates rows. See `docs/ai/context/verification-automation.md` for -// the broader rationale on test boot cost. -let sharedDbPromise: Promise | null = null - -async function getSharedDb(): Promise { - sharedDbPromise ??= mockDB(schema) - return sharedDbPromise -} - -async function resetDataRows(db: Database): Promise { - // Delete in FK-safe order. better-auth's session / account / verification - // tables reference user with `onDelete: cascade`, so deleting `user` last - // implicitly clears them — we still call them out for clarity and so a - // future test that seeds sessions directly does not silently leak rows. - await db.delete(schema.fluxTransaction) - await db.delete(schema.userFlux) - await db.delete(schema.session) - await db.delete(schema.account) - await db.delete(schema.user) -} - -interface SeedUserOptions { - id: string - email?: string - balance: number -} - -interface SessionUser { - id: string - email: string - emailVerified?: boolean - name?: string - /** better-auth `admin` plugin role. Set `'admin'` to pass `adminGuard`. */ - role?: string | null -} - -export type Harness = Awaited> - -/** - * Boots a Hono app with the same wiring as production for a verification - * scenario. - * - * Use when: - * - You need to assert a full user path (HTTP -> route -> service -> DB / ledger) - * rather than a unit-level code path - * - You want real `createFluxService` + `createBillingService` against a real - * in-memory Postgres (PGlite), with auth / OIDC / WebSocket / OTel stubbed - * - * Expects: - * - No external network. The mock router never opens sockets so - * pre-flight-rejecting cases never reach it; tests that actually need an - * upstream LLM response must stub `fetch` themselves - * - * Returns: - * - A `Harness` value with the mounted app, drizzle handle, and helpers to - * set a session user, seed flux balance, override config keys, and inspect - * the in-memory Redis store - */ - -export async function startVerificationContext() { - const db = await getSharedDb() - await resetDataRows(db) - - let activeSession: { user: any, session: any } | null = null - - const auth: any = { - api: { - getSession: vi.fn(async () => activeSession), - getOAuthServerConfig: vi.fn(async () => ({})), - getOpenIdConfig: vi.fn(async () => ({})), - }, - handler: vi.fn(async () => new Response('not-found', { status: 404 })), - } - - const configStore: Record = { - FLUX_PER_REQUEST: 1, - INITIAL_USER_FLUX: 0, - AUTH_RATE_LIMIT_MAX: 1000, - AUTH_RATE_LIMIT_WINDOW_SEC: 60, - FLUX_PER_1K_CHARS_TTS: 2, - TTS_DEBT_TTL_SECONDS: 86400, - } - const configKV: any = { - get: vi.fn(async (key: string) => configStore[key]), - getOrThrow: vi.fn(async (key: string) => { - if (configStore[key] === undefined) - throw new Error(`Config key "${key}" is not set`) - return configStore[key] - }), - getOptional: vi.fn(async (key: string) => (configStore[key] ?? null)), - set: vi.fn(async (key: string, value: any) => { - configStore[key] = value - }), - } - - const redisStore = new Map() - const redisSubscriber = { - on: vi.fn(), - subscribe: vi.fn(async () => 1), - unsubscribe: vi.fn(async () => 0), - quit: vi.fn(async () => 'OK'), - } - const redis: any = { - get: vi.fn(async (key: string) => redisStore.get(key) ?? null), - getBuffer: vi.fn(async (key: string) => { - const v = redisStore.get(key) - return v ? Buffer.from(v, 'utf8') : null - }), - set: vi.fn(async (key: string, value: any) => { - redisStore.set(key, String(value)) - return 'OK' - }), - del: vi.fn(async (key: string) => (redisStore.delete(key) ? 1 : 0)), - incrby: vi.fn(async (key: string, by: number) => { - const next = (Number.parseInt(redisStore.get(key) ?? '0', 10) || 0) + by - redisStore.set(key, String(next)) - return next - }), - expire: vi.fn(async () => 1), - duplicate: vi.fn(() => redisSubscriber), - publish: vi.fn(async () => 0), - } - - const fluxService = createFluxService(db, redis, configKV) - const billingService = createBillingService(db, redis, configKV) - const adminFluxGrantsService = createAdminFluxGrantsService({ db, billingService }) - - // NOTICE: - // Production wires 5 soft-delete handlers (stripe / flux / providers / - // characters / chats). The harness only wires `flux` so the verification - // for "balance soft-deleted + ledger preserved" can run without dragging - // in stripe SDK, character / chat / provider services. Tests covering the - // other 4 handlers should opt in via a future option flag rather than - // widening the default wiring. - const userDeletionService = createUserDeletionService() - userDeletionService.register({ - name: 'flux', - priority: 20, - softDelete: ({ userId }) => fluxService.deleteAllForUser(userId), - }) - - // NOTICE: - // The Proxy returns a fresh vi.fn() for every property access. Stand-in for - // services this verification doesn't touch (chat, characters, providers, - // stripe, admin-flux-grants, user-deletion, ttsMeter). If a test exercises - // one of these and starts getting `undefined is not a function` errors, - // wire in a real instance instead of widening this stub. - const stub: any = new Proxy({}, { get: () => vi.fn(async () => undefined) }) - - const env: any = { - API_SERVER_URL: 'http://localhost:3000', - OTEL_SERVICE_NAME: 'airi-server-test', - ADDITIONAL_TRUSTED_ORIGINS: '', - HOST: '127.0.0.1', - PORT: 0, - } - - const { app } = await buildApp({ - auth, - db, - characterService: stub, - chatService: stub, - providerService: stub, - fluxService, - fluxTransactionService: stub, - stripeService: stub, - billingService, - adminFluxGrantsService, - adminUsersService: stub, - ttsMeter: stub, - requestLogService: { logRequest: vi.fn(async () => undefined) } as any, - configKV, - redis, - env, - otel: null, - userDeletionService, - llmRouter: { - route: vi.fn(async () => new Response('{}', { status: 200 })), - invalidateConfig: vi.fn(), - } as any, - }) - - return { - app, - db, - schema, - redisStore, - configStore, - userDeletionService, - fluxService, - setSessionUser(user: SessionUser | null) { - activeSession = user - ? { - user: { - id: user.id, - email: user.email, - name: user.name ?? user.id, - emailVerified: user.emailVerified ?? true, - role: user.role ?? null, - banned: false, - banExpires: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - session: { - id: `sess-${user.id}`, - userId: user.id, - token: `tok-${user.id}`, - createdAt: new Date(), - updatedAt: new Date(), - expiresAt: new Date(Date.now() + 3600_000), - ipAddress: null, - userAgent: null, - }, - } - : null - }, - async seedUser(opts: SeedUserOptions) { - await db.insert(schema.user).values({ - id: opts.id, - name: opts.id, - email: opts.email ?? `${opts.id}@example.com`, - emailVerified: true, - }).onConflictDoNothing() - await db.insert(schema.userFlux).values({ - userId: opts.id, - flux: opts.balance, - }).onConflictDoNothing() - // NOTICE: - // Prime the Redis cache so `fluxService.getFlux()` reads the seeded - // balance directly instead of touching the DB-init path (which would - // create an `initial` flux_transaction row and skew ledger assertions). - redisStore.set(userFluxRedisKey(opts.id), String(opts.balance)) - }, - setConfig(kv: Record) { - Object.assign(configStore, kv) - }, - } -} diff --git a/apps/server/tests/verifications/account-deletion.integration.test.ts b/apps/server/tests/verifications/account-deletion.integration.test.ts deleted file mode 100644 index dafc8f14a..000000000 --- a/apps/server/tests/verifications/account-deletion.integration.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Verification: docs/ai/context/verification-automation.md -// Source doc: apps/server/docs/ai-context/verifications/account-deletion.md -// -// Covers one slice of the verification doc — the part that pins down -// "Flux balance soft-deleted, cache invalidated, ledger preserved". -// -// What this test does NOT cover (intentional, out of harness scope): -// - The Better Auth `/api/auth/delete-user/callback` HTTP entry point. The -// prod trigger lives inside better-auth's plugin internals; testing it -// end-to-end would mean mounting the real `createAuth(...)` against -// better-auth's verification-token flow, which the harness does not do. -// We call `userDeletionService.softDeleteAll(...)` directly — the same -// function better-auth's `beforeDelete` hook calls — so the orchestration -// contract is exercised even though the HTTP boundary is skipped. -// - The stripe / providers / characters / chats handlers. The harness wires -// only the `flux` handler (see `_harness.ts`). Verifying the other four -// needs widening the default wiring or a per-test opt-in. - -import type { Harness } from './_harness' - -import { eq } from 'drizzle-orm' -import { afterAll, beforeEach, describe, expect, it } from 'vitest' - -import { userFluxRedisKey } from '../../src/utils/redis-keys' -import { startVerificationContext } from './_harness' - -describe('verification: account-deletion (flux slice)', () => { - let ctx: Harness - - beforeEach(async () => { - ctx = await startVerificationContext() - }) - - afterAll(async () => { - // PGlite is module-cached and lives for the worker's lifetime. - }) - - it('soft-deletes user_flux, invalidates redis cache, leaves flux_transaction ledger intact', async () => { - const userId = 'user-doomed' - - // Seed: balance 500 + one historical debit ledger row. The ledger row is - // the load-bearing assertion — flux_transaction has no `deleted_at` - // column on purpose, and the bare userId (no FK) is the mechanism that - // lets the ledger outlive the user row. See `schemas/flux-transaction.ts` - // NOTICE comment. - await ctx.seedUser({ id: userId, balance: 500 }) - await ctx.db.insert(ctx.schema.fluxTransaction).values({ - userId, - type: 'debit', - amount: 50, - balanceBefore: 550, - balanceAfter: 500, - description: 'prior llm call', - }) - - // Sanity precondition: balance is 500 and ledger has 1 row. - const balanceBefore = await ctx.fluxService.getFlux(userId) - expect(balanceBefore.flux).toBe(500) - expect(ctx.redisStore.get(userFluxRedisKey(userId))).toBe('500') - const ledgerBefore = await ctx.db.query.fluxTransaction.findMany({ - where: eq(ctx.schema.fluxTransaction.userId, userId), - }) - expect(ledgerBefore).toHaveLength(1) - - // Act: same call better-auth makes inside the delete-user callback hook. - await ctx.userDeletionService.softDeleteAll({ userId, reason: 'user-requested' }) - - // Assert 1: user_flux row is stamped with deletedAt (soft-delete). - const rawRow = await ctx.db - .select() - .from(ctx.schema.userFlux) - .where(eq(ctx.schema.userFlux.userId, userId)) - expect(rawRow).toHaveLength(1) - expect(rawRow[0].deletedAt).not.toBeNull() - expect(rawRow[0].flux).toBe(500) // balance value is NOT zeroed; soft-delete only stamps deletedAt - - // Assert 2: redis cache for the user's balance is dropped. - expect(ctx.redisStore.get(userFluxRedisKey(userId))).toBeUndefined() - - // Assert 3: ledger rows persist. The audit trail outlives the user row. - const ledgerAfter = await ctx.db.query.fluxTransaction.findMany({ - where: eq(ctx.schema.fluxTransaction.userId, userId), - }) - expect(ledgerAfter).toHaveLength(1) - expect(ledgerAfter[0].description).toBe('prior llm call') - - // Assert 4: `fluxService.getFlux` rejects after soft-delete. The read - // path filters on `deletedAt IS NULL`, the init-path's - // `onConflictDoNothing` no-ops against the soft-deleted row, and the - // re-read still finds nothing — so the service throws. In production - // this scenario should not arise because better-auth hard-deletes the - // user row after `softDeleteAll`. The throw is the defense-in-depth - // failure mode described in the `fluxService` NOTICE for any request - // that bypasses `sessionMiddleware` with a soft-deleted user. - await expect(ctx.fluxService.getFlux(userId)).rejects.toThrow(/Failed to initialize flux/) - }) - - it('softDeleteAll is idempotent — calling it twice does not throw and leaves state unchanged', async () => { - // The handler uses `WHERE deletedAt IS NULL` so the second call finds 0 - // rows and is a no-op. `redis.del` is also a no-op when the key is - // absent. Pinning this prevents a future refactor that tightens the - // where-clause and accidentally requires the row to be live. - const userId = 'user-doomed-twice' - await ctx.seedUser({ id: userId, balance: 100 }) - - await ctx.userDeletionService.softDeleteAll({ userId, reason: 'user-requested' }) - await ctx.userDeletionService.softDeleteAll({ userId, reason: 'user-requested' }) - - const rawRow = await ctx.db - .select() - .from(ctx.schema.userFlux) - .where(eq(ctx.schema.userFlux.userId, userId)) - expect(rawRow).toHaveLength(1) - expect(rawRow[0].deletedAt).not.toBeNull() - }) -}) diff --git a/apps/server/tests/verifications/admin-flux-grants.integration.test.ts b/apps/server/tests/verifications/admin-flux-grants.integration.test.ts deleted file mode 100644 index 9999d416e..000000000 --- a/apps/server/tests/verifications/admin-flux-grants.integration.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Verification: docs/ai/context/verification-automation.md -// Source doc: apps/server/docs/ai-context/verifications/admin-flux-grants.md -// -// Covers the three user paths from the verification doc: -// Path 1: admin POST /api/admin/flux-grants → 200 + granted array, ledger row written -// Path 2: ?dryRun=true → preview returned, ledger unchanged -// Path 3: adminGuard rejects (401 no session / 403 non-admin role) - -import type { Harness } from './_harness' - -import { eq } from 'drizzle-orm' -import { afterAll, beforeEach, describe, expect, it } from 'vitest' - -import { startVerificationContext } from './_harness' - -const ADMIN_EMAIL = 'admin@example.com' - -describe('verification: admin-flux-grants', () => { - let ctx: Harness - - beforeEach(async () => { - ctx = await startVerificationContext() - ctx.setConfig({ INITIAL_USER_FLUX: 0 }) - // Admin user (caller). Admin access is role-based (`role === 'admin'`). - await ctx.seedUser({ id: 'admin-1', email: ADMIN_EMAIL, balance: 0 }) - }) - - afterAll(async () => { - // PGlite is per-context and per-test — letting it drop out of scope is enough. - }) - - describe('path 1: admin synchronously grants flux', () => { - it('credits 100 flux to each existing recipient and returns the per-email outcome buckets', async () => { - await ctx.seedUser({ id: 'recipient-1', email: 'rec1@example.com', balance: 0 }) - await ctx.seedUser({ id: 'recipient-2', email: 'rec2@example.com', balance: 25 }) - ctx.setSessionUser({ id: 'admin-1', email: ADMIN_EMAIL, role: 'admin' }) - - const res = await ctx.app.request('/api/admin/flux-grants', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - description: 'integration-test grant', - amount: 100, - emails: ['rec1@example.com', 'rec2@example.com'], - }), - }) - - expect(res.status).toBe(200) - const body = await res.json() as { - summary: { willGrant: number, totalFluxToIssue: number } - result: { - granted: { email: string, userId: string, fluxTransactionId: string, balanceAfter: number }[] - skipped: unknown[] - failed: unknown[] - } - } - - expect(body.summary.willGrant).toBe(2) - expect(body.summary.totalFluxToIssue).toBe(200) - expect(body.result.failed).toEqual([]) - expect(body.result.skipped).toEqual([]) - expect(body.result.granted).toHaveLength(2) - - const granted1 = body.result.granted.find(g => g.email === 'rec1@example.com') - const granted2 = body.result.granted.find(g => g.email === 'rec2@example.com') - expect(granted1?.balanceAfter).toBe(100) - expect(granted2?.balanceAfter).toBe(125) - expect(granted1?.fluxTransactionId).toBeTruthy() - - // Ledger writes per recipient with type='promo' and the operator id in metadata. - const rec1Ledger = await ctx.db.query.fluxTransaction.findMany({ - where: eq(ctx.schema.fluxTransaction.userId, 'recipient-1'), - }) - expect(rec1Ledger).toHaveLength(1) - expect(rec1Ledger[0].type).toBe('promo') - expect(rec1Ledger[0].amount).toBe(100) - expect(rec1Ledger[0].balanceBefore).toBe(0) - expect(rec1Ledger[0].balanceAfter).toBe(100) - expect(rec1Ledger[0].description).toBe('integration-test grant') - const meta = rec1Ledger[0].metadata as { issuedByUserId?: string, description?: string } - expect(meta?.issuedByUserId).toBe('admin-1') - }) - }) - - describe('path 2: dry-run preview', () => { - it('reports willGrant / notFound / duplicateInInput without writing a ledger row', async () => { - await ctx.seedUser({ id: 'recipient-1', email: 'rec1@example.com', balance: 0 }) - ctx.setSessionUser({ id: 'admin-1', email: ADMIN_EMAIL, role: 'admin' }) - - const res = await ctx.app.request('/api/admin/flux-grants?dryRun=true', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - description: 'smoke', - amount: 100, - emails: [ - 'rec1@example.com', - 'REC1@example.com', // case-variant duplicate - 'ghost@nope.example', // not_found - 'rec1@example.com', // exact duplicate - ], - }), - }) - - expect(res.status).toBe(200) - const body = await res.json() as { - preview: { - totalEmails: number - willGrant: number - willSkip: { notFound: number, userDeleted: number, duplicateInInput: number } - totalFluxToIssue: number - } - } - expect(body.preview.totalEmails).toBe(4) - expect(body.preview.willGrant).toBe(1) - expect(body.preview.willSkip.notFound).toBe(1) - expect(body.preview.willSkip.duplicateInInput).toBe(2) - expect(body.preview.totalFluxToIssue).toBe(100) - - // Ledger must be untouched. - const ledger = await ctx.db.query.fluxTransaction.findMany({ - where: eq(ctx.schema.fluxTransaction.userId, 'recipient-1'), - }) - expect(ledger).toEqual([]) - }) - }) - - describe('path 3: adminGuard rejects unauthorized callers', () => { - it('returns 401 when no session is attached', async () => { - ctx.setSessionUser(null) - - const res = await ctx.app.request('/api/admin/flux-grants', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ description: 'x', amount: 1, emails: ['a@b.com'] }), - }) - - expect(res.status).toBe(401) - }) - - it('returns 403 when the session user has no admin role', async () => { - await ctx.seedUser({ id: 'normie', email: 'normie@example.com', balance: 0 }) - ctx.setSessionUser({ id: 'normie', email: 'normie@example.com', role: 'user' }) - - const res = await ctx.app.request('/api/admin/flux-grants', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ description: 'x', amount: 1, emails: ['a@b.com'] }), - }) - - expect(res.status).toBe(403) - }) - }) -}) diff --git a/apps/server/tests/verifications/flux-unbilled-exploit.integration.test.ts b/apps/server/tests/verifications/flux-unbilled-exploit.integration.test.ts deleted file mode 100644 index 0f3c4132c..000000000 --- a/apps/server/tests/verifications/flux-unbilled-exploit.integration.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Verification: docs/ai/context/verification-automation.md -// Source doc: apps/server/docs/ai-context/verifications/flux-unbilled-exploit-fix.md -// -// Covers the "user path" section of the verification doc: -// Scenario: user with 0 < balance < fallbackRate fires N concurrent LLM -// completion requests. -// Expected (post-patch): all N are rejected at pre-flight with 402; the -// upstream router is never invoked; flux_transaction ledger gains -// no 'debit' row; user_flux.flux stays at the seeded value. - -import type { Harness } from './_harness' - -import { and, eq } from 'drizzle-orm' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' - -import { startVerificationContext } from './_harness' - -describe('verification: flux-unbilled-exploit-fix', () => { - let ctx: Harness - - beforeAll(async () => { - ctx = await startVerificationContext() - }) - - beforeEach(async () => { - // Clean ledger between cases so balanceBefore / balanceAfter assertions - // don't carry state across tests. - await ctx.db.delete(ctx.schema.fluxTransaction) - await ctx.db.delete(ctx.schema.userFlux) - ctx.redisStore.clear() - }) - - afterAll(async () => { - // PGlite is in-memory — letting it fall out of scope is enough. - }) - - it('rejects N concurrent partial-balance requests at pre-flight, never writes a debit ledger row', async () => { - // ROOT CAUSE: - // - // Before commit 7267b0d6b the pre-flight gate read `if (flux.flux <= 0)`, - // so users with `0 < balance < FLUX_PER_REQUEST` could spawn N concurrent - // requests, each pass pre-flight, complete the upstream LLM call, and - // race on a post-billing debit that almost always failed (insufficient - // funds for the requested amount, full-amount rollback). Result: N free - // LLM responses. - // - // After 7267b0d6b: pre-flight rejects when `flux.flux < fallbackRate` - // before the upstream call. This test pins the after-patch behavior so - // a regression that loosens the gate back to `<= 0` will fail here. - - const userId = 'user-partial-balance' - const N = 5 - - ctx.setConfig({ FLUX_PER_REQUEST: 100, FLUX_PER_1K_TOKENS: 50 }) - await ctx.seedUser({ id: userId, balance: 5 }) - ctx.setSessionUser({ id: userId, email: `${userId}@example.com` }) - - const responses = await Promise.all( - Array.from({ length: N }, () => ctx.app.request('/api/v1/openai/chat/completions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }), - })), - ) - - // All N must be rejected before the gateway is touched. - expect(responses).toHaveLength(N) - for (const res of responses) { - expect(res.status).toBe(402) - } - const bodies = await Promise.all(responses.map(r => r.json() as Promise<{ error: string, message: string }>)) - for (const body of bodies) { - expect(body.error).toBe('PAYMENT_REQUIRED') - expect(body.message).toMatch(/Insufficient flux/i) - } - - // Ledger must be untouched. The pre-flight reject path never reaches - // `consumeFluxForLLM`, so no `debit` rows should exist. - const ledger = await ctx.db.query.fluxTransaction.findMany({ - where: eq(ctx.schema.fluxTransaction.userId, userId), - }) - expect(ledger).toEqual([]) - - // Balance must be exactly the seeded value. No drain, no rollback. - const fluxRow = await ctx.db.query.userFlux.findFirst({ - where: and( - eq(ctx.schema.userFlux.userId, userId), - // Active rows only — deletedAt IS NULL is fluxService's read guard - ), - }) - expect(fluxRow?.flux).toBe(5) - }) - - it('allows a request when balance >= fallbackRate (pre-flight pass-through smoke check)', async () => { - // Companion case: with sufficient balance the pre-flight gate must NOT - // block. We don't run the request to completion (no upstream gateway - // available in this harness), only assert that the failure mode here is - // upstream-fetch error (not 402). This protects against a regression - // that flips the comparison and rejects everyone. - const userId = 'user-funded' - - ctx.setConfig({ FLUX_PER_REQUEST: 100, FLUX_PER_1K_TOKENS: 50 }) - await ctx.seedUser({ id: userId, balance: 1000 }) - ctx.setSessionUser({ id: userId, email: `${userId}@example.com` }) - - const res = await ctx.app.request('/api/v1/openai/chat/completions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }), - }) - - // Pre-flight passed -> route delegated to the mock router. The harness - // wires `llmRouter.route` to a vi.fn that returns 200, so we only assert - // the pre-flight gate did NOT short-circuit with 402. - expect(res.status).not.toBe(402) - }) -})