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

58
tests/content.test.ts Normal file
View File

@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { openDatabase, migrateDatabase } from '../src/server/db';
import { seedDatabase } from '../src/server/seed';
import { readContent, saveContent, resolvePublicRoute, deleteContent } from '../src/server/content';
import { importLegacy } from '../src/server/legacy-import';
function setup(t: { after: (fn: () => void) => void }) {
const directory = mkdtempSync(path.join(tmpdir(), 'portfolio-test-'));
const connection = openDatabase(directory);
migrateDatabase(connection); seedDatabase(connection);
t.after(() => { connection.sqlite.close(); rmSync(directory, { recursive: true, force: true }); });
return connection;
}
test('seed is idempotent and content saves are validated and immediately readable', t => {
const connection = setup(t); const original = readContent(connection);
const hero = original.sections.find(section => section.key === 'hero')!;
hero.translations.en.name = 'Test owner';
saveContent(connection, 'sections', hero);
seedDatabase(connection);
assert.equal(resolvePublicRoute(readContent(connection)).portfolio.messages.hero.name, 'Test owner');
assert.throws(() => saveContent(connection, 'sections', { ...hero, translations: { en: { name: '' } } }));
assert.equal(resolvePublicRoute(readContent(connection)).portfolio.messages.hero.name, 'Test owner');
assert.throws(() => importLegacy(connection), /before content has been edited/);
saveContent(connection, 'sections', { ...hero, visible: false });
assert.ok(!JSON.stringify(resolvePublicRoute(readContent(connection)).portfolio).includes('Test owner'));
});
test('runtime projects and arbitrary locales resolve, hidden and missing URLs do not', t => {
const connection = setup(t);
saveContent(connection, 'languages', { code: 'ar', name: 'العربية', direction: 'rtl', enabled: true, messages: { 'projects.viewCaseStudy': 'دراسة الحالة' } });
assert.equal(resolvePublicRoute(readContent(connection), ['ar']).portfolio.direction, 'rtl');
assert.equal(resolvePublicRoute(readContent(connection), ['ar']).portfolio.messages.projects.viewCaseStudy, 'دراسة الحالة');
const project = readContent(connection).entries.find(entry => entry.kind === 'projects')!;
saveContent(connection, 'entries', { ...project, id: 'new-project' });
assert.equal(resolvePublicRoute(readContent(connection), ['ar','projects','new-project']).project?.id, 'new-project');
saveContent(connection, 'entries', { ...project, id: 'new-project', visible: false });
assert.throws(() => resolvePublicRoute(readContent(connection), ['projects','new-project']), /not found/);
deleteContent(connection, 'entries', project.id);
assert.throws(() => resolvePublicRoute(readContent(connection), ['projects',project.id]), /not found/);
for (const parts of [['en'], ['unknown'], ['ar','bad'], ['admin']]) assert.throws(() => resolvePublicRoute(readContent(connection), parts));
assert.throws(() => saveContent(connection, 'languages', { code: 'en', name: 'English', direction: 'ltr', enabled: false, messages: {} }));
});
test('legacy import preserves English and Persian projects and only runs once', t => {
const connection = setup(t); importLegacy(connection);
const snapshot = readContent(connection);
assert.equal(resolvePublicRoute(snapshot, ['projects','qlockify']).project?.title, 'Qlockify');
assert.equal(resolvePublicRoute(snapshot, ['fa']).portfolio.direction, 'rtl');
assert.ok(snapshot.entries.length > 5);
assert.throws(() => importLegacy(connection));
});
test('startup rejects a database migrated by an incompatible application image', t => {
const connection = setup(t);
connection.sqlite.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('future-migration', Date.now() + 100000);
assert.throws(() => migrateDatabase(connection), /do not match/);
});

View File

