mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
import {
|
|
csvList,
|
|
csvListFiltered,
|
|
decodeBase64Url,
|
|
isP256PrivateKey,
|
|
isUncompressedP256Key,
|
|
isVapidSubject,
|
|
nonNegativeIntOr,
|
|
numberOr,
|
|
parseBool,
|
|
parseDurationMs,
|
|
parseLinkLocalAllowList,
|
|
positiveIntOr,
|
|
positiveNumberOr,
|
|
resolveKeepaliveMs,
|
|
resolveSessionTtlMs,
|
|
stripTrailingSlashes,
|
|
} from '../../../src/app-config/parsers';
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
describe('parseBool', () => {
|
|
it('accepts the whole truthy family, any casing, padded', () => {
|
|
for (const v of ['true', 'TRUE', 'True', '1', 'on', 'ON', 'yes', 'Yes', ' true ']) {
|
|
expect(parseBool(v)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('accepts the whole falsy family', () => {
|
|
for (const v of ['false', 'FALSE', '0', 'off', 'OFF', 'no', 'No', ' off ']) {
|
|
expect(parseBool(v)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('returns undefined for unset, blank and out-of-family values (default applies)', () => {
|
|
expect(parseBool(undefined)).toBeUndefined();
|
|
expect(parseBool('')).toBeUndefined();
|
|
expect(parseBool(' ')).toBeUndefined();
|
|
expect(parseBool('maybe')).toBeUndefined();
|
|
expect(parseBool('enabled')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('numberOr', () => {
|
|
it('parses plain numbers', () => {
|
|
expect(numberOr('8080', 3001)).toBe(8080);
|
|
});
|
|
|
|
// Pins the legacy `Number(x) || d` quirk: "0" is treated as unset.
|
|
it('falls back on NaN, empty and "0"', () => {
|
|
expect(numberOr('abc', 3001)).toBe(3001);
|
|
expect(numberOr('', 3001)).toBe(3001);
|
|
expect(numberOr(undefined, 3001)).toBe(3001);
|
|
expect(numberOr('0', 3001)).toBe(3001);
|
|
});
|
|
|
|
it('keeps negative values (legacy quirk: -5 || d === -5)', () => {
|
|
expect(numberOr('-5', 3001)).toBe(-5);
|
|
});
|
|
});
|
|
|
|
describe('positiveNumberOr', () => {
|
|
it('accepts positive numbers including fractions', () => {
|
|
expect(positiveNumberOr('12.5', 500)).toBe(12.5);
|
|
});
|
|
|
|
it('falls back on zero, negatives, NaN and unset', () => {
|
|
expect(positiveNumberOr('0', 500)).toBe(500);
|
|
expect(positiveNumberOr('-3', 500)).toBe(500);
|
|
expect(positiveNumberOr('abc', 500)).toBe(500);
|
|
expect(positiveNumberOr(undefined, 500)).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('positiveIntOr', () => {
|
|
it('parses leading integers the way parseInt does (pins "5x" → 5)', () => {
|
|
expect(positiveIntOr('5', 20)).toBe(5);
|
|
expect(positiveIntOr('5x', 20)).toBe(5);
|
|
});
|
|
|
|
it('falls back on zero, negatives and garbage', () => {
|
|
expect(positiveIntOr('0', 20)).toBe(20);
|
|
expect(positiveIntOr('-1', 20)).toBe(20);
|
|
expect(positiveIntOr('abc', 20)).toBe(20);
|
|
expect(positiveIntOr(undefined, 20)).toBe(20);
|
|
});
|
|
});
|
|
|
|
describe('nonNegativeIntOr', () => {
|
|
it('accepts zero (0 disables retries) and positive integers', () => {
|
|
expect(nonNegativeIntOr('0', 1)).toBe(0);
|
|
expect(nonNegativeIntOr('3', 1)).toBe(3);
|
|
});
|
|
it('falls back on unset, blank, negative, and non-numeric', () => {
|
|
expect(nonNegativeIntOr(undefined, 1)).toBe(1);
|
|
expect(nonNegativeIntOr(' ', 1)).toBe(1);
|
|
expect(nonNegativeIntOr('-1', 1)).toBe(1);
|
|
expect(nonNegativeIntOr('nope', 1)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('csvList / csvListFiltered', () => {
|
|
it('returns null when unset or empty', () => {
|
|
expect(csvList(undefined)).toBeNull();
|
|
expect(csvList('')).toBeNull();
|
|
expect(csvListFiltered(undefined)).toBeNull();
|
|
});
|
|
|
|
it('trims entries', () => {
|
|
expect(csvList(' a.example , b.example ')).toEqual(['a.example', 'b.example']);
|
|
});
|
|
|
|
// The two ALLOWED_ORIGINS call sites differ: websocket keeps empty entries,
|
|
// the CORS middleware drops them. Both behaviors are pinned.
|
|
it('csvList keeps empty entries, csvListFiltered drops them', () => {
|
|
expect(csvList('a,,b')).toEqual(['a', '', 'b']);
|
|
expect(csvListFiltered('a,,b')).toEqual(['a', 'b']);
|
|
});
|
|
});
|
|
|
|
describe('stripTrailingSlashes', () => {
|
|
it('strips all trailing slashes', () => {
|
|
expect(stripTrailingSlashes('https://x.example///')).toBe('https://x.example');
|
|
expect(stripTrailingSlashes('https://x.example')).toBe('https://x.example');
|
|
});
|
|
});
|
|
|
|
describe('parseDurationMs', () => {
|
|
it('parses the ms-style units used by SESSION_DURATION', () => {
|
|
expect(parseDurationMs('1h')).toBe(3_600_000);
|
|
expect(parseDurationMs('7d')).toBe(7 * 86_400_000);
|
|
expect(parseDurationMs('500')).toBe(500); // unitless = ms
|
|
expect(parseDurationMs(' 12H ')).toBe(12 * 3_600_000); // trims + case-insensitive
|
|
expect(parseDurationMs('1.5h')).toBe(1.5 * 3_600_000);
|
|
});
|
|
|
|
it('rejects zero, negatives and garbage', () => {
|
|
expect(parseDurationMs('0h')).toBeNull();
|
|
expect(parseDurationMs('bogus')).toBeNull();
|
|
expect(parseDurationMs('-1d')).toBeNull();
|
|
expect(parseDurationMs('1 fortnight')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveSessionTtlMs', () => {
|
|
it('defaults to 1h and clamps to 24h', () => {
|
|
expect(resolveSessionTtlMs(undefined)).toBe(3_600_000);
|
|
expect(resolveSessionTtlMs('120')).toBe(120_000);
|
|
expect(resolveSessionTtlMs(String(48 * 60 * 60))).toBe(24 * 60 * 60 * 1000);
|
|
expect(resolveSessionTtlMs('0')).toBe(3_600_000);
|
|
expect(resolveSessionTtlMs('abc')).toBe(3_600_000);
|
|
});
|
|
});
|
|
|
|
describe('resolveKeepaliveMs', () => {
|
|
it('defaults to 25s and allows 0 to disable', () => {
|
|
expect(resolveKeepaliveMs(undefined)).toBe(25_000);
|
|
expect(resolveKeepaliveMs('10')).toBe(10_000);
|
|
expect(resolveKeepaliveMs('0')).toBe(0);
|
|
expect(resolveKeepaliveMs('abc')).toBe(25_000);
|
|
});
|
|
});
|
|
|
|
describe('parseLinkLocalAllowList (ALLOW_LINK_LOCAL_IPS)', () => {
|
|
it('takes single link-local addresses, trimmed, and ignores empty entries', () => {
|
|
expect(parseLinkLocalAllowList(' 169.254.1.2 , ,169.254.0.1')).toEqual({
|
|
ips: ['169.254.1.2', '169.254.0.1'],
|
|
invalid: [],
|
|
});
|
|
expect(parseLinkLocalAllowList(undefined)).toEqual({ ips: [], invalid: [] });
|
|
expect(parseLinkLocalAllowList('')).toEqual({ ips: [], invalid: [] });
|
|
});
|
|
|
|
it('never takes the blocks where clouds serve metadata and credentials', () => {
|
|
const r = parseLinkLocalAllowList('169.254.169.254,169.254.170.2,169.254.169.123,169.254.1.2');
|
|
expect(r.ips).toEqual(['169.254.1.2']);
|
|
expect(r.invalid).toEqual(['169.254.169.254', '169.254.170.2', '169.254.169.123']);
|
|
});
|
|
|
|
it('refuses anything that is not one link-local IPv4 in the form a resolver answers with', () => {
|
|
for (const bad of [
|
|
'10.0.0.1',
|
|
'169.254.1',
|
|
'169.254.1.2/32',
|
|
'169.254.01.2',
|
|
'169.254.1.256',
|
|
'fe80::1',
|
|
'host.example',
|
|
]) {
|
|
expect(parseLinkLocalAllowList(bad)).toEqual({ ips: [], invalid: [bad] });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Web Push key material', () => {
|
|
it('decodeBase64Url refuses anything outside the base64url alphabet instead of skipping it', () => {
|
|
expect(decodeBase64Url('AQID')).toEqual(Buffer.from([1, 2, 3]));
|
|
expect(decodeBase64Url(' AQID ')).toEqual(Buffer.from([1, 2, 3]));
|
|
expect(decodeBase64Url('AQI=')).toEqual(Buffer.from([1, 2]));
|
|
expect(decodeBase64Url('-_8')).toEqual(Buffer.from([0xfb, 0xff]));
|
|
for (const value of ['', 'AQ+D', 'AQ/D', 'AQ ID', 'AQ=D', 'AQ===']) {
|
|
expect(decodeBase64Url(value), value).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('isUncompressedP256Key and isP256PrivateKey check the sizes the Web Push tools print', () => {
|
|
const point = Buffer.concat([Buffer.from([0x04]), Buffer.alloc(64, 1)]).toString('base64url');
|
|
expect(isUncompressedP256Key(point)).toBe(true);
|
|
const compressed = Buffer.concat([Buffer.from([0x02]), Buffer.alloc(64, 1)]).toString('base64url');
|
|
expect(isUncompressedP256Key(compressed)).toBe(false);
|
|
expect(isUncompressedP256Key(Buffer.alloc(33, 4).toString('base64url'))).toBe(false);
|
|
expect(isP256PrivateKey(Buffer.alloc(32, 9).toString('base64url'))).toBe(true);
|
|
expect(isP256PrivateKey(Buffer.alloc(31, 9).toString('base64url'))).toBe(false);
|
|
expect(isP256PrivateKey('not base64url!')).toBe(false);
|
|
});
|
|
|
|
it('isVapidSubject accepts a mailto: address or an https: URL and nothing else', () => {
|
|
const accepted = [
|
|
'mailto:ops@example.com',
|
|
'MAILTO:ops@example.com',
|
|
'https://trek.example.com',
|
|
' https://x.test/c ',
|
|
];
|
|
for (const ok of accepted) {
|
|
expect(isVapidSubject(ok), ok).toBe(true);
|
|
}
|
|
const refused = [
|
|
'ops@example.com',
|
|
'mailto:',
|
|
'mailto:no-at-sign',
|
|
'mailto:a b@example.com',
|
|
'http://x.test',
|
|
'not a url',
|
|
];
|
|
for (const bad of refused) {
|
|
expect(isVapidSubject(bad), bad).toBe(false);
|
|
}
|
|
});
|
|
});
|