test(acl)(#25): extract pure grantIsLive/liveWhere + unit tests
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Move the lifecycle predicate into lib/acl/lifecycle.ts (re-exported from the ACL module) so it can be unit-tested without a database. Adds Node built-in test-runner coverage for the boundary conditions that define the fix: past expiry -> dead, expiry == now -> dead (exclusive), revoke beats a future expiry, and liveWhere kept in lockstep with grantIsLive. Runner is node:test via ts-node (no new dependency; pnpm add is blocked by the nix-built node_modules hoist pattern). 'npm test' -> 7 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
85e781dfa9
commit
e2cf10a66d
4 changed files with 90 additions and 31 deletions
|
|
@ -21,6 +21,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
|
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
|
||||||
"build:client": "tsup src/client.ts -d dist/client",
|
"build:client": "tsup src/client.ts -d dist/client",
|
||||||
|
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/*.test.ts",
|
||||||
"prisma:generate": "npx prisma generate",
|
"prisma:generate": "npx prisma generate",
|
||||||
"prisma:migrate": "npx prisma migrate deploy",
|
"prisma:migrate": "npx prisma migrate deploy",
|
||||||
"prisma:create": "npx prisma db push --preview-feature",
|
"prisma:create": "npx prisma db push --preview-feature",
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,11 @@
|
||||||
import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
|
import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
|
||||||
import prisma from '../../../db.js';
|
import prisma from '../../../db.js';
|
||||||
|
import { liveWhere } from './lifecycle.js';
|
||||||
|
|
||||||
/**
|
// Re-export the single lifecycle predicate so callers (e.g.
|
||||||
* "Is this grant valid right now?" — the single lifecycle predicate, used
|
// Backend.validateToken) import it from the ACL module. The implementation
|
||||||
* identically at redeem time (Backend.validateToken) and sign time
|
// lives in ./lifecycle.ts so it can be unit-tested without a database.
|
||||||
* (checkIfPubkeyAllowed). Both Token and SigningCondition carry `revokedAt`
|
export { grantIsLive } from './lifecycle.js';
|
||||||
* + `expiresAt`, so one shape governs token grants and manual overrides.
|
|
||||||
*
|
|
||||||
* The original #24 bug was possible because redeem-time checked expiry and
|
|
||||||
* sign-time didn't — two definitions of "valid" that drifted. Defining it
|
|
||||||
* once makes them impossible to disagree. See aiolabs/nsecbunkerd#25.
|
|
||||||
*/
|
|
||||||
export function grantIsLive(
|
|
||||||
grant: { revokedAt?: Date | null; expiresAt?: Date | null },
|
|
||||||
now: Date = new Date(),
|
|
||||||
): boolean {
|
|
||||||
if (grant.revokedAt) return false;
|
|
||||||
if (grant.expiresAt && grant.expiresAt.getTime() <= now.getTime()) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prisma `where` fragment selecting only lifecycle-live rows (not revoked,
|
|
||||||
* not past expiry) — grantIsLive expressed in SQL so the live filter happens
|
|
||||||
* in the query, not in app code after the fact. `now` is threaded in so a
|
|
||||||
* single request evaluates every row against one clock reading.
|
|
||||||
*/
|
|
||||||
function liveWhere(now: Date) {
|
|
||||||
return {
|
|
||||||
revokedAt: null,
|
|
||||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layered authorization check. Order matters (denials beat grants):
|
* Layered authorization check. Order matters (denials beat grants):
|
||||||
|
|
|
||||||
40
src/daemon/lib/acl/lifecycle.ts
Normal file
40
src/daemon/lib/acl/lifecycle.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Pure grant-lifecycle logic, extracted from the ACL so it can be unit-tested
|
||||||
|
* without a database and reused verbatim at redeem time and sign time.
|
||||||
|
*
|
||||||
|
* The original #24 bug was possible because redeem-time checked expiry and
|
||||||
|
* sign-time didn't — two definitions of "valid" that drifted. Defining "is
|
||||||
|
* this grant valid right now?" exactly once makes them impossible to disagree.
|
||||||
|
* See aiolabs/nsecbunkerd#25.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The lifecycle fields every grant (Token, SigningCondition) carries. */
|
||||||
|
export type Lifecycle = {
|
||||||
|
revokedAt?: Date | null;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Is this grant valid right now?" — the single lifecycle predicate. A grant
|
||||||
|
* is live iff it has not been revoked and its expiry (if any) is still in the
|
||||||
|
* future. Expiry is treated as exclusive at the boundary: a grant whose
|
||||||
|
* `expiresAt` equals `now` is already dead.
|
||||||
|
*/
|
||||||
|
export function grantIsLive(grant: Lifecycle, now: Date = new Date()): boolean {
|
||||||
|
if (grant.revokedAt) return false;
|
||||||
|
if (grant.expiresAt && grant.expiresAt.getTime() <= now.getTime()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `grantIsLive` expressed as a Prisma `where` fragment, so the live filter
|
||||||
|
* runs in the query rather than in app code after the fetch. `now` is threaded
|
||||||
|
* in explicitly so a single request evaluates every row against one clock
|
||||||
|
* reading. Kept in lockstep with `grantIsLive` (see lifecycle.test.ts).
|
||||||
|
*/
|
||||||
|
export function liveWhere(now: Date) {
|
||||||
|
return {
|
||||||
|
revokedAt: null,
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
44
tests/lifecycle.test.ts
Normal file
44
tests/lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { grantIsLive, liveWhere } from '../src/daemon/lib/acl/lifecycle';
|
||||||
|
|
||||||
|
// Fixed reference clock so the assertions don't depend on wall time.
|
||||||
|
const now = new Date('2026-06-19T12:00:00.000Z');
|
||||||
|
const past = new Date(now.getTime() - 60_000);
|
||||||
|
const future = new Date(now.getTime() + 60_000);
|
||||||
|
|
||||||
|
test('grantIsLive: no revoke, no expiry -> live', () => {
|
||||||
|
assert.equal(grantIsLive({}, now), true);
|
||||||
|
assert.equal(grantIsLive({ revokedAt: null, expiresAt: null }, now), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grantIsLive: future expiry -> live', () => {
|
||||||
|
assert.equal(grantIsLive({ expiresAt: future }, now), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grantIsLive: past expiry -> dead (the #24 case the old code missed at sign time)', () => {
|
||||||
|
assert.equal(grantIsLive({ expiresAt: past }, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grantIsLive: expiry exactly now -> dead (boundary is exclusive)', () => {
|
||||||
|
assert.equal(grantIsLive({ expiresAt: new Date(now.getTime()) }, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grantIsLive: revoked -> dead even with a future expiry (revoke wins)', () => {
|
||||||
|
assert.equal(grantIsLive({ revokedAt: past, expiresAt: future }, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grantIsLive: defaults now to the current time', () => {
|
||||||
|
assert.equal(grantIsLive({ expiresAt: new Date(Date.now() + 3_600_000) }), true);
|
||||||
|
assert.equal(grantIsLive({ expiresAt: new Date(Date.now() - 3_600_000) }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// liveWhere is the SQL mirror of grantIsLive; pin its shape so the two
|
||||||
|
// can't silently drift (a drift would re-open the redeem-vs-sign gap #25
|
||||||
|
// exists to close).
|
||||||
|
test('liveWhere: mirrors grantIsLive as a prisma where-fragment', () => {
|
||||||
|
assert.deepEqual(liveWhere(now), {
|
||||||
|
revokedAt: null,
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue