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 { clientKey } 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('images pasted into a text are saved next to it', async () => { const paste = async (url, file, name = 'bild', token = csrf) => { const body = new FormData(); body.set('_csrf', token); body.set('bild', new Blob([file]), name); return request(url, { method: 'POST', body }); }; let res = await paste('/admin/projekte/alpha/bild', await image('png')); assert.equal(res.status, 200); assert.deepEqual(await res.json(), { src: 'bild.png' }); assert.equal((await sharp(path.join(dataDir, 'projects/alpha/bild.png')).metadata()).width, 2400); assert.deepEqual(await (await paste('/admin/projekte/alpha/bild', await image('png'))).json(), { src: 'bild-2.png' }); res = await paste('/admin/seiten/bild', await image('jpeg'), 'Rack Foto.jpg'); assert.deepEqual(await res.json(), { src: 'images/rack-foto.jpg' }); res = await request('/admin/homelab', { method: 'POST', body: form({ visible: 'on', 'body[de]': 'Mein Rack:\n\n![Das Rack](images/rack-foto.jpg)' }), }); assert.equal(res.status, 303); assert.match(publicStore.get().pages.homelab.de.html, / { assert.equal(clientKey('79.221.147.164'), '79.221.147.164'); assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20'); assert.equal(clientKey('2003:f6:8f30:d900::1'), '2003:f6:8f30:d900::/64'); assert.equal(clientKey('2003:00f6:8f30:d900:1234:5678:9abc:def0'), '2003:f6:8f30:d900::/64'); assert.equal(clientKey('2001:db8::1'), '2001:db8:0:0::/64'); assert.equal(clientKey('::1'), '0:0:0:0::/64'); }); test('too many wrong passwords lock the login, per client behind the proxy', async () => { const login = (ip, password) => request('/admin/login', { method: 'POST', headers: { 'x-forwarded-for': ip, 'x-forwarded-proto': 'https' }, body: new URLSearchParams({ password }), }); let res; for (let i = 0; i < 11; i++) res = await login('203.0.113.7', `wrong ${i}`); assert.equal(res.status, 429); assert.equal((await login('203.0.113.7', 'correct horse')).status, 429, 'even with the right password'); res = await login('198.51.100.1', 'correct horse'); assert.equal(res.status, 303, 'other visitors can still log in'); assert.match(res.headers.get('set-cookie'), /Secure/i, 'over https the cookie is secure'); for (let i = 0; i < 10; i++) await login(`2001:db8:1:2::${i + 1}`, 'nope'); assert.equal((await login('2001:db8:1:2::ff', 'nope')).status, 429, 'a new address in the same /64 does not help'); assert.equal((await login('2001:db8:1:3::1', 'nope')).status, 401); });