Files
Personal-Website/test/admin.test.js
T
2026-10-25 20:29:26 +01:00

248 lines
9.6 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 { 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);
});