104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
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');
|
|
});
|
|
});
|