Skip to content
137 changes: 128 additions & 9 deletions web/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import path from 'node:path'
import { getTableName } from 'drizzle-orm'
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
import { canonicalS3Marker } from '../test-support/filesystem-grant-marker-fixtures'
import { LEGACY_CLARIFICATION_MAX_TEXT_BYTES } from '@/lib/mcps/legacy-clarification'

const taskEventRedisEnvironment = {
publisher: process.env.FORGE_TASK_EVENT_PUBLISHER_REDIS_URL,
Expand Down Expand Up @@ -64,12 +65,12 @@ const mockLoadProtectedApprovalReviewPreflight = vi.fn().mockResolvedValue(null)
const mockReadProtectedMcpOperatorReview = vi.fn().mockResolvedValue([])
const mockListApprovedPackagePlanRegistrations = vi.fn().mockResolvedValue([])
const {
mockAppendArchitectClarificationAnswer,
mockAppendArchitectClarificationAnswers,
mockReadS4RuntimeModeV1,
mockArchitectPlanStorageConfiguration,
mockGenerateTaskTitle,
} = vi.hoisted(() => ({
mockAppendArchitectClarificationAnswer: vi.fn(),
mockAppendArchitectClarificationAnswers: vi.fn(),
mockReadS4RuntimeModeV1: vi.fn().mockResolvedValue('protected'),
mockArchitectPlanStorageConfiguration: vi.fn().mockReturnValue({
mode: 'protected', digestKey: Buffer.alloc(32, 7), digestKeyId: 'test-v1',
Expand All @@ -82,7 +83,7 @@ vi.mock('@/lib/mcps/protected-review-preflight', () => ({
vi.mock('@/lib/mcps/history-reader', () => ({
listApprovedPackagePlanRegistrations: mockListApprovedPackagePlanRegistrations,
readProtectedMcpOperatorReview: mockReadProtectedMcpOperatorReview,
appendArchitectClarificationAnswer: mockAppendArchitectClarificationAnswer,
appendArchitectClarificationAnswers: mockAppendArchitectClarificationAnswers,
}))
vi.mock('@/lib/mcps/s4-lease', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/mcps/s4-lease')>(),
Expand Down Expand Up @@ -7319,6 +7320,9 @@ describe('POST /api/tasks/:id/questions', () => {
.mockReturnValueOnce(chain([{ id: 'task-1', status: 'awaiting_answers' }]))
.mockReturnValueOnce(chain([{
id: questionId,
status: 'open',
answerReferenceId: null,
questionEntryId: `clarification_question:${questionId}`,
sourcePlanArtifactId: '88888888-8888-4888-8888-888888888888',
sourcePlanVersion: 1,
}]))
Expand All @@ -7328,7 +7332,7 @@ describe('POST /api/tasks/:id/questions', () => {
createdAt: new Date('2026-07-22T00:00:00.000Z'),
answeredAt: new Date('2026-07-22T00:01:00.000Z'),
}]))
mockAppendArchitectClarificationAnswer.mockResolvedValue({ answerId: 'answer-1', allAnswered: true })
mockAppendArchitectClarificationAnswers.mockResolvedValue([{ answerId: 'answer-1', allAnswered: true }])
mockRedisLpush.mockResolvedValue(1)
mockRedisEval.mockResolvedValue(1)

Expand All @@ -7351,9 +7355,9 @@ describe('POST /api/tasks/:id/questions', () => {
allAnswered: true,
})
expect(JSON.stringify(body)).not.toContain('RAW-')
expect(mockAppendArchitectClarificationAnswer).toHaveBeenCalledWith(expect.objectContaining({
answer, questionId, taskId: 'task-1',
}))
expect(mockAppendArchitectClarificationAnswers).toHaveBeenCalledWith([
expect.objectContaining({ answer, questionId, taskId: 'task-1' }),
])
expect(mockDbUpdate).not.toHaveBeenCalled()
expect(mockRedisPublish).not.toHaveBeenCalled()
const answeredEvent = mockRedisEval.mock.calls.find((call) => call[4] === 'questions:answered')
Expand Down Expand Up @@ -7459,7 +7463,7 @@ describe('POST /api/tasks/:id/questions', () => {
suggestions: ['main'],
answer,
}])
expect(mockAppendArchitectClarificationAnswer).not.toHaveBeenCalled()
expect(mockAppendArchitectClarificationAnswers).not.toHaveBeenCalled()
expect(mockRedisLpush).toHaveBeenCalledWith('forge:answers', JSON.stringify({ taskId }))
expect(JSON.stringify(mockRedisLpush.mock.calls)).not.toContain(answer)
expect(JSON.stringify(mockRedisEval.mock.calls)).not.toContain(answer)
Expand All @@ -7477,6 +7481,9 @@ describe('POST /api/tasks/:id/questions', () => {
.mockReturnValueOnce(chain([{ id: taskId, status: 'awaiting_answers' }]))
.mockReturnValueOnce(chain([{
id: questionId,
status: 'open',
answerReferenceId: null,
questionEntryId: `clarification_question:${questionId}`,
sourcePlanArtifactId: null,
sourcePlanVersion: null,
}]))
Expand All @@ -7491,7 +7498,119 @@ describe('POST /api/tasks/:id/questions', () => {
expect(response.status).toBe(409)
expect(mockDbTransaction).not.toHaveBeenCalled()
expect(mockDbUpdate).not.toHaveBeenCalled()
expect(mockAppendArchitectClarificationAnswer).not.toHaveBeenCalled()
expect(mockAppendArchitectClarificationAnswers).not.toHaveBeenCalled()
})

it('validates the whole protected form before calling the atomic writer', async () => {
mockGetSession.mockResolvedValue(FAKE_SESSION)
const firstQuestionId = '77777777-7777-4777-8777-777777777777'
const secondQuestionId = '99999999-9999-4999-8999-999999999999'
mockDbSelect
.mockReturnValueOnce(chain([{ id: 'task-1', status: 'awaiting_answers' }]))
.mockReturnValueOnce(chain([{
id: firstQuestionId,
status: 'open',
answerReferenceId: null,
questionEntryId: `clarification_question:${firstQuestionId}`,
sourcePlanArtifactId: '88888888-8888-4888-8888-888888888888',
sourcePlanVersion: 1,
}, {
id: secondQuestionId,
status: 'answered',
answerReferenceId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
questionEntryId: `clarification_question:${secondQuestionId}`,
sourcePlanArtifactId: '88888888-8888-4888-8888-888888888888',
sourcePlanVersion: 1,
}]))

const { POST } = await import('@/app/api/tasks/[id]/questions/route')
const response = await POST(authRequest('/api/tasks/task-1/questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: [
{ id: firstQuestionId, answer: 'first' },
{ id: secondQuestionId, answer: 'second' },
] }),
}) as never, { params: Promise.resolve({ id: 'task-1' }) })

expect(response.status).toBe(409)
expect(mockAppendArchitectClarificationAnswers).not.toHaveBeenCalled()
expect(mockRedisLpush).not.toHaveBeenCalled()
expect(mockRedisEval).not.toHaveBeenCalled()
})

it.each([
{
name: 'duplicate ids',
answers: [
{ id: '77777777-7777-4777-8777-777777777777', answer: 'first' },
{ id: '77777777-7777-4777-8777-777777777777', answer: 'second' },
],
},
{
name: 'oversized answer',
answers: [{
id: '77777777-7777-4777-8777-777777777777',
answer: 'x'.repeat(LEGACY_CLARIFICATION_MAX_TEXT_BYTES + 1),
}],
},
])('rejects $name before any protected write or continuation', async ({ answers }) => {
mockGetSession.mockResolvedValue(FAKE_SESSION)
mockDbSelect.mockReturnValueOnce(chain([{ id: 'task-1', status: 'awaiting_answers' }]))

const { POST } = await import('@/app/api/tasks/[id]/questions/route')
const response = await POST(authRequest('/api/tasks/task-1/questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
}) as never, { params: Promise.resolve({ id: 'task-1' }) })

expect(response.status).toBe(400)
expect(mockAppendArchitectClarificationAnswers).not.toHaveBeenCalled()
expect(mockRedisLpush).not.toHaveBeenCalled()
expect(mockRedisEval).not.toHaveBeenCalled()
})

it('durably queues re-plan before best-effort progress publication', async () => {
mockGetSession.mockResolvedValue(FAKE_SESSION)
const questionId = '77777777-7777-4777-8777-777777777777'
mockDbSelect
.mockReturnValueOnce(chain([{ id: 'task-1', status: 'awaiting_answers' }]))
.mockReturnValueOnce(chain([{
id: questionId,
status: 'open',
answerReferenceId: null,
questionEntryId: `clarification_question:${questionId}`,
sourcePlanArtifactId: '88888888-8888-4888-8888-888888888888',
sourcePlanVersion: 1,
}]))
.mockReturnValueOnce(chain([{
id: questionId,
status: 'answered',
createdAt: new Date('2026-07-30T00:00:00.000Z'),
answeredAt: new Date('2026-07-30T00:01:00.000Z'),
}]))
mockAppendArchitectClarificationAnswers.mockResolvedValue([{
answerId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
allAnswered: true,
}])
mockRedisLpush.mockResolvedValue(1)
mockRedisEval.mockRejectedValueOnce(new Error('RAW-EVENT-OUTAGE-SENTINEL'))

const { POST } = await import('@/app/api/tasks/[id]/questions/route')
const response = await POST(authRequest('/api/tasks/task-1/questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: [{ id: questionId, answer: 'RAW-ANSWER-SENTINEL' }] }),
}) as never, { params: Promise.resolve({ id: 'task-1' }) })

expect(response.status).toBe(200)
expect(mockRedisLpush).toHaveBeenCalledTimes(1)
expect(mockRedisLpush.mock.invocationCallOrder[0]).toBeLessThan(
mockRedisEval.mock.invocationCallOrder[0],
)
expect(JSON.stringify(mockRedisLpush.mock.calls)).not.toContain('RAW-ANSWER-SENTINEL')
expect(JSON.stringify(mockRedisEval.mock.calls)).not.toContain('RAW-ANSWER-SENTINEL')
})
})

Expand Down
17 changes: 17 additions & 0 deletions web/__tests__/core-diagnostic-output-closure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4598,6 +4598,12 @@ describe('core output source sentinel', () => {
const malformedRecoveryMethod = queueSource.match(
/ private async removeMalformedRecoveryMember\([\s\S]*?\n private decodeRetryPromotionTransition/,
)?.[0] ?? ''
const currentRecoveryScript = queueSource.match(
/const RECOVER_STUCK_JOB_SCRIPT = `([\s\S]*?)`\n\nconst RECOVER_LEGACY_JOB_SCRIPT/,
)?.[1] ?? ''
const legacyRecoveryScript = queueSource.match(
/const RECOVER_LEGACY_JOB_SCRIPT = `([\s\S]*?)`\n\nconst RECOVER_MALFORMED_JOB_SCRIPT/,
)?.[1] ?? ''

expect(queueSource).toContain("failureCategory: DEAD_LETTER_FAILURE_CATEGORY")
expect(queueSource).toContain('schemaVersion: QUEUE_ENVELOPE_SCHEMA_VERSION')
Expand Down Expand Up @@ -4838,6 +4844,17 @@ describe('core output source sentinel', () => {
)
expect(queueSource).toContain("redis.call('RPUSH', KEYS[1], ARGV[1])")
expect(queueSource).toContain('const STUCK_RECOVERY_SCAN_LIMIT = 100')
expect(currentRecoveryScript).toContain('valid_marker(current_marker, now_ms)')
expect(currentRecoveryScript).not.toContain('legacy_marker_timestamp(current_marker, now_ms)')
expect(legacyRecoveryScript).toContain(
'local timestamp = legacy_marker_timestamp(current_marker, now_ms)',
)
expect(legacyRecoveryScript).not.toContain('valid_marker(current_marker, now_ms)')
expect(queueSource).toContain(
"not string.match(marker, '^[1-9][0-9]*$')",
)
expect(queueSource).toContain('numeric_timestamp > 9007199254740991')
expect(queueSource).toContain('numeric_timestamp > now_ms')
expect(jsonKeyScanSource).toContain('const MAX_JSON_CODE_UNITS = 1_000_000')
expect(queueSource).toContain('-- forge:queue:recover-malformed-v1')
expect(queueSource).toContain(
Expand Down
59 changes: 59 additions & 0 deletions web/__tests__/epic-172-s4-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,65 @@ describe('Epic 172 S4 PostgreSQL CI contract', () => {
expect(s4Migration).toMatch(/RETURNS TABLE \(purpose text, source_kind text, task_id uuid/)
})

it('certifies every protected clarification table and routine in the S4 owner finalizer', () => {
const ownedTableInventory = s4RoleBootstrap.match(
/const OWNED_TABLES = \[([\s\S]*?)\] as const/,
)?.[1] ?? ''
const exactRoutineInventory = s4RoleBootstrap.match(
/const EXACT_CLARIFICATION_ROUTINES = \[([\s\S]*?)\] as const/,
)?.[1] ?? ''

for (const table of [
'architect_clarification_answers',
'architect_clarification_answer_writes',
]) {
expect(ownedTableInventory).toContain(`'${table}'`)
}
for (const routine of [
{
identity: 'forge.bind_architect_replan_context_v3(uuid,uuid)',
name: 'bind_architect_replan_context_v3',
grantee: 'forge_architect_plan_writer',
},
{
identity: 'forge.resolve_architect_plan_entry_v2(uuid)',
name: 'resolve_architect_plan_entry_v2',
grantee: 'forge_architect_plan_resolver',
},
{
identity: 'forge.append_architect_clarification_answer_v1(bytea,uuid,uuid,uuid,bigint,uuid,text,text,text)',
name: 'append_architect_clarification_answer_v1',
grantee: 'forge_architect_plan_history_reader',
},
]) {
expect(exactRoutineInventory).toContain(`identity: '${routine.identity}'`)
expect(exactRoutineInventory).toContain(`name: '${routine.name}'`)
expect(exactRoutineInventory).toContain(`grantee: '${routine.grantee}'`)
}

expect(s4RoleBootstrap).toContain('acl.grantee <> table_row.relowner')
expect(s4RoleBootstrap).toContain("acl.grantee = 0 and acl.privilege_type = 'EXECUTE'")
expect(s4RoleBootstrap).toContain(
'routine.oid = pg_catalog.to_regprocedure(expected.routine_identity)',
)
expect(s4RoleBootstrap).toMatch(
/if exists \(\s+with expected\(routine_identity, routine_name, expected_grantee\)/,
)
expect(s4RoleBootstrap).toContain('observed.proowner <>')
expect(s4RoleBootstrap).toContain('observed.acl_count <> 2')
expect(s4RoleBootstrap).toContain('observed.owner_execute_count <> 1')
expect(s4RoleBootstrap).toContain('observed.expected_execute_count <> 1')
expect(s4RoleBootstrap).toContain('and not acl.is_grantable')
expect(s4RoleBootstrap).toContain(
'pg_catalog.to_regprocedure(expected.routine_identity) = routine.oid',
)
expect(s4RoleBootstrap).toContain(
"raise exception 'The exact S4 clarification routine authority is incomplete'",
)
expect(s4RoleBootstrap).not.toContain('acl.grantee <> case routine.proname')
expect(s4RoleBootstrap).toContain(') <> 73 then')
})

it('audits the complete protected clarification history set without truncation', () => {
const historyReader = s4Migration.match(
/CREATE OR REPLACE FUNCTION forge\.read_architect_plan_history_v1\([\s\S]*?\n\$\$;/,
Expand Down
Loading