b6b58d0659
Admin > Users page crashed with "TypeError: e.split is not a function" on native installs (SQLite default). Reported by @blazmaric in #485 with a clean diagnosis: SQLite returns lastLogin / createdAt / updatedAt as integer milliseconds since epoch, while Postgres returns ISO strings via the standard JSON serialiser. The page used parseISO() on the raw value and parseISO trips on numbers. Fix at both layers — defence in depth: - backend/src/routes/adminUsers.js: new toIso() helper applied in transformUser + transformInvitation. Coerces Date / number / numeric-string / null to a single ISO 8601 string contract before the response leaves the API. Protects every consumer (frontend AND external API tokens / n8n) regardless of which DB driver is underneath. - frontend/src/services/userManagement.service.ts: same helper as defence-in-depth for stale backends mid-deploy and any cached pre-fix response shape. Also surfaced an existing transformInvitation gap — invitations endpoints were returning raw response.data.invitations without going through the transformer. 10 unit tests pin the toIso contract: all known driver shapes (Date, number, numeric-string, ISO-string, null/undefined/empty) plus the full transformer paths for transformUser and transformInvitation. Out of scope: same epoch-ms surface may exist on other admin pages that were never tested against SQLite (events list, customers, webhooks, api tokens, activity log). Worth a follow-up audit pass to apply toIso() in every snake_case→camelCase transformer the admin routes use, but the immediate Users-page crash is the only reported one and shipping that fix unblocks @blazmaric.
124 lines
4.4 KiB
JavaScript
124 lines
4.4 KiB
JavaScript
/**
|
|
* Pin the date-field normalisation in adminUsers transformer (#485).
|
|
*
|
|
* The Users page crashed on native/SQLite installs because Postgres
|
|
* returned ISO strings while SQLite returned epoch-millisecond
|
|
* integers, and the frontend `parseISO()` blew up on numbers with
|
|
* "e.split is not a function". The transformer now coerces every
|
|
* shape to an ISO 8601 string before serialising.
|
|
*
|
|
* These tests guard the contract so a future refactor can't quietly
|
|
* regress and re-break the same page on the same DB.
|
|
*/
|
|
|
|
const adminUsersRoute = require('../../src/routes/adminUsers');
|
|
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
|
|
|
|
describe('toIso', () => {
|
|
it('passes null and undefined through unchanged', () => {
|
|
expect(toIso(null)).toBeNull();
|
|
expect(toIso(undefined)).toBeUndefined();
|
|
// Empty string also short-circuits — important so an unset
|
|
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
|
|
expect(toIso('')).toBe('');
|
|
});
|
|
|
|
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
|
|
// 2026-05-14T10:00:00.000Z, in epoch ms.
|
|
const epochMs = 1778752800000;
|
|
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
|
|
});
|
|
|
|
it('coerces a stringified large integer to an ISO 8601 string', () => {
|
|
// Some SQLite drivers stringify large integers because they
|
|
// overflow JS safe-integer in the driver's serialiser. Re-coerce
|
|
// so the frontend doesn't try to parseISO('1778752800000').
|
|
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
|
|
});
|
|
|
|
it('coerces a Date instance via toISOString', () => {
|
|
const d = new Date('2026-01-01T12:34:56.000Z');
|
|
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
|
|
});
|
|
|
|
it('passes an existing ISO string through unchanged', () => {
|
|
const iso = '2026-05-14T10:00:00.000Z';
|
|
expect(toIso(iso)).toBe(iso);
|
|
});
|
|
|
|
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
|
|
// Defensive: anything that isn't a 10+ digit integer string is
|
|
// treated as already-stringified — the date library will surface
|
|
// the failure cleanly if it's malformed, rather than the
|
|
// transformer silently rewriting it.
|
|
expect(toIso('2026-05-14')).toBe('2026-05-14');
|
|
});
|
|
});
|
|
|
|
describe('transformUser', () => {
|
|
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
|
|
const sqliteRow = {
|
|
id: 1,
|
|
username: 'admin',
|
|
email: 'admin@example.com',
|
|
is_active: 1,
|
|
last_login: 1778752800000, // epoch ms
|
|
last_login_ip: '127.0.0.1',
|
|
created_at: 1778751144600, // epoch ms
|
|
updated_at: 1778751242320, // epoch ms
|
|
role_id: 1,
|
|
role_name: 'super_admin',
|
|
role_display_name: 'Super Admin',
|
|
created_by_username: null,
|
|
};
|
|
|
|
const out = transformUser(sqliteRow);
|
|
|
|
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
|
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
// Other fields untouched.
|
|
expect(out.username).toBe('admin');
|
|
expect(out.lastLoginIp).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('leaves Postgres ISO strings intact', () => {
|
|
const pgRow = {
|
|
id: 2,
|
|
username: 'second',
|
|
email: 'second@example.com',
|
|
is_active: true,
|
|
last_login: '2026-05-14T10:00:00.000Z',
|
|
created_at: '2026-05-13T08:00:00.000Z',
|
|
updated_at: '2026-05-14T09:00:00.000Z',
|
|
};
|
|
const out = transformUser(pgRow);
|
|
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
|
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
|
|
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
|
|
});
|
|
|
|
it('keeps last_login null when the user has never logged in', () => {
|
|
const out = transformUser({
|
|
id: 3, username: 'fresh', email: 'fresh@example.com',
|
|
is_active: 1, last_login: null,
|
|
});
|
|
expect(out.lastLogin).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('transformInvitation', () => {
|
|
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
|
|
const out = transformInvitation({
|
|
id: 9,
|
|
email: 'invitee@example.com',
|
|
expires_at: 1779357600000,
|
|
created_at: 1778752800000,
|
|
role_name: 'admin',
|
|
invited_by: 'admin',
|
|
});
|
|
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
|
|
});
|
|
});
|