diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..ffb1b17 --- /dev/null +++ b/e2e/admin.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; + +test('owner can edit content, create a translated project, upload media, and sign out', async ({ page, request }) => { + expect((await request.get('/api/admin/settings')).status()).toBe(401); + await page.goto('/admin'); + await expect(page).toHaveURL(/\/admin\/login$/); + await page.getByLabel('Email', { exact: true }).fill('e2e@example.com'); + await page.getByLabel('Password', { exact: true }).fill('Only-for-isolated-e2e-tests-2026'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Site settings', exact: true })).toBeVisible(); + await page.screenshot({ path: test.info().outputPath('admin-settings.png'), fullPage: true }); + await page.getByRole('button', { name: 'Profile & sections', exact: true }).click(); + await page.getByRole('button', { name: 'Hero hero', exact: true }).click(); + const name = page.getByLabel('Name', { exact: true }); + const oldName = await name.inputValue(); + await name.fill('An editable portfolio'); + await page.getByRole('button', { name: 'Save changes', exact: true }).first().click(); + await expect(page.getByRole('status')).toContainText('Saved'); + expect(await (await request.get('/')).text()).toContain('An editable portfolio'); + await page.getByLabel('Name', { exact: true }).fill(oldName); + await page.getByRole('button', { name: 'Save changes', exact: true }).first().click(); + await expect(page.getByRole('status')).toContainText('Saved'); + + await page.getByRole('button', { name: 'Languages', exact: true }).click(); + await page.getByRole('button', { name: '+ Add language', exact: true }).click(); + await page.getByLabel('Name', { exact: true }).fill('Deutsch'); + await page.getByLabel('Language code').fill('de'); + await page.getByRole('button', { name: 'Save changes', exact: true }).first().click(); + await expect(page.getByRole('status')).toContainText('Saved'); + const german = await request.get('/de'); expect(german.status()).toBe(200); expect(await german.text()).toContain('lang="de"'); + + await page.getByRole('button', { name: 'Projects', exact: true }).click(); + await page.getByRole('button', { name: '+ Add item', exact: true }).click(); + const id = `browser-project-${test.info().project.name}`; + await page.getByLabel('ID (used in the URL)', { exact: true }).fill(id); + await page.getByRole('button', { name: 'Save changes', exact: true }).first().click(); + await expect(page.getByRole('status')).toContainText('Saved'); + expect((await request.get(`/de/projects/${id}`)).status()).toBe(200); + await page.getByLabel('Visible', { exact: true }).uncheck(); + await page.getByRole('button', { name: 'Save changes', exact: true }).first().click(); + await expect(page.getByRole('status')).toContainText('Saved'); + expect((await request.get(`/projects/${id}`)).status()).toBe(404); + page.once('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Delete item', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('Deleted'); + + await page.getByRole('button', { name: 'Media library', exact: true }).click(); + await page.locator('input[type=file]').setInputFiles({ name: 'certificate.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF') }); + await expect(page.getByRole('link', { name: 'certificate.pdf' }).first()).toBeVisible(); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page).toHaveURL(/\/admin\/login$/); + await page.goto('/admin'); await expect(page).toHaveURL(/\/admin\/login$/); +}); diff --git a/e2e/project-case-study.spec.ts b/e2e/project-case-study.spec.ts index fbb1ca5..fcd58c5 100644 --- a/e2e/project-case-study.spec.ts +++ b/e2e/project-case-study.spec.ts @@ -16,6 +16,9 @@ test('a pointer click on the card CTA opens a route-backed drawer and restores t }); const card = page.getByRole('article', { name: 'Qlockify' }); const cta = card.getByRole('link', { name: 'View case study' }); + // Measure the viewport from which the user actually clicks, after any + // automatic scroll needed to bring the link into view. + await cta.scrollIntoViewIfNeeded(); const portfolioScroll = await page.evaluate(() => window.scrollY); await cta.click(); @@ -48,6 +51,9 @@ test('a shared route opens over the portfolio and adjacent navigation replaces h }); test('share fallback, copy feedback, focus containment, and the localized RTL route work', async ({ page, context }) => { + // This test exercises the fallback. Native mobile share sheets are outside + // the DOM and are covered separately through the supported-share branch. + await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: undefined })); await context.grantPermissions(['clipboard-read', 'clipboard-write']); await page.goto('/fa/projects/qlockify', { waitUntil: 'domcontentloaded' }); const dialog = await expectDrawerOpen(page); @@ -68,7 +74,16 @@ test('share fallback, copy feedback, focus containment, and the localized RTL ro expect(await page.evaluate(() => document.activeElement?.closest('dialog') !== null)).toBe(true); }); -test('the gallery serves original media and opens an accessible full-screen viewer', async ({ page }) => { +test('mobile uses native sharing when it is available', async ({ page, isMobile }) => { + test.skip(!isMobile, 'Native sharing is used on compact layouts'); + await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { document.documentElement.dataset.sharedUrl = data.url; } })); + await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' }); + const dialog = await expectDrawerOpen(page); + await dialog.getByRole('button', { name: 'Share this case study' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-shared-url', new RegExp(`${caseStudyPath}$`)); +}); + +test('the gallery serves original media and opens an accessible full-screen viewer', async ({ page, isMobile }) => { await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' }); const drawer = await expectDrawerOpen(page); const stage = drawer.getByRole('button', { name: /Open full-screen image/ }); @@ -79,7 +94,11 @@ test('the gallery serves original media and opens an accessible full-screen view const viewer = page.getByRole('dialog', { name: 'Full-screen project gallery' }); await expect(viewer).toBeVisible(); await expect(viewer.getByText('1 of 8')).toBeVisible(); - await viewer.getByRole('button', { name: 'Next image' }).click(); + if (isMobile) { + await viewer.getByRole('button', { name: /^Show image 2:/ }).click(); + } else { + await viewer.getByRole('button', { name: 'Next image' }).click(); + } await expect(viewer.getByText('2 of 8')).toBeVisible(); await viewer.getByRole('button', { name: 'Close full-screen image' }).click(); await expect(stage).toBeFocused(); diff --git a/playwright.config.ts b/playwright.config.ts index 673f897..2620a29 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,11 +22,10 @@ export default defineConfig({ { name: 'mobile-chromium', use: { ...devices['Pixel 7'], channel: 'chrome' } }, ], webServer: { - command: 'node server.js', - cwd: '.next/standalone', + command: 'node scripts/e2e-server.mjs', env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) }, url: `http://127.0.0.1:${port}`, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120_000, }, }); diff --git a/scripts/e2e-server.mjs b/scripts/e2e-server.mjs new file mode 100644 index 0000000..e03b793 --- /dev/null +++ b/scripts/e2e-server.mjs @@ -0,0 +1,16 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { spawn, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const cwd = path.resolve('.next/standalone'); +const directory = await mkdtemp(path.join(tmpdir(), 'portfolio-e2e-')); +const port = process.env.PLAYWRIGHT_PORT || '43119'; +const env = { ...process.env, DATA_DIR: directory, SITE_URL: `http://127.0.0.1:${port}`, HOSTNAME: '127.0.0.1', PORT: port, OWNER_EMAIL: 'e2e@example.com', OWNER_PASSWORD: 'Only-for-isolated-e2e-tests-2026' }; +for (const command of ['import-legacy', 'create-owner']) { + const result = spawnSync(process.execPath, ['maintenance.mjs', command], { cwd, env, stdio: 'inherit' }); + if (result.status !== 0) { await rm(directory, { recursive: true, force: true }); process.exit(result.status || 1); } +} +const server = spawn(process.execPath, ['server.js'], { cwd, env, stdio: 'inherit' }); +process.on('SIGTERM', () => server.kill()); process.on('SIGINT', () => server.kill()); +server.on('exit', async code => { await rm(directory, { recursive: true, force: true }); process.exit(code || 0); }); diff --git a/scripts/smoke-container.sh b/scripts/smoke-container.sh index fba7935..76a57b3 100644 --- a/scripts/smoke-container.sh +++ b/scripts/smoke-container.sh @@ -3,14 +3,20 @@ set -Eeuo pipefail image="${1:?runtime image is required}" container_name="portfolio-smoke-${GITEA_RUN_ID:-$$}" +volume_name="${container_name}-data" cleanup() { docker rm --force "$container_name" >/dev/null 2>&1 || true + docker volume rm "$volume_name" >/dev/null 2>&1 || true } trap cleanup EXIT cleanup -docker run --detach --name "$container_name" "$image" >/dev/null +docker volume create "$volume_name" >/dev/null +start_container() { + docker run --detach --name "$container_name" --mount "type=volume,source=$volume_name,target=/data" --env SITE_URL=http://127.0.0.1:3000 "$image" >/dev/null +} +start_container for _ in {1..30}; do status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name")" @@ -32,6 +38,11 @@ if [[ "$(docker inspect --format '{{.State.Health.Status}}' "$container_name")" exit 1 fi +docker exec "$container_name" node -e "fetch('http://127.0.0.1:3000/').then(async r=>{if(!r.ok||!(await r.text()).includes('Your Name'))process.exit(1)})" +# Exercise the packaged maintenance command and native SQLite driver in the image. +docker exec "$container_name" node maintenance.mjs import-legacy +docker exec --env OWNER_EMAIL=smoke@example.com --env OWNER_PASSWORD=Isolated-container-smoke-password-2026 "$container_name" node maintenance.mjs create-owner + docker exec -i "$container_name" node <<'NODE' const origin = 'http://127.0.0.1:3000'; @@ -63,3 +74,39 @@ if ((await optimized.arrayBuffer()).byteLength === 0) { throw new Error('Optimized image response was empty.'); } NODE + +# Upload through the real authenticated endpoint, then replace the container. +docker exec -i "$container_name" node --input-type=module <<'NODE' +import { writeFileSync } from 'node:fs'; +const origin = 'http://127.0.0.1:3000'; +const login = await fetch(`${origin}/api/auth/sign-in/email`, { + method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'smoke@example.com', password: 'Isolated-container-smoke-password-2026' }), +}); +if (!login.ok) throw new Error('Container owner login failed'); +const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; '); +const form = new FormData(); +form.set('file', new Blob(['%PDF-1.4\nContainer persistence check\n%%EOF'], { type: 'application/pdf' }), 'check.pdf'); +const response = await fetch(`${origin}/api/admin/media`, { method: 'POST', headers: { Origin: origin, Cookie: cookie }, body: form }); +if (!response.ok) throw new Error(`Upload failed: ${response.status}`); +const uploaded = await response.json(); +writeFileSync('/data/smoke-media-key', uploaded.key); +NODE +docker rm --force "$container_name" >/dev/null +start_container +docker exec -i "$container_name" node --input-type=module <<'NODE' +import { readFileSync } from 'node:fs'; +const origin = 'http://127.0.0.1:3000'; +let ready = false; +for (let attempt = 0; attempt < 30; attempt++) { + try { if ((await fetch(`${origin}/api/health`)).ok) { ready = true; break; } } catch {} + await new Promise(resolve => setTimeout(resolve, 1000)); +} +if (!ready) throw new Error('Replacement container did not start'); +const page = await fetch(`${origin}/projects/interanet-solutions`); +if (!page.ok) throw new Error('Database did not survive container replacement'); +const key = readFileSync('/data/smoke-media-key', 'utf8'); +const media = await fetch(`${origin}/media/${key}`); +if (!media.ok || !(await media.text()).includes('Container persistence check')) throw new Error('Upload did not survive container replacement'); +console.log('Container startup, native SQLite, owner login, uploads, and replacement persistence passed.'); +NODE diff --git a/tests/content.test.ts b/tests/content.test.ts new file mode 100644 index 0000000..8e03e15 --- /dev/null +++ b/tests/content.test.ts @@ -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/); +}); diff --git a/tests/footer-scene.test.mjs b/tests/footer-scene.test.mjs index 4ecd37f..b15cf17 100644 --- a/tests/footer-scene.test.mjs +++ b/tests/footer-scene.test.mjs @@ -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, //); + assert.match(footerSection, //); assert.doesNotMatch(footerSection, /next\/image/); assert.doesNotMatch(footerSection, /footer-background.*\.png/); }); diff --git a/tests/performance-architecture.test.mjs b/tests/performance-architecture.test.mjs index 4689f57..89cec31 100644 --- a/tests/performance-architecture.test.mjs +++ b/tests/performance-architecture.test.mjs @@ -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'), diff --git a/tests/project-case-studies.test.mjs b/tests/project-case-studies.test.mjs index 81c8e72..9eb522e 100644 --- a/tests/project-case-studies.test.mjs +++ b/tests/project-case-studies.test.mjs @@ -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/); }); diff --git a/tests/runtime-server.test.mjs b/tests/runtime-server.test.mjs index d7a9e76..60eb47c 100644 --- a/tests/runtime-server.test.mjs +++ b/tests/runtime-server.test.mjs @@ -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([ diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..4135591 --- /dev/null +++ b/tests/security.test.ts @@ -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('')), /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/); +});