import { Inject, Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { loadEnv } from "../../config/env.js"; import { armMockAssistantSeam, armMockDbInvalidStructure, armMockFailures, armMockGenerateSeam, armMockUnknownBlockId, resetWorkspaceCache } from "../ai/gateway-provider.js"; import type { AiUsageLimitsInput, MockAssistantInput, MockDbInvalidInput, MockFailInput, MockGenerateSeamInput, MockUnknownBlockIdInput } from "./ai-test-seam.dto.js"; /** * Test-only AI seam (TASK-0122 — `INTERNAL_TEST_SEAM`). * * Two capabilities the E2E error-state spec needs but no public API offers: * * 1. `armMockOutage` — fail the NEXT mock chat call FOR THAT WORKSPACE * mid-stream (a few deltas, then a retryable 5xx/429). The mock is * created per gateway call, so a running API process cannot be flipped * via `process.env`; this seam sets a single-shot PER-WORKSPACE arming * that the next mock built for that workspace consumes. Scoping by * workspace (rather than process-global) keeps the E2E deterministic * when Playwright's desktop + mobile projects run in parallel against * the same API. Only meaningful when `AI_PROVIDER=mock`; for a real * provider it is a harmless no-op (the next call is unaffected) — but * the seam is gated exactly like every other internal surface. * * 2. `setAiUsageLimits` — write `workspace.usageLimits.aiMonthlyTokens` * (the monthly token budget the AI budget guard enforces). No public API * mutates this column, so the E2E budget-402 scenario is untestable * without the seam. Setting `null` removes the key (= unlimited). * * Safety model (mirrors `TestSeamService`): * - Gated by the SAME fail-closed `InternalServiceGuard` as `/internal/*` * (shared `INTERNAL_SERVICE_TOKEN`, constant-time compare, user JWTs * rejected). * - ADDITIONALLY closed unless `INTERNAL_TEST_SEAM=1` — re-checked in this * service so a mis-wired controller cannot open the seam. * - Every write is audited (`workspace.ai_test_seam_…`) so a stray call in * the wild is visible in the audit trail. */ @Injectable() export class AiTestSeamService { // Plain parameter injection (matches the sibling TestSeamService that boots // successfully; see test-seam.service.ts for the regression note). constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} /** Arm the NEXT mock chat call FOR `workspaceId` to fail mid-stream with `status`. */ async armMockOutage(workspaceId: string, input: MockFailInput) { this.assertSeamEnabled(); if (loadEnv().AI_PROVIDER !== "mock") { // The arming would be consumed by a real provider adapter that ignores // it — fail loudly so the test setup is visibly wrong, not silently // skipped. throw ApiError.unprocessable("Mock-outage arming requires AI_PROVIDER=mock.", [ { reason: "ai_provider_not_mock" } ]); } const status = armMockFailures(workspaceId, input.status, input.deltas); await this.audit.write({ action: "workspace.ai_test_seam_mock_outage_armed", resourceType: "ai", resourceId: "mock-outage", workspaceId, metadata: { status, deltas: input.deltas, seam: true } }); return { armed: true, workspaceId, status, deltas: input.deltas }; } /** Set (or clear, via `null`) the workspace's monthly AI token budget. */ async setAiUsageLimits(workspaceId: string, input: AiUsageLimitsInput) { this.assertSeamEnabled(); const ws = await this.prisma.workspace.findUnique({ where: { id: workspaceId }, select: { id: true, deletedAt: true, usageLimits: true } }); if (!ws || ws.deletedAt) throw ApiError.notFound("Workspace not found."); const current = (ws.usageLimits ?? {}) as Record; const next = { ...current }; if (input.aiMonthlyTokens === null) { delete next.aiMonthlyTokens; // null = unlimited (the key's absence) } else { next.aiMonthlyTokens = input.aiMonthlyTokens; } await this.prisma.workspace.update({ where: { id: workspaceId }, data: { usageLimits: next as Prisma.InputJsonValue } }); // The budget guard reads the workspace through a 30 s cache — without // this reset a patched limit would not take effect for up to 30 s. resetWorkspaceCache(); await this.audit.write({ action: "workspace.ai_test_seam_usage_limits_set", resourceType: "workspace", resourceId: workspaceId, workspaceId, metadata: { aiMonthlyTokens: input.aiMonthlyTokens, seam: true } }); return { workspaceId, usageLimits: next }; } /** * TASK-0123: arm the NEXT mock writing call FOR `workspaceId` to emit a * forged blockId (the "unknown ID → drop + note" validation seam). */ async armMockUnknownBlockId(workspaceId: string, input: MockUnknownBlockIdInput) { this.assertSeamEnabled(); if (loadEnv().AI_PROVIDER !== "mock") { throw ApiError.unprocessable("Unknown-blockId arming requires AI_PROVIDER=mock.", [ { reason: "ai_provider_not_mock" } ]); } armMockUnknownBlockId(workspaceId, input.unknownId); await this.audit.write({ action: "workspace.ai_test_seam_unknown_block_id_armed", resourceType: "ai", resourceId: "mock-unknown-id", workspaceId, metadata: { unknownId: input.unknownId, seam: true } }); return { armed: true, workspaceId, unknownId: input.unknownId }; } /** * TASK-0124: arm the NEXT mock database-generation call FOR `workspaceId` * to emit a deliberately INVALID tool call — the seam for the * "validation fails → retry once → 422, no partial write" path. */ async armMockDbInvalid(workspaceId: string, input: MockDbInvalidInput) { this.assertSeamEnabled(); if (loadEnv().AI_PROVIDER !== "mock") { throw ApiError.unprocessable("Database-invalid arming requires AI_PROVIDER=mock.", [ { reason: "ai_provider_not_mock" } ]); } armMockDbInvalidStructure(workspaceId, input.action); await this.audit.write({ action: "workspace.ai_test_seam_db_invalid_armed", resourceType: "ai", resourceId: "mock-db-invalid", workspaceId, metadata: { action: input.action, seam: true } }); return { armed: true, workspaceId, action: input.action }; } /** * TASK-0126: arm the NEXT mock PAGE-GENERATION call FOR `workspaceId` — * the `failStep` (the executor's step-failure → rollback path) and the * `slowMs` (the service's total-timeout watchdog → `ai_timeout`) seams. */ async armMockGenerateSeam(workspaceId: string, input: MockGenerateSeamInput) { this.assertSeamEnabled(); if (loadEnv().AI_PROVIDER !== "mock") { throw ApiError.unprocessable("Generate-seam arming requires AI_PROVIDER=mock.", [ { reason: "ai_provider_not_mock" } ]); } const arming = armMockGenerateSeam(workspaceId, input); await this.audit.write({ action: "workspace.ai_test_seam_generate_seam_armed", resourceType: "ai", resourceId: "mock-generate", workspaceId, metadata: { failStep: input.failStep ?? null, slowMs: input.slowMs ?? null, seam: true } }); return { armed: true, workspaceId, ...arming }; } /** * T-172…T-175: arm the mock ASSISTANT calls for `workspaceId` (hostile * invalid tool call and/or a forged candidate id). */ async armMockAssistantSeam(workspaceId: string, input: MockAssistantInput) { this.assertSeamEnabled(); if (loadEnv().AI_PROVIDER !== "mock") { throw ApiError.unprocessable("Assistant-seam arming requires AI_PROVIDER=mock.", [ { reason: "ai_provider_not_mock" } ]); } armMockAssistantSeam(workspaceId, input); await this.audit.write({ action: "workspace.ai_test_seam_assistant_armed", resourceType: "ai", resourceId: "mock-assistant", workspaceId, metadata: { hostile: input.hostile, forgeId: input.forgeId, seam: true } }); return { armed: true, workspaceId, ...input }; } /** Defense in depth: the route AND the service both require the flag. */ private assertSeamEnabled(): void { if (loadEnv().INTERNAL_TEST_SEAM !== "1") { throw ApiError.unauthorized("Test seam is disabled.", [{ reason: "test_seam_disabled" }]); } } }