test(acl): DB-backed integration tests for checkIfPubkeyAllowed (#29) #33
5 changed files with 232 additions and 2 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ config
|
||||||
.env
|
.env
|
||||||
.turbo
|
.turbo
|
||||||
prisma
|
prisma
|
||||||
|
tests/.tmp
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@
|
||||||
"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",
|
"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: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,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 prisma from '../../../db.js';
|
||||||
import { liveWhere } from './lifecycle.js';
|
import { liveWhere } from './lifecycle.js';
|
||||||
|
|
||||||
|
|
|
||||||
195
tests/acl.integration.test.ts
Normal file
195
tests/acl.integration.test.ts
Normal file
|
|
@ -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<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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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);
|
||||||
|
});
|
||||||
32
tests/register-ts.cjs
Normal file
32
tests/register-ts.cjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue