import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { after, before, test } from 'node:test'; import sharp from 'sharp'; import YAML from 'yaml'; import { isPrivateAddress } from '../src/admin/auth.js'; import { createAdminApp } from '../src/app.js'; import { loadConfig } from '../src/config.js'; import { createContentStore } from '../src/content.js'; import { seedData } from '../src/data.js'; import { createFixture } from './fixtures.js'; let contentDir; let dataDir; let cacheDir; let server; let base; let publicStore; let cookie = ''; let csrf = ''; before(async () => { contentDir = await createFixture(); dataDir = await mkdtemp(path.join(os.tmpdir(), 'site-data-')); cacheDir = await mkdtemp(path.join(os.tmpdir(), 'site-cache-')); const config = loadConfig({ production: true, showDrafts: false, contentDir, dataDir, cacheDir, siteUrl: 'https://example.org', adminPassword: 'correct horse', }); await seedData(config); publicStore = createContentStore(config); await publicStore.reload(); const drafts = createContentStore({ ...config, showDrafts: true }); const app = await createAdminApp({ config, content: drafts, stores: [publicStore, drafts] }); server = app.listen(0); await new Promise((resolve) => server.once('listening', resolve)); base = `http://127.0.0.1:${server.address().port}`; }); after(async () => { server.close(); for (const dir of [contentDir, dataDir, cacheDir]) await rm(dir, { recursive: true, force: true }); }); const request = (url, options = {}) => fetch(base + url, { redirect: 'manual', ...options, headers: { cookie, ...options.headers } }); const form = (fields) => new URLSearchParams({ _csrf: csrf, ...fields }); const readYaml = async (rel) => YAML.parse(await readFile(path.join(dataDir, rel), 'utf8')); const image = (format = 'png', options = {}) => sharp({ create: { width: 3000, height: 2000, channels: 3, background: '#ff76a4' } }) [format]() .withMetadata(options) .toBuffer(); test('everything needs a login', async () => { for (const url of ['/admin', '/admin/werdegang', '/de/', '/media/projects/alpha/cover.png']) { const res = await request(url); assert.equal(res.status, 303, url); assert.equal(res.headers.get('location'), '/admin/login'); } assert.equal((await request('/admin/login')).status, 200); }); test('wrong password is rejected, the right one logs in', async () => { let res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: 'nope' }) }); assert.equal(res.status, 401); res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: 'correct horse' }) }); assert.equal(res.status, 303); cookie = res.headers.get('set-cookie').split(';')[0]; assert.match(res.headers.get('set-cookie'), /HttpOnly/i); assert.match(res.headers.get('set-cookie'), /SameSite=Strict/i); const html = await (await request('/admin')).text(); csrf = /name="_csrf" value="([^"]+)"/.exec(html)[1]; assert.match(html, /Alpha/); }); test('posts without the form token are refused', async () => { const res = await request('/admin/werdegang', { method: 'POST', body: new URLSearchParams({ 'entries[0][title][de]': 'x' }) }); assert.equal(res.status, 403); }); test('create a project with images', async () => { const body = new FormData(); body.set('_csrf', csrf); body.set('de[title]', 'Neues Projekt'); body.set('de[summary]', 'Kurz'); body.set('de[body]', 'Text mit **Markdown**'); body.set('tags', 'Web, Test'); body.set('featured', 'on'); body.set('links[0][label][de]', 'Repo'); body.set('links[0][url]', 'https://example.org/repo'); body.set('cover', new Blob([await image('jpeg', { exif: { IFD0: { Copyright: 'secret place' } } })]), 'Foto.JPG'); body.append('images', new Blob([await image('png')]), 'Mein Screenshot.png'); const res = await request('/admin/projekte/neu', { method: 'POST', body }); assert.equal(res.status, 303); assert.equal(res.headers.get('location'), '/admin/projekte/neues-projekt?ok=created'); const meta = await readYaml('projects/neues-projekt/meta.yml'); assert.equal(meta.cover, 'cover.jpg'); assert.deepEqual(meta.gallery, ['mein-screenshot.png']); assert.deepEqual(meta.tags, ['web', 'test']); assert.deepEqual(meta.links, [{ label: 'Repo', url: 'https://example.org/repo' }]); const cover = await sharp(path.join(dataDir, 'projects/neues-projekt/cover.jpg')).metadata(); assert.equal(cover.width, 2400); assert.equal(cover.exif, undefined, 'EXIF data is removed'); const project = publicStore.get().projects.find((p) => p.slug === 'neues-projekt'); assert.ok(project, 'public site sees the new project right away'); assert.equal(project.text.en.fallback, 'de'); }); test('bad input is reported and nothing is written', async () => { const body = new FormData(); body.set('_csrf', csrf); body.set('de[title]', 'Kaputt'); body.set('links[0][url]', 'javascript:alert(1)'); let res = await request('/admin/projekte/neu', { method: 'POST', body }); assert.equal(res.status, 422); assert.match(await res.text(), /kein gültiger Link/); body.set('links[0][url]', ''); body.set('cover', new Blob(['not an image']), 'virus.exe'); res = await request('/admin/projekte/neu', { method: 'POST', body }); assert.equal(res.status, 422); assert.equal(existsSync(path.join(dataDir, 'projects/kaputt')), false); }); test('edit: rename, draft, remove the cover', async () => { const body = new FormData(); body.set('_csrf', csrf); body.set('slug', 'umbenannt'); body.set('de[title]', 'Neues Projekt'); body.set('en[title]', 'New project'); body.set('draft', 'on'); body.set('removeCover', 'on'); body.set('gallery[0][file]', 'mein-screenshot.png'); body.set('gallery[0][caption][de]', 'Bildschirm'); const res = await request('/admin/projekte/neues-projekt', { method: 'POST', body }); assert.equal(res.status, 303); const dir = path.join(dataDir, 'projects/umbenannt'); assert.equal(existsSync(path.join(dataDir, 'projects/neues-projekt')), false); assert.equal(existsSync(path.join(dir, 'cover.jpg')), false); assert.ok(existsSync(path.join(dir, 'en.md'))); const meta = await readYaml('projects/umbenannt/meta.yml'); assert.equal(meta.draft, true); assert.deepEqual(meta.gallery, [{ src: 'mein-screenshot.png', caption: 'Bildschirm' }]); assert.ok(!publicStore.get().projects.some((p) => p.slug === 'umbenannt'), 'drafts stay off the public site'); assert.equal((await request('/de/portfolio/umbenannt')).status, 200, 'but show up in the preview'); }); test('delete a project', async () => { const res = await request('/admin/projekte/umbenannt/loeschen', { method: 'POST', body: form({}) }); assert.equal(res.status, 303); assert.equal(existsSync(path.join(dataDir, 'projects/umbenannt')), false); assert.equal((await request('/admin/projekte/..%2F..%2Fsite')).status, 404); }); test('journey, profile and homelab', async () => { let res = await request('/admin/werdegang', { method: 'POST', body: form({ 'about[de]': 'Hallo, ich bin Test.', 'entries[0][title][de]': 'Studium', 'entries[0][title][en]': 'University', 'entries[0][from]': '2024', 'entries[0][ongoing]': 'on', 'entries[0][type]': 'edu', 'entries[1][title][de]': '', }), }); assert.equal(res.status, 303); assert.deepEqual((await readYaml('journey.yml')).entries, [ { title: { de: 'Studium', en: 'University' }, type: 'edu', from: '2024', ongoing: true }, ]); assert.match(publicStore.get().pages.journey.de.html, /Hallo, ich bin Test/); res = await request('/admin/profil', { method: 'POST', body: form({ name: 'Test', email: 'neu@example.org', 'now[0][de]': 'Tests schreiben', 'skills[0][group][de]': 'Betrieb', 'skills[0][items][de]': 'Linux, Videoschnitt', 'skills[0][items][en]': 'Linux, Video editing', }), }); assert.equal(res.status, 303); const site = await readYaml('site.yml'); assert.equal(site.email, 'neu@example.org'); assert.deepEqual(site.skills, [{ group: 'Betrieb', items: ['Linux', { de: 'Videoschnitt', en: 'Video editing' }] }]); res = await request('/admin/profil', { method: 'POST', body: form({ name: '' }) }); assert.equal(res.status, 422); assert.equal(publicStore.get().homelab, null); res = await request('/admin/homelab', { method: 'POST', body: form({ visible: 'on', 'lead[de]': 'Was läuft', 'services[0][group][de]': 'Web', 'services[0][items][0][name]': 'nginx', 'services[0][items][1][name]': 'Gitea', 'services[0][items][1][url]': 'https://git.example.org', }), }); assert.equal(res.status, 303); assert.deepEqual(publicStore.get().homelab.services, [ { group: 'Web', items: [{ name: 'nginx' }, { name: 'Gitea', url: 'https://git.example.org' }] }, ]); assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft'); }); test('home network check', () => { for (const ip of ['127.0.0.1', '::1', '192.168.178.20', '10.0.0.5', '172.18.0.1', '::ffff:192.168.178.20', 'fd00::1', 'fe80::1']) { assert.ok(isPrivateAddress(ip), ip); } for (const ip of ['79.221.147.164', '172.32.0.1', '2003:f6:8f30:d900::1', '8.8.8.8']) { assert.ok(!isPrivateAddress(ip), ip); } }); test('too many wrong passwords lock the login', async () => { let res; for (let i = 0; i < 11; i++) { res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: `wrong ${i}` }) }); } assert.equal(res.status, 429); });