test: cover publishing, authentication and storage persistence

This commit is contained in:
2026-09-15 21:48:15 +03:30
parent ee9f49e4b7
commit c17b4b57b2
11 changed files with 294 additions and 23 deletions

69
tests/security.test.ts Normal file
View File

@@ -0,0 +1,69 @@
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/);
});