261 lines
10 KiB
JavaScript
261 lines
10 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('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);
|
|
});
|