Repository navigation
test(app): add verify hook revocation and race coverage #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,7 +42,7 @@ Chain strategy: stacked-to-main | |
| - [x] 2.3 GREEN `ports/fakes.ts`: in-memory candidate + authority repos (append-only, current-leaf, revocation); INSERT-only; export. (W3A candidate fake + W3B1 authority fake; W3B2 coverage tests.) | ||
| - [x] 2.4 RED fake tests: candidate replay/collision/stale/concurrency; authority current/superseded/revoked. (W3A 11/11 candidate; W3B1 8/8 core + W3B2 10/10 coverage.) | ||
| - [ ] 2.6 RED+GREEN atomic verifyWork+proof owner (`app/src/worker/verify.ts`): one transaction = `completed→verified` win + proof append (verifier, fresh Delegation, policy); revocation supersede; no partial write. | ||
| - [ ] 2.5 `pnpm check` green. | ||
| - [x] 2.5 `pnpm check` green. (1582 passed / 6 skipped on the full work-unit-3 stack; 9 pre-existing warnings.) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this slice lands, the new coverage and apply-progress section declare W3D2 and task 2.6 complete, but the canonical task immediately above remains unchecked. Any tooling or maintainer using Useful? React with 👍 / 👎. |
||
|
|
||
| ## Phase 3: PostgreSQL | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| import { | ||
| InMemoryDelegationRepository, | ||
| InMemoryPromotionAuthorityRepository, | ||
| InMemoryWorkRepository, | ||
| promotionScopeFor, | ||
| type FencingDirective, | ||
| type LearningSubject, | ||
| type Work, | ||
| type WorkRepository, | ||
| } from '@io/business-domain/src/index.js'; | ||
| import type { DbConnection } from '@io/database/src/connection.js'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { verifyWorkWithProofAtomically } from '../src/worker/verify.js'; | ||
|
|
||
| /** | ||
| * Worker verify hook — COVERAGE (task 2.6, W3D2): revocation supersedes the | ||
| * appended proof; a missing delegation and a CAS version-conflict abort with | ||
| * ZERO writes (the win + proof rollback paths live in `worker-verify-work.test.ts`). | ||
| */ | ||
|
|
||
| const subject: LearningSubject = { skillId: 'sdlc-review', skillVersion: 3 }; | ||
| const scope = promotionScopeFor('acme', subject); | ||
| const policyRef = { policyId: 'pol-1', version: 1 }; | ||
|
|
||
| function completedWork(): Work { | ||
| return { | ||
| workId: 'work-1', | ||
| companyId: 'acme', | ||
| delegationId: 'del-1', | ||
| proposer: 'principal-proposer', | ||
| description: 'promote the learning candidate', | ||
| state: 'completed', | ||
| version: 3, | ||
| fencingToken: 0, | ||
| evidenceRefs: [], | ||
| }; | ||
| } | ||
|
|
||
| /** Work fake whose CAS always loses (version-conflict), never mutating. */ | ||
| class RacedWorkRepository implements WorkRepository { | ||
| private entries = new Map<string, Work>(); | ||
|
|
||
| save(work: Work): Promise<Readonly<Work>> { | ||
| this.entries.set(work.workId, work); | ||
| return Promise.resolve(work); | ||
| } | ||
|
|
||
| get(companyId: string, workId: string): Promise<Work | undefined> { | ||
| const entry = this.entries.get(workId); | ||
| return Promise.resolve( | ||
| entry !== undefined && entry.companyId === companyId ? entry : undefined, | ||
| ); | ||
| } | ||
|
|
||
| updateIfVersion(_work: Work, _expectedVersion: number, _fencing?: FencingDirective) { | ||
| return Promise.resolve({ | ||
| ok: false as const, | ||
| reason: 'version-conflict' as const, | ||
| current: completedWork(), | ||
|
Comment on lines
+56
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the version-conflict scenario, the fake returns Useful? React with 👍 / 👎. |
||
| }); | ||
| } | ||
|
|
||
| listActionableByCompany(): Promise<readonly Work[]> { | ||
| return Promise.resolve([...this.entries.values()]); | ||
| } | ||
|
|
||
| setRecoveryRequest(): ReturnType<WorkRepository['setRecoveryRequest']> { | ||
| return Promise.resolve({ ok: false, reason: 'version-conflict' }); | ||
| } | ||
|
|
||
| listRecoveryRequestedByCompany(): Promise<readonly Work[]> { | ||
| return Promise.resolve([]); | ||
| } | ||
| } | ||
|
|
||
| /** A pass-through one-shot transaction double (no repository rollback here). */ | ||
| function connection(): DbConnection { | ||
| return { | ||
| execute: () => Promise.resolve(undefined), | ||
| query: <T>() => Promise.resolve([] as readonly T[]), | ||
| transaction: <T>(fn: (conn: DbConnection) => Promise<T>) => | ||
| fn({ | ||
| execute: () => Promise.resolve(undefined), | ||
| query: <U>() => Promise.resolve([] as readonly U[]), | ||
| transaction: () => Promise.reject(new Error('nested transactions are forbidden')), | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| async function activeDelegation(): Promise<InMemoryDelegationRepository> { | ||
| const delegation = new InMemoryDelegationRepository(); | ||
| await delegation.save({ | ||
| delegationId: 'del-1', | ||
| companyId: 'acme', | ||
| delegator: 'owner-1', | ||
| delegate: 'principal-promoter', | ||
| authorityScope: { scope, actions: ['learning.promote'] }, | ||
| budget: { currency: 'usd', limit: 1000 }, | ||
| validFrom: 0, | ||
| validUntil: 99999, | ||
| expectedOutcome: 'promote the skill', | ||
| state: 'active', | ||
| }); | ||
| return delegation; | ||
| } | ||
|
|
||
| function input() { | ||
| return { companyId: 'acme', workId: 'work-1', subject, policyRef }; | ||
| } | ||
|
|
||
| describe('verifyWorkWithProofAtomically — coverage', () => { | ||
| it('a revoked superseding revision makes the appended proof resolve revoked', async () => { | ||
| const work = new InMemoryWorkRepository(); | ||
| await work.save(completedWork()); | ||
| const delegation = await activeDelegation(); | ||
| const authority = new InMemoryPromotionAuthorityRepository({ delegation }); | ||
| const result = await verifyWorkWithProofAtomically( | ||
| { | ||
| repositories: () => ({ work, delegation, authority }), | ||
| connection: connection(), | ||
| verifier: 'actor-verifier', | ||
| now: () => 500, | ||
| }, | ||
| input(), | ||
| ); | ||
| if (!result.ok) throw new Error('fixture: expected the verify win'); | ||
| const { current: _current, ...rest } = result.proof; | ||
| expect( | ||
| await authority.appendProof({ | ||
| ...rest, | ||
| proofRevision: 2, | ||
| supersedesProofRevision: 1, | ||
| revoked: true, | ||
| revocationVersion: 1, | ||
| }), | ||
| ).toBe('appended'); | ||
| expect( | ||
| await authority.resolve({ | ||
| sourceRef: 'proof:work-1', | ||
| companyId: 'acme', | ||
| subject, | ||
| policyRef, | ||
| at: 500, | ||
| expectedPrincipalId: 'principal-promoter', | ||
| expectedActorId: 'actor-verifier', | ||
| command: 'learning.promote', | ||
| capability: 'learning.promote', | ||
| scope, | ||
| }), | ||
| ).toEqual({ kind: 'unavailable', reason: 'authority-revoked' }); | ||
| }); | ||
|
|
||
| it('a missing delegation aborts with zero writes', async () => { | ||
| const work = new InMemoryWorkRepository(); | ||
| await work.save(completedWork()); | ||
| const delegation = new InMemoryDelegationRepository(); | ||
| const authority = new InMemoryPromotionAuthorityRepository({ delegation }); | ||
| const result = await verifyWorkWithProofAtomically( | ||
| { | ||
| repositories: () => ({ work, delegation, authority }), | ||
| connection: connection(), | ||
| verifier: 'actor-verifier', | ||
| now: () => 500, | ||
| }, | ||
| input(), | ||
| ); | ||
| expect(result).toEqual({ ok: false, reason: 'delegation-unavailable' }); | ||
| expect((await work.get('acme', 'work-1'))?.state).toBe('completed'); | ||
| expect( | ||
| await authority.resolve({ | ||
| sourceRef: 'proof:work-1', | ||
| companyId: 'acme', | ||
| subject, | ||
| policyRef, | ||
| at: 500, | ||
| expectedPrincipalId: 'principal-promoter', | ||
| expectedActorId: 'actor-verifier', | ||
| command: 'learning.promote', | ||
| capability: 'learning.promote', | ||
| scope, | ||
| }), | ||
| ).toEqual({ kind: 'unavailable', reason: 'authority-missing' }); | ||
| }); | ||
|
|
||
| it('a version-conflict CAS loss is typed and never appends a proof', async () => { | ||
| const raced = new RacedWorkRepository(); | ||
| await raced.save(completedWork()); | ||
| const delegation = await activeDelegation(); | ||
| const authority = new InMemoryPromotionAuthorityRepository({ delegation }); | ||
| const result = await verifyWorkWithProofAtomically( | ||
| { | ||
| repositories: () => ({ work: raced, delegation, authority }), | ||
| connection: connection(), | ||
| verifier: 'actor-verifier', | ||
| now: () => 500, | ||
| }, | ||
| input(), | ||
| ); | ||
| expect(result).toEqual({ | ||
| ok: false, | ||
| reason: 'version-conflict', | ||
| current: completedWork(), | ||
| }); | ||
| expect((await raced.get('acme', 'work-1'))?.state).toBe('completed'); | ||
| expect( | ||
| await authority.resolve({ | ||
| sourceRef: 'proof:work-1', | ||
| companyId: 'acme', | ||
| subject, | ||
| policyRef, | ||
| at: 500, | ||
| expectedPrincipalId: 'principal-promoter', | ||
| expectedActorId: 'actor-verifier', | ||
| command: 'learning.promote', | ||
| capability: 'learning.promote', | ||
| scope, | ||
| }), | ||
| ).toEqual({ kind: 'unavailable', reason: 'authority-missing' }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The W3D1 rollback cannot safely “drop the child's files”:
packages/app/src/worker/verify.tsalready containedverifyEffectbefore W3D1 and is imported byworker.ts, so deleting that file would remove established worker verification behavior and break its consumers. Document a rollback that reverts only the learning-promotion hook additions and its new tests rather than claiming every affected file is a new, unused module.Useful? React with 👍 / 👎.