import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { AuthUser } from './auth.js'; import { closeDatabase, getDatabase } from './database.js'; import { authenticateTechnical, createTechnicalAccess, idempotentResponse, revokeTechnicalAccess, updateTechnicalAccess } from './technical-access.js'; let directory: string; let actor: AuthUser; beforeEach(() => { directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-api-test-')); process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite'); process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads'); execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env }); execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env }); const now = new Date().toISOString(); getDatabase() .prepare( `INSERT INTO users (id, email, email_normalized, display_name, password_hash, role, password_changed_at_utc, created_at_utc, updated_at_utc) VALUES ('test-user', 'test@example.org', 'test@example.org', 'Test', 'not-used', 'barkeeper', ?, ?, ?)` ) .run(now, now, now); actor = { id: 'test-user', email: 'test@example.org', displayName: 'Test', role: 'barkeeper', lockedAtUtc: null }; }); afterEach(() => { closeDatabase(); delete process.env.SENDEWERK_DB_PATH; delete process.env.SENDEWERK_UPLOAD_DIR; rmSync(directory, { recursive: true, force: true }); }); function request(token: string, key?: string) { return new Request('https://sendewerk.test/api/v1/pieces', { headers: { Authorization: `Bearer ${token}`, ...(key ? { 'Idempotency-Key': key } : {}) } }); } describe('technical API access', () => { it('enforces permissions and revocation without storing the raw token', () => { const created = createTechnicalAccess(actor, 'Leser', 'read'); expect(authenticateTechnical(request(created.token))).toMatchObject({ name: 'Leser', permissions: 'read' }); expect(() => authenticateTechnical(request(created.token), true)).toThrow('API_FORBIDDEN'); expect( getDatabase() .prepare('SELECT token_hash FROM technical_accesses WHERE id = ?') .get(created.id) ).not.toMatchObject({ token_hash: created.token }); updateTechnicalAccess(actor, created.id, 'Leser bearbeitet', 'read_write'); expect(authenticateTechnical(request(created.token), true)).toMatchObject({ name: 'Leser bearbeitet', permissions: 'read_write' }); revokeTechnicalAccess(actor, created.id); expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED'); }); it('replays an identical idempotent write and rejects a changed payload', () => { const created = createTechnicalAccess(actor, 'Schreiber', 'read_write'); const technical = authenticateTechnical(request(created.token), true); let calls = 0; const first = idempotentResponse( technical, request(created.token, 'command-1'), { title: 'A' }, () => ({ status: 201, body: { calls: ++calls } }) ); const replay = idempotentResponse( technical, request(created.token, 'command-1'), { title: 'A' }, () => ({ status: 201, body: { calls: ++calls } }) ); expect(first.duplicate).toBe(false); expect(replay).toMatchObject({ duplicate: true, body: { calls: 1 } }); expect(calls).toBe(1); expect(() => idempotentResponse(technical, request(created.token, 'command-1'), { title: 'B' }, () => ({ status: 201, body: {} })) ).toThrow('API_IDEMPOTENCY_CONFLICT'); }); });