From 0b9ffe8ca64f0ec49b5717dded60893fbbdc204c Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:03:39 +0200 Subject: [PATCH] test(acl)(#29): DB-backed integration tests for checkIfPubkeyAllowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap flagged in #27 review: the wiring that actually closes #24 (step-4 Token join filtered by liveWhere) was untested — only the pure predicate was. Now covered end-to-end against a throwaway SQLite DB + the real prisma client. Harness (no new dependency; pnpm add is blocked by the nix node_modules hoist pattern): - tests/register-ts.cjs: ts-node (transpile-only) + a CommonJS resolver that maps the app's '.js' ESM-style specifiers to their '.ts' sources. - node:test temp DB via 'prisma db push'; a before() guard refuses to run unless DATABASE_URL points at tests/.tmp/ (never truncates a real DB). - npm run test:integration / test:all. 13 cases incl. the #24 regression guard (expired token -> denied), revoke, connect-off-live-token, override expiry/revoke ignored, deny-beats-grant, kind mismatch, no-KeyUser. Also: acl/index.ts NDK import -> 'import type' (NostrEvent/NIP46Method are type-only) so the ACL module no longer pulls ESM-only NDK at runtime — required for the CommonJS test import, and a correct cleanup besides. Requires the prisma engine env (CI/nix ok; devShell pending #30). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + package.json | 4 +- src/daemon/lib/acl/index.ts | 2 +- tests/acl.integration.test.ts | 195 ++++++++++++++++++++++++++++++++++ tests/register-ts.cjs | 32 ++++++ 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 tests/acl.integration.test.ts create mode 100644 tests/register-ts.cjs diff --git a/.gitignore b/.gitignore index f3a073a..a6bcada 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ config .env .turbo prisma +tests/.tmp diff --git a/package.json b/package.json index f1f8732..07fa987 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "scripts": { "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", - "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/*.test.ts", + "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts", + "test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts", + "test:all": "npm run test && npm run test:integration", "prisma:generate": "npx prisma generate", "prisma:migrate": "npx prisma migrate deploy", "prisma:create": "npx prisma db push --preview-feature", diff --git a/src/daemon/lib/acl/index.ts b/src/daemon/lib/acl/index.ts index 1c7912d..07aab08 100644 --- a/src/daemon/lib/acl/index.ts +++ b/src/daemon/lib/acl/index.ts @@ -1,4 +1,4 @@ -import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk'; +import type { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk'; import prisma from '../../../db.js'; import { liveWhere } from './lifecycle.js'; diff --git a/tests/acl.integration.test.ts b/tests/acl.integration.test.ts new file mode 100644 index 0000000..d81418f --- /dev/null +++ b/tests/acl.integration.test.ts @@ -0,0 +1,195 @@ +// 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 } 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 { + 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 { + 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 { + 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 { + await prisma.signingCondition.create({ + data: { + keyUserId, + method: opts.method, + kind: opts.kind ?? undefined, + allowed: opts.allowed, + expiresAt: opts.expiresAt ?? null, + revokedAt: opts.revokedAt ?? null, + }, + }); +} + +// ---- 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.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); +}); + +test('expired token -> sign_event denied [#24 regression guard]', 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), undefined); +}); + +test('revoked token -> sign_event denied', 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), undefined); +}); + +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 denied', 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'), 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); +}); diff --git a/tests/register-ts.cjs b/tests/register-ts.cjs new file mode 100644 index 0000000..ddd5ca7 --- /dev/null +++ b/tests/register-ts.cjs @@ -0,0 +1,32 @@ +// Test bootstrap: register ts-node (transpile-only) AND teach CommonJS to +// resolve the project's `.js`-extension, ESM-style import specifiers to their +// `.ts` sources. The app is written `import x from './y.js'` but compiled by +// tsup; under a plain CommonJS require there is no `y.js` on disk, only `y.ts`. +// This lets integration tests import the real app modules (acl, db, backend) +// without a build step or a bundler. Used by the `test:integration` script. +const Module = require('module'); + +require('ts-node').register({ transpileOnly: true }); + +const originalResolve = Module._resolveFilename; +Module._resolveFilename = function (request, parent, isMain, options) { + try { + return originalResolve.call(this, request, parent, isMain, options); + } catch (err) { + // Only retry relative `.js` specifiers as `.ts` — never touch package + // imports (e.g. `@prisma/client`) or anything that already resolved. + if ( + (request.startsWith('./') || request.startsWith('../')) && + request.endsWith('.js') + ) { + return originalResolve.call( + this, + request.slice(0, -3) + '.ts', + parent, + isMain, + options, + ); + } + throw err; + } +};