diff --git a/src/core/PartialGraphContext.js b/src/core/PartialGraphContext.js index cc123b3..5e45a1a 100644 --- a/src/core/PartialGraphContext.js +++ b/src/core/PartialGraphContext.js @@ -120,15 +120,18 @@ export class PartialGraphContext { _addChallengeProof(proof) { const subjectId = this._resolveNodeId(proof.subject); const now = Date.now(); - const issuedAt = proof.issuedAt || proof.issued_at || proof.timestamp || now; - const expiresAt = proof.expiresAt || proof.expires_at || null; + // 0 is a valid timestamp (epoch-issued / already-expired); `||` would + // replace it with the wall clock or null, making an epoch-issued proof + // the most recent one and an epoch-expired proof never expire. + const issuedAt = proof.issuedAt ?? proof.issued_at ?? proof.timestamp ?? now; + const expiresAt = proof.expiresAt ?? proof.expires_at ?? null; const record = { name: proof.name, subject: subjectId, issuedAt, expiresAt, source: proof.source || 'partial', - proofId: proof.proofId || proof.proof_id || null, + proofId: proof.proofId ?? proof.proof_id ?? null, metadata: proof.metadata || null }; const key = this._challengeKey(record.name, subjectId); diff --git a/tests/rigor/challenge-proof.test.js b/tests/rigor/challenge-proof.test.js index 38754a4..45e5aed 100644 --- a/tests/rigor/challenge-proof.test.js +++ b/tests/rigor/challenge-proof.test.js @@ -163,6 +163,23 @@ describe('PartialGraphContext.getChallengeProof (rigor)', () => { `getChallengeProof returned an expired proof in ${inv.failureCount} cases`); }); + it('FIXED: zero timestamps are valid, not sentinels', () => { + const ctx = buildContext({ subjectIds: ['user:abc'], proofs: [ + { name: 'mfa', subject: 'user:abc', issuedAt: 42802, expiresAt: null }, + { name: 'mfa', subject: 'user:abc', issuedAt: 0, expiresAt: null } + ] }); + const subjectId = ctx._resolveNodeId('user:abc'); + // issuedAt: 0 must not be replaced with the wall clock + const best = ctx.getChallengeProof('mfa', subjectId, null, 167844); + assert.equal(best.issuedAt, 42802, 'epoch-issued proof must not win via wall-clock stamping'); + // expiresAt: 0 is an already-expired proof, not "no expiry" + const ctx2 = buildContext({ subjectIds: ['user:abc'], proofs: [ + { name: 'mfa', subject: 'user:abc', issuedAt: 100, expiresAt: 0 } + ] }); + const best2 = ctx2.getChallengeProof('mfa', subjectId, null, 167844); + assert.equal(best2, null, 'epoch-expired proof must stay expired'); + }); + it('returns the most-recently-issued proof when withinMs=null', async () => { async function recentCheck(proofs, name, subject, now) { const ctx = buildContext({ subjectIds: [subject], proofs }); @@ -218,7 +235,8 @@ describe('PartialGraphContext.getChallengeProof (rigor)', () => { const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'most-recent'); assert.ok(inv); assert.equal(inv.passed, true, - `getChallengeProof did not return the most-recent proof in ${inv.failureCount} cases`); + `getChallengeProof did not return the most-recent proof in ${inv.failureCount} cases: ` + + JSON.stringify((report.failures || []).slice(0, 2).map(f => ({ name: f.name, msg: f.message, seq: (f.sequence || []).map(s => s.args) })))); }); it('enforces withinMs freshness window', async () => {