test: cover publishing, authentication and storage persistence
This commit is contained in:
58
tests/content.test.ts
Normal file
58
tests/content.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
69
tests/security.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user