diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index 1beb8d3..9bb24fd 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -3170,6 +3170,14 @@ test_integrity_note: given: "An existing unfinalized private candidate ref and a separate finalized owner-bound private candidate ref" when: "Direct private-ref intake and authenticated native qualification are requested" then: "Direct intake refuses without a queue record, while the authenticated path records the exact private ref and commit in ordinary testing state" + - id: SCN-integration-adoption-approval-boundaries + given: "An ordinary source-only approval, or an adoption whose actor, action, approval revision, or provenance differs from the approved immutable binding" + when: "Preparation or a lifecycle transition is attempted" + then: "The transition refuses with its structured boundary code and no integration queue record" + - id: SCN-integration-adoption-request-replay + given: "A finalized adoption that has created its one ordinary private-ref integration request" + when: "A concurrent exact-row insert or later request replay is attempted" + then: "Real SQLite rejects the duplicate exact source row, the journal replay refuses, and exactly one queue row remains" observable_outcomes: - "Git ref values, structured refusal codes, durable journal state, and native integration-request fields" determinism_controls: @@ -3180,6 +3188,8 @@ test_integrity_note: - "Mutable lifecycle bindings or overwriteable adoption journal records" - "Claiming crash recovery without preserving actual ref effects" - "Treating a private candidate as an ordinary specialist branch" + - "Using an unrelated, source-only, stale, or mismatched approval as adoption authority" + - "Duplicating a private-ref queue record through a concurrent row or replay" relaxation: did_relax_any_assertion: false if_true_explain_spec_basis: "" diff --git a/src/integration/service.mjs b/src/integration/service.mjs index e027dab..a5e1f9d 100644 --- a/src/integration/service.mjs +++ b/src/integration/service.mjs @@ -80,6 +80,8 @@ export class IntegrationService { ); CREATE INDEX IF NOT EXISTS integration_state_created ON integration_requests(state, created_at, id); + CREATE UNIQUE INDEX IF NOT EXISTS integration_request_exact_source + ON integration_requests(project_id, source_branch, source_commit); `); } diff --git a/test/fleet/integration-adoption.test.mjs b/test/fleet/integration-adoption.test.mjs index b485039..a692194 100644 --- a/test/fleet/integration-adoption.test.mjs +++ b/test/fleet/integration-adoption.test.mjs @@ -97,6 +97,11 @@ function lifecycleInput(context, action, adoptionId, extra = {}) { return { actorId: 'owner', adoptionId, approvalId, approvalRevision, adapter, ...extra }; } +function blockedCodes(error) { + assert.equal(error.code.endsWith('_BLOCKED'), true, `expected a blocked adoption transition, got ${error.code}`); + return error.details.map((blocker) => blocker.code); +} + test('SCN-integration-adoption-private-ref-cas: a real temporary Git transaction atomically creates private archive and candidate refs, and stale expected refs refuse', () => { const context = fixture('torch-adoption-cas'); const adoptionId = 'fixture-adoption'; @@ -210,3 +215,73 @@ test('SCN-integration-adoption-private-ref-intake: finalized private refs enter assert.equal(requested.integrationRequest.targetBranch, 'main'); context.control.close(); }); + +test('SCN-integration-adoption-approval-boundaries: unrelated or source-only approval records cannot authorize an owner adoption, and actor, action, revision, and provenance mismatches each refuse', () => { + const unrelated = fixture('torch-adoption-unrelated-approval'); + const unrelatedId = 'fixture-adoption'; + const unrelatedApproval = unrelated.control.requestApproval({ + requester: unrelated.contributor, approver: 'owner', task: 'TASK-unrelated', + title: 'Ordinary source branch review', summary: 'Not an integration-adoption authority.', + }); + const unrelatedApproved = unrelated.control.decideApproval({ + approvalId: unrelatedApproval.id, decidedBy: 'owner', decision: 'approved', expectedRevision: unrelatedApproval.revision, + note: JSON.stringify({ schema: 'torch.dev/ordinary-source-review/v1alpha1', sourceArea: unrelated.contributor }), + }); + const unrelatedInput = { + actorId: 'owner', adoptionId: unrelatedId, candidateSha: unrelated.candidate, baseTargetSha: unrelated.base, + contributorArea: unrelated.contributor, destinationArea: unrelated.destination, targetBranch: 'main', + candidateRef: `refs/torch/integration-candidates/${unrelatedId}`, expectedOldRef: 'refs/heads/main', + approvalId: unrelatedApproved.id, approvalRevision: unrelatedApproved.revision, + provenance: ['fixture:owner-approved', `candidate:${unrelated.candidate}`], + provenanceManifestDigest: digest(['fixture:owner-approved', `candidate:${unrelated.candidate}`]), + adapter: { name: unrelated.service.adoption.refAdapter.name, id: unrelated.service.adoption.refAdapter.id }, + }; + assert.throws(() => unrelated.service.prepareAdoption(unrelatedInput), (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_BINDING_INVALID')); + assert.deepEqual(unrelated.service.list(), []); + unrelated.control.close(); + + const wrongActor = fixture('torch-adoption-wrong-actor'); + assert.throws(() => wrongActor.service.prepareAdoption({ + ...input(wrongActor, 'prepare', 'fixture-adoption'), actorId: wrongActor.contributor, + }), (error) => blockedCodes(error).includes('OWNER_ACTOR_REQUIRED')); + wrongActor.control.close(); + + const wrongAction = fixture('torch-adoption-wrong-action'); + prepare(wrongAction, 'fixture-adoption'); + assert.throws(() => wrongAction.service.finalizeAdoption(lifecycleInput(wrongAction, 'rollback', 'fixture-adoption')), + (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_BINDING_INVALID')); + wrongAction.control.close(); + + const staleRevision = fixture('torch-adoption-stale-revision'); + prepare(staleRevision, 'fixture-adoption'); + const staleInput = lifecycleInput(staleRevision, 'finalize', 'fixture-adoption'); + assert.throws(() => staleRevision.service.finalizeAdoption({ ...staleInput, approvalRevision: staleInput.approvalRevision - 1 }), + (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_REVISION_STALE')); + staleRevision.control.close(); + + const wrongProvenance = fixture('torch-adoption-wrong-provenance'); + assert.throws(() => wrongProvenance.service.prepareAdoption({ + ...input(wrongProvenance, 'prepare', 'fixture-adoption'), provenance: ['fixture:owner-approved', 'unapproved:alternate-provenance'], + }), (error) => blockedCodes(error).includes('ADOPTION_PROVENANCE_DIGEST_MISMATCH')); + wrongProvenance.control.close(); +}); + +test('SCN-integration-adoption-request-replay: real SQLite rejects a duplicate exact private-ref queue row, and a completed adoption request cannot replay into a second row', () => { + const context = fixture('torch-adoption-request-replay'); + const adoptionId = 'fixture-adoption'; + prepare(context, adoptionId); + const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId)); + const requested = context.service.requestAdoptedCandidate(lifecycleInput(context, 'request', adoptionId)); + const duplicate = context.control.database.prepare(`INSERT INTO integration_requests ( + id, project_id, source_area, source_branch, source_commit, target_branch, + base_target_commit, state, reason, required_checks, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + assert.throws(() => duplicate.run( + 'duplicate-race-row', context.control.projectId, context.contributor, finalized.candidateRef, context.candidate, + 'main', context.base, 'testing', null, '[]', requested.requestedAt, requested.requestedAt, + ), /UNIQUE constraint failed: integration_requests.project_id, integration_requests.source_branch, integration_requests.source_commit/); + assert.throws(() => context.service.requestAdoptedCandidate(lifecycleInput(context, 'request', adoptionId)), + (error) => error.code === 'ADOPTION_REQUEST_STATE_INVALID'); + assert.deepEqual(context.service.list().map((record) => record.id), [requested.integrationRequest.id]); + context.control.close(); +});