@@ -2,20 +2,18 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('the isometric footer preserves the real contact destinations and email', async () => {
test('the isometric footer uses editable contact destinations and email', async () => {
const footer = await readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8');
assert.match(footer, /tel:09388263077/);
assert.match(footer, /linkedin\.com\/in\/amiirkhl/);
assert.match(footer, /https:\/\/git\.amiirkhl\.ir/);
assert.match(footer, /https:\/\/t\.me\/amiirkhl/);
assert.match(footer, /mailto:amirhosseinkhalili901@gmail\.com/);
assert.match(footer, /portfolio\.profile\.contacts/);
assert.match(footer, /portfolio\.profile\.email/);
assert.doesNotMatch(footer, /amiirkhl|09388263077/);
});
test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => {
const footerSection = await readFile('src/components/FooterSection.tsx', 'utf8');
assert.match(footerSection, /<IsometricFooter locale=\{locale\}\s*\/>/);
assert.match(footerSection, /<IsometricFooter portfolio=\{portfolio\}\s*\/>/);
assert.doesNotMatch(footerSection, /next\/image/);
assert.doesNotMatch(footerSection, /footer-background.*\.png/);
});

View File

@@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { readFile, access } from 'node:fs/promises';
import test from 'node:test';
test('the production package does not contain runtime data or authentication secrets', async () => {
await assert.rejects(access('.next/standalone/data/auth-secret'));
await assert.rejects(access('.next/standalone/data/portfolio.sqlite'));
await assert.rejects(access('.next/standalone/.env'));
});
test('Next runs as a standalone server with the image optimizer enabled', async () => {
const [config, packageJson] = await Promise.all([
readFile('next.config.mjs', 'utf8'),

View File

@@ -5,11 +5,11 @@ import test from 'node:test';
const routeFiles = [
'src/app/(en)/projects/layout.tsx',
'src/app/(en)/projects/[projectId]/page.tsx',
'src/app/(fa)/fa/projects/layout.tsx',
'src/app/(fa)/fa/projects/[projectId]/page.tsx',
'src/app/(localized)/[locale]/projects/layout.tsx',
'src/app/(localized)/[locale]/projects/[projectId]/page.tsx',
];
test('localized project routes are statically generated and reject unknown slugs', async () => {
test('localized project routes use runtime content and metadata', async () => {
await Promise.all(routeFiles.map((file) => access(file)));
const pages = (
@@ -20,8 +20,8 @@ test('localized project routes are statically generated and reject unknown slugs
)
).join('\n');
assert.match(pages, /generateStaticParams/);
assert.match(pages, /dynamicParams\s*=\s*false/);
assert.doesNotMatch(pages, /generateStaticParams/);
assert.doesNotMatch(pages, /dynamicParams\s*=\s*false/);
assert.match(pages, /await params/);
assert.match(pages, /generateMetadata/);
});

View File

@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { access, cp, readFile } from 'node:fs/promises';
import { spawn, spawnSync } from 'node:child_process';
import { access, cp, readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { once } from 'node:events';
import path from 'node:path';
import test from 'node:test';
@@ -23,6 +25,8 @@ async function waitForServer(process, getLogs) {
}
test('the standalone server renders both locales and serves local images', async (t) => {
const dataDir = await mkdtemp(path.join(tmpdir(), 'portfolio-runtime-'));
const env = { ...process.env, DATA_DIR: dataDir, SITE_URL: origin, HOSTNAME: '127.0.0.1', PORT: String(port) };
await access(path.join(standaloneRoot, 'server.js'));
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
await cp(
@@ -31,15 +35,17 @@ test('the standalone server renders both locales and serves local images', async
{ recursive: true },
);
const imported = spawnSync(process.execPath, ['maintenance.mjs', 'import-legacy'], { cwd: standaloneRoot, env, encoding: 'utf8' });
assert.equal(imported.status, 0, imported.stderr);
const server = spawn(process.execPath, ['server.js'], {
cwd: standaloneRoot,
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLogs = '';
server.stdout.on('data', (chunk) => { serverLogs += chunk; });
server.stderr.on('data', (chunk) => { serverLogs += chunk; });
t.after(() => server.kill());
t.after(async () => { if (server.exitCode === null) { const exited = once(server, 'exit'); server.kill(); await exited; } await rm(dataDir, { recursive: true, force: true }); });
await waitForServer(server, () => serverLogs);
const [englishResponse, persianResponse] = await Promise.all([

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/);
});