Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
A request that finds no live token grant exited `checkIfPubkeyAllowed`
at `undefined` regardless of *why* — whether the binding never existed
or had simply lapsed (expired / token-revoked). `undefined` routes the
caller into the admin-prompt path, which for an unattended client (an
ATM spire) means the request hangs until a BunkerTimeoutError.
The Sintra smoke proved the divergence directly: a KeyUser-level revoke
exits at step 2 with `false` and the spire sees a clean BunkerRejected
("Pairing Required"), but a TTL expiry fell through to `undefined` and
the spire saw a BunkerTimeout ("Signer Unreachable") — same operator
intent ("this pairing is over"), two different, one-broken outcomes.
Classify the no-live-grant case before returning: if a token bound to
this KeyUser *would* have granted the request (its policy carries a
matching rule; for `connect`, any bound token) but is now expired or
token-revoked, return `false` so the client re-pairs immediately. Only
a genuinely never-granted (method/kind) request stays `undefined` so an
admin can still approve new permission out-of-band.
Usage-cap exhaustion is left at `undefined` deliberately: a windowed
cap is a temporary rate-limit that refills as the window rolls, not a
permanent lapse, so it must not be reclassed as the re-pair signal. A
dedicated rate-limit reply is a separate follow-up.
Tests: the #24 expired-token and token-revoke guards now assert `false`;
added connect-lapse, and two distinction cases proving a never-granted
method (live token, or a method the lapsed token never covered) stays
`undefined`.
340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
// Integration tests for checkIfPubkeyAllowed against a real (throwaway) SQLite
|
|
// DB — the wiring that actually closes #24 (step-4 Token join filtered by
|
|
// liveWhere) which the pure lifecycle.test.ts cannot exercise.
|
|
//
|
|
// Run via `npm run test:integration`, which sets DATABASE_URL to a temp file
|
|
// and routes through tests/register-ts.cjs (ts-node + `.js`->`.ts` resolution).
|
|
// Requires the prisma engine env (PRISMA_QUERY_ENGINE_LIBRARY etc.) on PATH —
|
|
// present in CI/nix; in the devShell pending #30. See #29.
|
|
import { test, before, beforeEach, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import prisma from '../src/db';
|
|
import { checkIfPubkeyAllowed, recordSigning } from '../src/daemon/lib/acl/index';
|
|
|
|
const KEY = 'test-key';
|
|
const PUB = 'client-pubkey-1';
|
|
|
|
const past = () => new Date(Date.now() - 60_000);
|
|
const future = () => new Date(Date.now() + 60_000);
|
|
const signEvt = { kind: 1 } as any;
|
|
|
|
// ---- seeding helpers -------------------------------------------------------
|
|
|
|
async function seedPolicy(method: string, kind: string | null): Promise<number> {
|
|
const policy = await prisma.policy.create({
|
|
data: { name: 'p', rules: { create: [{ method, kind: kind ?? undefined }] } },
|
|
});
|
|
return policy.id;
|
|
}
|
|
|
|
async function seedKeyUser(opts: { revokedAt?: Date | null } = {}): Promise<number> {
|
|
const ku = await prisma.keyUser.create({
|
|
data: { keyName: KEY, userPubkey: PUB, revokedAt: opts.revokedAt ?? null },
|
|
});
|
|
return ku.id;
|
|
}
|
|
|
|
async function seedToken(
|
|
keyUserId: number,
|
|
policyId: number,
|
|
opts: { expiresAt?: Date | null; revokedAt?: Date | null } = {},
|
|
): Promise<void> {
|
|
await prisma.token.create({
|
|
data: {
|
|
keyName: KEY,
|
|
token: 't-' + Math.random().toString(36).slice(2),
|
|
clientName: 'c',
|
|
createdBy: 'admin',
|
|
keyUserId,
|
|
policyId,
|
|
redeemedAt: new Date(),
|
|
expiresAt: opts.expiresAt ?? null,
|
|
revokedAt: opts.revokedAt ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function seedCondition(
|
|
keyUserId: number,
|
|
opts: { method: string; kind?: string | null; allowed: boolean; expiresAt?: Date | null; revokedAt?: Date | null },
|
|
): Promise<void> {
|
|
await prisma.signingCondition.create({
|
|
data: {
|
|
keyUserId,
|
|
method: opts.method,
|
|
kind: opts.kind ?? undefined,
|
|
allowed: opts.allowed,
|
|
expiresAt: opts.expiresAt ?? null,
|
|
revokedAt: opts.revokedAt ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
type RuleSeed = { method: string; kind: string | null; maxUsageCount?: number | null; windowSeconds?: number | null };
|
|
|
|
async function seedPolicyRules(rules: RuleSeed[]): Promise<number> {
|
|
const policy = await prisma.policy.create({
|
|
data: {
|
|
name: 'p',
|
|
rules: {
|
|
create: rules.map((r) => ({
|
|
method: r.method,
|
|
kind: r.kind ?? undefined,
|
|
maxUsageCount: r.maxUsageCount ?? null,
|
|
windowSeconds: r.windowSeconds ?? null,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
return policy.id;
|
|
}
|
|
|
|
async function seedSigningLog(
|
|
keyUserId: number,
|
|
method: string,
|
|
kind: string | null,
|
|
count: number,
|
|
ageMs = 0,
|
|
): Promise<void> {
|
|
const createdAt = new Date(Date.now() - ageMs);
|
|
for (let i = 0; i < count; i++) {
|
|
await prisma.signingLog.create({ data: { keyUserId, method, kind, createdAt } });
|
|
}
|
|
}
|
|
|
|
// ---- lifecycle -------------------------------------------------------------
|
|
|
|
before(() => {
|
|
const url = process.env.DATABASE_URL || '';
|
|
// Hard guard: these tests truncate tables. Never let that touch a real DB.
|
|
if (!url.includes('tests/.tmp/')) {
|
|
throw new Error(
|
|
`Refusing to run: DATABASE_URL must point at tests/.tmp/ (got "${url}"). Use 'npm run test:integration'.`,
|
|
);
|
|
}
|
|
const dir = path.resolve(process.cwd(), 'tests/.tmp');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.rmSync(path.resolve(dir, 'acl-int.db'), { force: true });
|
|
execSync('npx prisma db push --skip-generate --accept-data-loss', {
|
|
stdio: 'pipe',
|
|
env: process.env,
|
|
});
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// FK-safe truncation order.
|
|
await prisma.signingLog.deleteMany();
|
|
await prisma.signingCondition.deleteMany();
|
|
await prisma.request.deleteMany();
|
|
await prisma.token.deleteMany();
|
|
await prisma.policyRule.deleteMany();
|
|
await prisma.policy.deleteMany();
|
|
await prisma.keyUser.deleteMany();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
// ---- cases -----------------------------------------------------------------
|
|
|
|
test('live token + matching policy rule -> sign_event allowed', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
|
|
});
|
|
|
|
// A token bound to the KeyUser that has lapsed (expiry or token-revoke) means
|
|
// the pairing WAS granted and is now spent. It must hard-reject with `false` so
|
|
// an unattended client re-pairs immediately, NOT `undefined` (which routes to
|
|
// the admin-prompt path and hangs an ATM until a BunkerTimeoutError). The smoke
|
|
// on the Sintra proved the divergence: revoke -> clean reject, expiry -> hang.
|
|
// See aiolabs/nsecbunkerd#36 (and #24, which made the expired token stop
|
|
// granting in the first place).
|
|
test('expired token -> sign_event hard-rejected (false) [#24 + #36]', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, { expiresAt: past() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
|
|
});
|
|
|
|
test('token-revoked -> sign_event hard-rejected (false) [#36]', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, { revokedAt: past() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
|
|
});
|
|
|
|
test('live token -> connect allowed (pairing)', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'connect'), true);
|
|
});
|
|
|
|
test('expired token -> connect hard-rejected (false) [#36]', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, { expiresAt: past() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'connect'), false);
|
|
});
|
|
|
|
// The reject above is reserved for a binding that LAPSED. A request whose method
|
|
// was never in the (still-live) token's policy is genuinely new permission and
|
|
// must stay `undefined` so an admin could approve it out-of-band — it must NOT
|
|
// be swept up by the #36 re-pair signal.
|
|
test('live token, method outside its policy -> undefined (never granted, not lapsed) [#36]', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'nip44_encrypt'), undefined);
|
|
});
|
|
|
|
// And a lapsed token only rejects the method IT covered: a different method has
|
|
// no lapsed grant of its own, so it stays a never-granted `undefined`.
|
|
test('expired token, request a method it never covered -> undefined [#36]', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, { expiresAt: past() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'nip44_encrypt'), undefined);
|
|
});
|
|
|
|
test('KeyUser.revokedAt denies (false) and beats a live token', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser({ revokedAt: past() });
|
|
await seedToken(ku, pid, {});
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
|
|
});
|
|
|
|
test('live SigningCondition grant -> allowed', async () => {
|
|
const ku = await seedKeyUser();
|
|
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
|
|
});
|
|
|
|
test('expired SigningCondition grant -> ignored (falls through to undefined)', async () => {
|
|
const ku = await seedKeyUser();
|
|
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, expiresAt: past() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
|
|
});
|
|
|
|
test('revoked SigningCondition grant -> ignored (falls through to undefined)', async () => {
|
|
const ku = await seedKeyUser();
|
|
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, revokedAt: past() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
|
|
});
|
|
|
|
test('live SigningCondition deny beats a live token grant -> false', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: false });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false);
|
|
});
|
|
|
|
test('future-dated token + future-dated grant stay live', async () => {
|
|
const ku = await seedKeyUser();
|
|
await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, expiresAt: future() });
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
|
|
});
|
|
|
|
test('policy rule kind mismatch -> undefined', async () => {
|
|
const pid = await seedPolicy('sign_event', '1');
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', { kind: 2 } as any), undefined);
|
|
});
|
|
|
|
test('no KeyUser -> undefined', async () => {
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
|
|
});
|
|
|
|
// ---- usage caps (#28) ------------------------------------------------------
|
|
|
|
test('usage cap: under the limit -> allowed', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 3, windowSeconds: 3600 }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 2); // 2 < 3
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
|
|
});
|
|
|
|
test('usage cap: at the limit -> denied', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 3, windowSeconds: 3600 }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 3); // 3 >= 3
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
|
|
});
|
|
|
|
test('usage cap: signings outside the window do not count', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: 3600 }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 5, 2 * 3600 * 1000); // 5, aged 2h (outside 1h)
|
|
await seedSigningLog(ku, 'sign_event', '1', 1); // 1 recent
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // only 1 in window
|
|
});
|
|
|
|
test('uncapped rule -> allowed regardless of log volume', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: null }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 100);
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
|
|
});
|
|
|
|
test('lifetime cap (windowSeconds null) counts all-time', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: null }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 2, 10 * 24 * 3600 * 1000); // 2, aged 10 days
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); // lifetime: 2 >= 2
|
|
});
|
|
|
|
test('kind-specific cap counts only that kind', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: 3600 }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '2', 5); // kind 2 — irrelevant to a kind-1 cap
|
|
await seedSigningLog(ku, 'sign_event', '1', 1);
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // kind-1 count = 1 < 2
|
|
});
|
|
|
|
test('stacked caps: the hourly cap binds even when the daily is fine', async () => {
|
|
const pid = await seedPolicyRules([
|
|
{ method: 'sign_event', kind: '1', maxUsageCount: 20, windowSeconds: 3600 },
|
|
{ method: 'sign_event', kind: '1', maxUsageCount: 200, windowSeconds: 86400 },
|
|
]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 20); // hits the 20/hr cap; well under 200/day
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
|
|
});
|
|
|
|
test('stacked caps: the daily cap binds even when the hourly is fine', async () => {
|
|
const pid = await seedPolicyRules([
|
|
{ method: 'sign_event', kind: '1', maxUsageCount: 20, windowSeconds: 3600 },
|
|
{ method: 'sign_event', kind: '1', maxUsageCount: 200, windowSeconds: 86400 },
|
|
]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
await seedSigningLog(ku, 'sign_event', '1', 15); // hourly: 15 < 20 OK
|
|
await seedSigningLog(ku, 'sign_event', '1', 190, 2 * 3600 * 1000); // +190 aged 2h: in day, not hour
|
|
// hourly = 15 (OK), daily = 205 >= 200 -> denied
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
|
|
});
|
|
|
|
test('recordSigning feeds the cap: record then re-check denies at the limit', async () => {
|
|
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 1, windowSeconds: 3600 }]);
|
|
const ku = await seedKeyUser();
|
|
await seedToken(ku, pid, {});
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // 0 < 1
|
|
await recordSigning(KEY, PUB, 'sign_event', signEvt); // now 1 recorded
|
|
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); // 1 >= 1
|
|
});
|