test: cover publishing, authentication and storage persistence
This commit is contained in:
53
e2e/admin.spec.ts
Normal file
53
e2e/admin.spec.ts
Normal file
@@ -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$/);
|
||||||
|
});
|
||||||
@@ -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 card = page.getByRole('article', { name: 'Qlockify' });
|
||||||
const cta = card.getByRole('link', { name: 'View case study' });
|
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);
|
const portfolioScroll = await page.evaluate(() => window.scrollY);
|
||||||
await cta.click();
|
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 }) => {
|
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 context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
await page.goto('/fa/projects/qlockify', { waitUntil: 'domcontentloaded' });
|
await page.goto('/fa/projects/qlockify', { waitUntil: 'domcontentloaded' });
|
||||||
const dialog = await expectDrawerOpen(page);
|
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);
|
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' });
|
await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' });
|
||||||
const drawer = await expectDrawerOpen(page);
|
const drawer = await expectDrawerOpen(page);
|
||||||
const stage = drawer.getByRole('button', { name: /Open full-screen image/ });
|
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' });
|
const viewer = page.getByRole('dialog', { name: 'Full-screen project gallery' });
|
||||||
await expect(viewer).toBeVisible();
|
await expect(viewer).toBeVisible();
|
||||||
await expect(viewer.getByText('1 of 8')).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 expect(viewer.getByText('2 of 8')).toBeVisible();
|
||||||
await viewer.getByRole('button', { name: 'Close full-screen image' }).click();
|
await viewer.getByRole('button', { name: 'Close full-screen image' }).click();
|
||||||
await expect(stage).toBeFocused();
|
await expect(stage).toBeFocused();
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ export default defineConfig({
|
|||||||
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'], channel: 'chrome' } },
|
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'], channel: 'chrome' } },
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'node server.js',
|
command: 'node scripts/e2e-server.mjs',
|
||||||
cwd: '.next/standalone',
|
|
||||||
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
|
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
|
||||||
url: `http://127.0.0.1:${port}`,
|
url: `http://127.0.0.1:${port}`,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
16
scripts/e2e-server.mjs
Normal file
16
scripts/e2e-server.mjs
Normal file
@@ -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); });
|
||||||
@@ -3,14 +3,20 @@ set -Eeuo pipefail
|
|||||||
|
|
||||||
image="${1:?runtime image is required}"
|
image="${1:?runtime image is required}"
|
||||||
container_name="portfolio-smoke-${GITEA_RUN_ID:-$$}"
|
container_name="portfolio-smoke-${GITEA_RUN_ID:-$$}"
|
||||||
|
volume_name="${container_name}-data"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
docker rm --force "$container_name" >/dev/null 2>&1 || true
|
docker rm --force "$container_name" >/dev/null 2>&1 || true
|
||||||
|
docker volume rm "$volume_name" >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
cleanup
|
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
|
for _ in {1..30}; do
|
||||||
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name")"
|
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
|
exit 1
|
||||||
fi
|
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'
|
docker exec -i "$container_name" node <<'NODE'
|
||||||
const origin = 'http://127.0.0.1:3000';
|
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.');
|
throw new Error('Optimized image response was empty.');
|
||||||
}
|
}
|
||||||
NODE
|
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
|
||||||
|
|||||||
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 { readFile } from 'node:fs/promises';
|
||||||
import test from 'node:test';
|
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');
|
const footer = await readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8');
|
||||||
|
|
||||||
assert.match(footer, /tel:09388263077/);
|
assert.match(footer, /portfolio\.profile\.contacts/);
|
||||||
assert.match(footer, /linkedin\.com\/in\/amiirkhl/);
|
assert.match(footer, /portfolio\.profile\.email/);
|
||||||
assert.match(footer, /https:\/\/git\.amiirkhl\.ir/);
|
assert.doesNotMatch(footer, /amiirkhl|09388263077/);
|
||||||
assert.match(footer, /https:\/\/t\.me\/amiirkhl/);
|
|
||||||
assert.match(footer, /mailto:amirhosseinkhalili901@gmail\.com/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => {
|
test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => {
|
||||||
const footerSection = await readFile('src/components/FooterSection.tsx', 'utf8');
|
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, /next\/image/);
|
||||||
assert.doesNotMatch(footerSection, /footer-background.*\.png/);
|
assert.doesNotMatch(footerSection, /footer-background.*\.png/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile, access } from 'node:fs/promises';
|
||||||
import test from 'node:test';
|
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 () => {
|
test('Next runs as a standalone server with the image optimizer enabled', async () => {
|
||||||
const [config, packageJson] = await Promise.all([
|
const [config, packageJson] = await Promise.all([
|
||||||
readFile('next.config.mjs', 'utf8'),
|
readFile('next.config.mjs', 'utf8'),
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import test from 'node:test';
|
|||||||
const routeFiles = [
|
const routeFiles = [
|
||||||
'src/app/(en)/projects/layout.tsx',
|
'src/app/(en)/projects/layout.tsx',
|
||||||
'src/app/(en)/projects/[projectId]/page.tsx',
|
'src/app/(en)/projects/[projectId]/page.tsx',
|
||||||
'src/app/(fa)/fa/projects/layout.tsx',
|
'src/app/(localized)/[locale]/projects/layout.tsx',
|
||||||
'src/app/(fa)/fa/projects/[projectId]/page.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)));
|
await Promise.all(routeFiles.map((file) => access(file)));
|
||||||
|
|
||||||
const pages = (
|
const pages = (
|
||||||
@@ -20,8 +20,8 @@ test('localized project routes are statically generated and reject unknown slugs
|
|||||||
)
|
)
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
assert.match(pages, /generateStaticParams/);
|
assert.doesNotMatch(pages, /generateStaticParams/);
|
||||||
assert.match(pages, /dynamicParams\s*=\s*false/);
|
assert.doesNotMatch(pages, /dynamicParams\s*=\s*false/);
|
||||||
assert.match(pages, /await params/);
|
assert.match(pages, /await params/);
|
||||||
assert.match(pages, /generateMetadata/);
|
assert.match(pages, /generateMetadata/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { access, cp, readFile } from 'node:fs/promises';
|
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 path from 'node:path';
|
||||||
import test from 'node:test';
|
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) => {
|
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 access(path.join(standaloneRoot, 'server.js'));
|
||||||
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
|
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
|
||||||
await cp(
|
await cp(
|
||||||
@@ -31,15 +35,17 @@ test('the standalone server renders both locales and serves local images', async
|
|||||||
{ recursive: true },
|
{ 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'], {
|
const server = spawn(process.execPath, ['server.js'], {
|
||||||
cwd: standaloneRoot,
|
cwd: standaloneRoot,
|
||||||
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
|
env,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
let serverLogs = '';
|
let serverLogs = '';
|
||||||
server.stdout.on('data', (chunk) => { serverLogs += chunk; });
|
server.stdout.on('data', (chunk) => { serverLogs += chunk; });
|
||||||
server.stderr.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);
|
await waitForServer(server, () => serverLogs);
|
||||||
|
|
||||||
const [englishResponse, persianResponse] = await Promise.all([
|
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