Files
website/src/lib/server/worldtodo/validate.test.ts

127 lines
4.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { looksLikeBot, normaliseUrl, parseRequest, plain } from './validate';
const add = {
kind: 'add',
title: 'Fix the <b>broken</b> streetlights on Main Street',
description: 'Half the lights have been out since August.\n\n<script>x</script>Please fix.',
scale: 'street',
category: 'safety',
location_name: 'Main Street, Gouda',
country_code: 'nl',
lat: '52.01',
lon: '4.71',
status: 'open',
source_urls: ['https://example.org/news', '', 'https://example.org/news'],
reason: 'I walk here every evening and it is dark.',
display_name: ''
};
describe('worldtodo request validation', () => {
it('accepts a valid add, strips HTML and normalises', () => {
const r = parseRequest(add);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.fields?.title).toBe('Fix the broken streetlights on Main Street');
expect(r.value.fields?.description).not.toContain('<');
expect(r.value.fields?.country_code).toBe('NL');
expect(r.value.fields?.lat).toBe(52.01);
expect(r.value.fields?.source_urls).toEqual(['https://example.org/news']);
expect(r.value.displayName).toBeNull();
});
it('requires sources for add and edit, and a reason of 10+ chars', () => {
const r = parseRequest({ ...add, source_urls: [], reason: 'short' });
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.errors.source_urls).toBeTruthy();
expect(r.errors.reason).toBeTruthy();
});
it('rejects non-http URLs, too many URLs and overlong URLs', () => {
expect(normaliseUrl('javascript:alert(1)')).toBeNull();
expect(normaliseUrl('ftp://example.org/x')).toBeNull();
expect(normaliseUrl(`https://example.org/${'a'.repeat(500)}`)).toBeNull();
expect(normaliseUrl('https://example.org/ok')).toBe('https://example.org/ok');
const many = Array.from({ length: 6 }, (_, i) => `https://e${i}.org/`);
const r = parseRequest({ ...add, source_urls: many });
expect(r.ok).toBe(false);
});
it('needs both coordinates or neither, in range', () => {
expect(parseRequest({ ...add, lon: '' }).ok).toBe(false);
expect(parseRequest({ ...add, lat: '91' }).ok).toBe(false);
expect(parseRequest({ ...add, lat: '', lon: '' }).ok).toBe(true);
});
it('delete needs only a task and a reason', () => {
const r = parseRequest({
kind: 'delete',
task: 'some-task',
reason: 'It was fixed last week.'
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.fields).toBeNull();
expect(parseRequest({ kind: 'delete', reason: 'It was fixed last week.' }).ok).toBe(false);
});
it('caps the display name at 40 characters', () => {
expect(parseRequest({ ...add, display_name: 'x'.repeat(41) }).ok).toBe(false);
const r = parseRequest({ ...add, display_name: ' <i>Anna</i> ' });
expect(r.ok && r.value.displayName).toBe('Anna');
});
it('flags the honeypot and too-fast submissions', () => {
const now = 1_000_000;
expect(looksLikeBot('', now - 10_000, now)).toBe(false);
expect(looksLikeBot('http://spam', now - 10_000, now)).toBe(true);
expect(looksLikeBot('', now - 1000, now)).toBe(true);
expect(looksLikeBot('', 'garbage', now)).toBe(true);
});
it('plain() keeps paragraphs but drops control characters', () => {
expect(plain('a\u0000b\r\n\r\n\r\nc', true)).toBe('a b\n\nc');
expect(plain(' one\ntwo ')).toBe('one two');
});
it('add without the optional keys defaults them (JSON API)', () => {
const { country_code, lat, lon, status, display_name, ...minimal } = add;
void [country_code, lat, lon, status, display_name];
const r = parseRequest(minimal);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.fields).toMatchObject({
country_code: null,
lat: null,
lon: null,
status: 'open'
});
});
it('a partial edit is validated on top of the current task', () => {
const base = {
title: 'Fix the broken streetlights on Main Street',
description: 'Out since August.',
scale: 'street',
category: 'safety',
location_name: 'Main Street, Gouda',
country_code: 'NL',
lat: 52.01,
lon: 4.71,
status: 'open',
source_urls: ['https://example.org/news']
};
const r = parseRequest(
{
kind: 'edit',
task: 'main-street',
status: 'in_progress',
reason: 'Works started on Monday.'
},
base
);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.fields).toMatchObject({ ...base, status: 'in_progress' });
});
});