import { describe, expect, test } from 'bun:test'; import { arrayField, isoOrNull, toNode, toUser } from './normalize'; // Headscale's REST layer is a gRPC gateway marshalling protobuf, and it leaks in three specific ways. // These transforms are where that leak is contained, so they are the file most likely to be quietly wrong // after an upstream release — and they had no tests at all. describe('isoOrNull', () => { test("the protobuf zero timestamp means 'never', not the year 1", () => { // The leak that matters most: unset timestamps arrive as this literal rather than being omitted. // Rendered naively a node's expiry reads as year 1, which looks like an expired node rather than one // that never expires. expect(isoOrNull('0001-01-01T00:00:00Z')).toBeNull(); }); test('pre-1971 is treated as the sentinel too, for builds that emit a different zero', () => { expect(isoOrNull('1970-01-01T00:00:00Z')).toBeNull(); expect(isoOrNull('1960-06-01T00:00:00Z')).toBeNull(); }); test('a real timestamp survives, normalised to ISO', () => { expect(isoOrNull('2026-08-15T10:30:00Z')).toBe('2026-08-15T10:30:00.000Z'); }); test('anything that is not a usable string is null rather than a crash', () => { // EmitUnpopulated means absent messages arrive as null, so these are normal input, not corruption. for (const bad of [null, undefined, '', 'not a date', 42, {}, []]) { expect(isoOrNull(bad)).toBeNull(); } }); }); describe('toNode', () => { test('ids stay STRINGS — never numbers', () => { // Headscale ids are uint64 serialized as JSON strings. Number() breaks silently above 2^53, and the // ids are database-assigned rather than small by contract, so this is a real ceiling and not theory. const node = toNode({ id: '9007199254740993', name: 'a' }); expect(node.id).toBe('9007199254740993'); expect(typeof node.id).toBe('string'); }); test('givenName wins over name, falling back when it is unset', () => { expect(toNode({ id: '1', givenName: 'laptop', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop'); expect(toNode({ id: '1', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop.tail1234.ts.net'); }); test('an exit node is recognised from either default route', () => { expect(toNode({ id: '1', availableRoutes: ['0.0.0.0/0'] }).isExitNode).toBe(true); expect(toNode({ id: '1', availableRoutes: ['::/0'] }).isExitNode).toBe(true); expect(toNode({ id: '1', availableRoutes: ['10.0.0.0/24'] }).isExitNode).toBe(false); expect(toNode({ id: '1' }).isExitNode).toBe(false); }); test('an unknown register method degrades rather than leaking the enum', () => { expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_AUTH_KEY' }).registerMethod).toBe('authkey'); // A method added in a future release must not put REGISTER_METHOD_SOMETHING_NEW in the UI. expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_FUTURE' }).registerMethod).toBe('unknown'); expect(toNode({ id: '1' }).registerMethod).toBe('unknown'); }); test('online is strictly true, so a missing field is offline rather than truthy', () => { expect(toNode({ id: '1', online: true }).online).toBe(true); expect(toNode({ id: '1', online: 'true' }).online).toBe(false); expect(toNode({ id: '1' }).online).toBe(false); }); test('a node with nothing but an id normalises instead of throwing', () => { // EmitUnpopulated guarantees absent repeated fields arrive as [] and absent messages as null, and // there is no way to tell "unset" from "empty" — so every accessor has to tolerate both. const node = toNode({ id: '7' }); expect(node.ipAddresses).toEqual([]); expect(node.tags).toEqual([]); expect(node.user).toBeNull(); expect(node.lastSeen).toBeNull(); }); test('non-string entries are dropped from string arrays rather than rendered', () => { expect(toNode({ id: '1', ipAddresses: ['100.64.0.1', null, 42, '::1'] }).ipAddresses).toEqual([ '100.64.0.1', '::1', ]); }); }); describe('toUser', () => { test('null and undefined pass through as null', () => { expect(toUser(null)).toBeNull(); expect(toUser(undefined)).toBeNull(); }); }); describe('arrayField', () => { test('pulls the named array, keeping only objects', () => { expect(arrayField({ nodes: [{ id: '1' }, null, 'x', { id: '2' }] }, 'nodes')).toEqual([{ id: '1' }, { id: '2' }]); }); test('a missing field or a non-array body is an empty list, not a throw', () => { expect(arrayField({}, 'nodes')).toEqual([]); expect(arrayField(null, 'nodes')).toEqual([]); expect(arrayField({ nodes: 'not-an-array' }, 'nodes')).toEqual([]); }); });