Files
Personal-Website/test/admin.test.js
T
2026-10-25 22:07:13 +01:00

291 lines
12 KiB
JavaScript

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, /<img src="\/media\/pages\/images\/rack-foto\.jpg\?w=1600&[^"]+" srcset=/);
res = await paste('/admin/seiten/bild', 'not an image', 'virus.exe');
assert.equal(res.status, 422);
assert.match((await res.json()).error, /kein Bild/);
assert.equal((await paste('/admin/projekte/gibts-nicht/bild', await image('png'))).status, 404);
assert.equal((await paste('/admin/seiten/bild', await image('png'), 'bild', 'falsch')).status, 403);
});
test('clients are counted by address, IPv6 by /64', () => {
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);
});