70 lines
4.6 KiB
TypeScript
70 lines
4.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
import { randomBytes } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import sharp from 'sharp';
|
|
import { initialize } from '../src/server/initialize';
|
|
import { provisionOwner } from '../src/server/owner';
|
|
import { getAuth } from '../src/server/auth';
|
|
import { requireOwner, boundedBody } from '../src/server/http';
|
|
import { storeUpload, deleteUpload } from '../src/server/media';
|
|
import { readContent, saveContent } from '../src/server/content';
|
|
import { backup, restore } from '../src/server/backup';
|
|
import { withContentLock } from '../src/server/write-lock';
|
|
import { openDatabase } from '../src/server/db';
|
|
|
|
test('owner authentication, media validation, backup and restore', async t => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'portfolio-security-'));
|
|
process.env.DATA_DIR = path.join(root, 'data');
|
|
process.env.SITE_URL = 'http://localhost:39991';
|
|
process.env.BETTER_AUTH_SECRET = randomBytes(48).toString('base64url');
|
|
const connection = initialize();
|
|
t.after(() => { connection.sqlite.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const email = 'owner@example.com'; const password = randomBytes(20).toString('base64url');
|
|
await provisionOwner(connection, email, password);
|
|
await assert.rejects(() => provisionOwner(connection, 'other@example.com', password), /already exists/);
|
|
const auth = getAuth();
|
|
const request = (endpoint: string, body: unknown) => new Request(`${process.env.SITE_URL}/api/auth/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json', origin: process.env.SITE_URL!, 'x-forwarded-for': '127.0.0.1' }, body: JSON.stringify(body) });
|
|
const signup = await auth.handler(request('sign-up/email', { email: 'outsider@example.com', password, name: 'Outsider' }));
|
|
assert.ok(signup.status >= 400);
|
|
const response = await auth.handler(request('sign-in/email', { email, password }));
|
|
assert.equal(response.status, 200, await response.clone().text());
|
|
const cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ');
|
|
assert.ok(cookie);
|
|
assert.equal((await requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`, { headers: { cookie } }))).email, email);
|
|
await assert.rejects(() => requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`)), /Sign in/);
|
|
await assert.rejects(() => requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`, { method: 'PUT', headers: { cookie, origin: 'https://evil.example' } })), /origin/);
|
|
await assert.rejects(() => boundedBody(new Request('http://localhost', { method: 'POST', body: 'oversized' }), 2), /too large/);
|
|
await provisionOwner(connection, email, randomBytes(20).toString('base64url'), true);
|
|
await assert.rejects(() => requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`, { headers: { cookie } })), /Sign in/);
|
|
let throttled = false;
|
|
for (let attempt = 0; attempt < 7; attempt++) {
|
|
const failed = await auth.handler(request('sign-in/email', { email, password: 'deliberately-wrong-password' }));
|
|
if (failed.status === 429) throttled = true;
|
|
}
|
|
assert.ok(throttled, 'Repeated login attempts must be rate limited');
|
|
|
|
await assert.rejects(() => storeUpload(connection, 'bad.png', Buffer.from('<script>alert(1)</script>')), /valid/);
|
|
const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: '#557799' } }).png().toBuffer();
|
|
const upload = await storeUpload(connection, '../../picture.png', png);
|
|
assert.equal(upload.originalName, 'picture.png'); assert.equal(upload.mime, 'image/webp');
|
|
const settings = readContent(connection).settings;
|
|
settings.avatar = { src: upload.url, width: upload.width!, height: upload.height! };
|
|
saveContent(connection, 'settings', settings);
|
|
assert.throws(() => deleteUpload(connection, upload.key), /still used/);
|
|
const destination = path.join(root, 'backup');
|
|
await backup(connection, process.env.DATA_DIR!, destination);
|
|
assert.ok(existsSync(path.join(destination, 'manifest.json')));
|
|
await withContentLock(async () => {
|
|
await assert.rejects(() => withContentLock(() => undefined), /in progress/);
|
|
});
|
|
const restoredDir = path.join(root, 'restored');
|
|
restore(destination, restoredDir);
|
|
const restored = openDatabase(restoredDir);
|
|
try { assert.equal(readContent(restored).settings.avatar.src, upload.url); assert.ok(existsSync(path.join(restoredDir, 'uploads', upload.key))); }
|
|
finally { restored.sqlite.close(); }
|
|
assert.throws(() => restore(destination, restoredDir), /new, empty/);
|
|
});
|