Files
TREK/server/tests/unit/app-config/parsers.test.ts
T
Maurice 01ae91c939 feat(server): give each installation a key for the TREK services and report its allowance
A new service-identity domain registers the installation with places.liketrek.com on its first Places request, in the background and behind a light scrypt proof of work, keeps the key encrypted in app_settings, renews it from day 60 through a daily job, drops it after three 401s in a row with up to six hours of jitter, and freezes it on a revocation until an admin starts over. TREK_SERVICES_KEY wins and turns registration off; TREK_SERVICES=off sends no key; a mirror set with TREK_PLACES_URL never registers and never sees the key, which goes only to the hosts in TREK_SERVICES_HOSTS over https.

The Places client takes its headers from the identity port, sends a TREK/<version> User-Agent and keeps the instance token. A 401 or 403 does not open its breaker. GET /api/admin/service-identity reports the state, tier and today's allowance, asking the issuer at most every 20 seconds; POST .../regenerate starts a new identity.
2026-10-29 13:34:28 +01:00

254 lines
8.9 KiB
TypeScript

import {
csvList,
csvListFiltered,
decodeBase64Url,
isP256PrivateKey,
isServiceKeyShape,
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);
}
});
});
describe('isServiceKeyShape', () => {
it('accepts tk1.<kid>.<payload>.<signature> with base64url parts, surrounding whitespace included', () => {
expect(isServiceKeyShape('tk1.test1.eyJpIjoiYSJ9.UyQG_zH-wmtY')).toBe(true);
expect(isServiceKeyShape(' tk1.k.p.s\n')).toBe(true);
});
it('refuses another version, a part too many or too few, and characters outside base64url', () => {
for (const bad of ['tk2.k.p.s', 'tk1.k.p', 'tk1.k.p.s.x', 'tk1.k.p+.s', 'tk1..p.s', 'Bearer tk1.k.p.s', '']) {
expect(isServiceKeyShape(bad), bad).toBe(false);
}
});
});