mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-13 05:15:21 +02:00
Migrate frontend to TypeScript with Deno-based tooling (#209)
* Migrate frontend to TypeScript with Deno-based tooling (#206) Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC binary) and adds Deno for formatting, linting, type-checking, and tests, keeping the project's no-Node/npm approach intact. Wires all four into CI, the Justfile, and the pre-commit hook. * fix: build TypeScript assets before running PHPUnit in CI The tests job ran bin/console sass:build but never typescript:build, so var/typescript/ didn't exist and any page rendering the importmap (e.g. backoffice/base.html.twig) errored during tests. * test: add regression test for backoffice navbar-toggler dead target Guards against the navbar-toggler button pointing at a collapse target (#navbarSupportedContent) that isn't rendered for the current user — the bug hit on the login page before #210 restructured the nav to always render at least one item (the Releases link) regardless of auth state. * fix: stop hand-enumerating TS files for deno check deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts was duplicated in CI and the Justfile, and silently misses any new controller subdirectory (fmt/lint/test already recurse assets/ via deno.json). Add a top-level exclude for assets/vendor/ (respected by all deno subcommands, unlike the per-task include/exclude blocks) so deno check assets/ can recurse safely instead. * fix: GitHubReleasesService date parsing and falsy release name - Move the release-mapping array_map inside the try block so a malformed published_at (or any other parse failure) degrades to the same empty-list fallback as an HTTP failure, instead of throwing uncaught out of the cache callback. - Stop treating a release literally named "0" as unnamed — the old `?:` fallback is falsy for that string and silently substituted the tag name instead. - Render release dates in UTC explicitly; Twig's date filter otherwise silently converts to the app's default timezone (Europe/Amsterdam), which could show the wrong calendar day for releases near midnight. * docs: add scope-creep-as-a-service rule to CLAUDE.md Per user instruction: when a review turns up a real bug outside the current task's scope, fix it in the same MR (with a regression test) rather than just reporting it, unless it needs a human design call.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { assertEquals, assertMatch } from '@std/assert';
|
||||
|
||||
// The module under test registers `document.addEventListener(...)` calls at
|
||||
// import time and reads `window.location`/`window.crypto`, so a minimal
|
||||
// `document`/`window` stub must exist in the global scope *before* the module
|
||||
// is imported. Deno has no browser DOM, and the actual surface these
|
||||
// functions touch on a form/input is narrow (getAttribute/setAttribute/value/
|
||||
// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill.
|
||||
let cookieJar = '';
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).document = {
|
||||
addEventListener: () => {},
|
||||
get cookie() {
|
||||
return cookieJar;
|
||||
},
|
||||
set cookie(value: string) {
|
||||
cookieJar += (cookieJar ? '; ' : '') + value;
|
||||
},
|
||||
};
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).window = {
|
||||
location: { protocol: 'http:' },
|
||||
crypto: globalThis.crypto,
|
||||
};
|
||||
|
||||
const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } =
|
||||
await import(
|
||||
'./csrf_protection_controller.ts'
|
||||
);
|
||||
|
||||
function fakeField(attributes: Record<string, string> = {}, value = '') {
|
||||
const store = new Map(Object.entries(attributes));
|
||||
return {
|
||||
getAttribute: (name: string) => store.get(name) ?? null,
|
||||
setAttribute: (name: string, val: string) => {
|
||||
store.set(name, val);
|
||||
},
|
||||
value,
|
||||
defaultValue: value,
|
||||
dispatchEvent: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
function fakeForm(field: ReturnType<typeof fakeField> | null): any {
|
||||
return { querySelector: () => field };
|
||||
}
|
||||
|
||||
Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => {
|
||||
cookieJar = '';
|
||||
generateCsrfToken(fakeForm(null));
|
||||
assertEquals(cookieJar, '');
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfToken generates a token and cookie on first use', () => {
|
||||
cookieJar = '';
|
||||
const field = fakeField({}, 'my_field_name');
|
||||
|
||||
generateCsrfToken(fakeForm(field));
|
||||
|
||||
assertEquals(
|
||||
field.getAttribute('data-csrf-protection-cookie-value'),
|
||||
'my_field_name',
|
||||
);
|
||||
assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/);
|
||||
assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/);
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => {
|
||||
cookieJar = '';
|
||||
const token = 'a'.repeat(24);
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'existing_name',
|
||||
}, token);
|
||||
|
||||
generateCsrfToken(fakeForm(field));
|
||||
|
||||
assertEquals(field.value, token);
|
||||
assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`));
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => {
|
||||
assertEquals(generateCsrfHeaders(fakeForm(null)), {});
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => {
|
||||
const token = 'b'.repeat(24);
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'field_name',
|
||||
}, token);
|
||||
|
||||
assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token });
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfHeaders omits the header when the token is too short', () => {
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'field_name',
|
||||
}, 'too-short');
|
||||
|
||||
assertEquals(generateCsrfHeaders(fakeForm(field)), {});
|
||||
});
|
||||
|
||||
Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => {
|
||||
cookieJar = '';
|
||||
const token = 'c'.repeat(24);
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'field_name',
|
||||
}, token);
|
||||
|
||||
removeCsrfToken(fakeForm(field));
|
||||
|
||||
assertMatch(cookieJar, new RegExp(`field_name_${token}=0`));
|
||||
});
|
||||
|
||||
Deno.test('removeCsrfToken does nothing without a csrf field', () => {
|
||||
cookieJar = '';
|
||||
removeCsrfToken(fakeForm(null));
|
||||
assertEquals(cookieJar, '');
|
||||
});
|
||||
Reference in New Issue
Block a user