mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
chore(server): cleanup unused scripts
This commit is contained in:
@@ -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<string, string>
|
||||
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 ? '<set>' : '<none>'}`)
|
||||
}
|
||||
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(() => '<unreadable>')
|
||||
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 ?? '<unknown>'}`)
|
||||
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)
|
||||
})
|
||||
@@ -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<Database> | null = null
|
||||
|
||||
async function getSharedDb(): Promise<Database> {
|
||||
sharedDbPromise ??= mockDB(schema)
|
||||
return sharedDbPromise
|
||||
}
|
||||
|
||||
async function resetDataRows(db: Database): Promise<void> {
|
||||
// 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<ReturnType<typeof startVerificationContext>>
|
||||
|
||||
/**
|
||||
* 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<string, any> = {
|
||||
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<string, string>()
|
||||
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<string, any>) {
|
||||
Object.assign(configStore, kv)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user