| … | … | @@ -420,6 +420,52 @@ test('two writers can both append after addWriter', async (t) => { |
| 420 | 420 | }) |
| 421 | 421 | }) |
| 422 | 422 | |
| 423 | // ── gip-overlay P4: writer-set membership IS the ref push authority ── |
| 424 | // A node may advance refs iff its writer key is in the per-repo autobase's |
| 425 | // writer set. Grant = addWriter (repo invite/join); revoke = removeWriter. |
| 426 | // A non-writer's op never reaches the linearized view (forgery rejected); |
| 427 | // reads are open regardless. |
| 428 | |
| 429 | test('refs authority: writer-set gates ref advances (forgery / grant / revoke)', async (t) => { |
| 430 | const storeA = tmpStore() |
| 431 | const storeB = tmpStore() |
| 432 | const A = new HyperIssues(storeA, { namespace: 'gip-auth' }) |
| 433 | await A.ready() |
| 434 | const B = new HyperIssues(storeB, { namespace: 'gip-auth', bootstrapKey: A.keyHex }) |
| 435 | await B.ready() |
| 436 | t.teardown(async () => { |
| 437 | for (const x of [A, B, storeA, storeB]) { try { await x.close() } catch {} } |
| 438 | }) |
| 439 | await replicateAndSync([A.base, B.base]) |
| 440 | |
| 441 | // 1. forgery: B is NOT a writer — its ref op must not reach A's view |
| 442 | t.absent(B.writable, 'B is not a writer yet') |
| 443 | try { await B.updateRef('refs/heads/main', 'aaa', 'bob') } catch {} |
| 444 | await replicateAndSync([A.base, B.base]) |
| 445 | t.is(await A.getRef('refs/heads/main'), null, 'non-writer ref op rejected (forgery)') |
| 446 | |
| 447 | // 2. grant: addWriter B → B advances the ref, A sees it |
| 448 | await A.addWriter('bob', { writerKey: B.localKeyHex, role: 'writer' }) |
| 449 | await replicateAndSync([A.base, B.base]) |
| 450 | t.ok(B.writable, 'B writable after grant') |
| 451 | await B.updateRef('refs/heads/main', 'bbb', 'bob') |
| 452 | await replicateAndSync([A.base, B.base]) |
| 453 | t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'granted writer advance accepted') |
| 454 | |
| 455 | // 3. revoke: removeWriter B → B's later op is not applied; reads still work |
| 456 | await A.removeWriter('bob') |
| 457 | await replicateAndSync([A.base, B.base]) |
| 458 | try { await B.updateRef('refs/heads/main', 'ccc', 'bob', 'bbb') } catch {} |
| 459 | await replicateAndSync([A.base, B.base]) |
| 460 | t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'post-revoke op not applied — ref unchanged') |
| 461 | t.is((await B.getRef('refs/heads/main')).hash, 'bbb', 'revoked node can still READ') |
| 462 | |
| 463 | // 4. remaining writer (A) keeps write authority |
| 464 | await A.updateRef('refs/heads/main', 'ddd', 'alice', 'bbb') |
| 465 | await replicateAndSync([A.base, B.base]) |
| 466 | t.is((await A.getRef('refs/heads/main')).hash, 'ddd', 'owner still advances refs after revoke') |
| 467 | }) |
| 468 | |
| 423 | 469 | test('createWithId preserves id, status, and timestamps (migration path)', async (t) => { |
| 424 | 470 | const tracker = await makeTracker(t, 'migration-1') |
| 425 | 471 | |