Merge pull request #490 from the-luap/fix/admin-users-sqlite-date-crash

fix(admin-users): normalise date fields to ISO across DB drivers (#485)
This commit is contained in:
Paul Nothaft
2026-05-14 21:02:13 +02:00
committed by GitHub
3 changed files with 206 additions and 10 deletions
@@ -0,0 +1,123 @@
/**
* 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');
});
});
+39 -5
View File
@@ -11,6 +11,37 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const userManagementService = require('../services/userManagementService');
const router = express.Router();
/**
* Coerce any of the shapes a TIMESTAMP column produces across our
* supported drivers into a single ISO 8601 string the frontend (and
* any external API consumer) can safely pass to date-fns / new Date.
*
* Postgres → Date object (becomes ISO via JSON.stringify anyway, but
* pinning the format defends against driver-side surprises).
* SQLite → integer milliseconds since epoch (the surface that crashed
* the admin Users page in #485 — `parseISO(123456789)` blows up
* with "e.split is not a function"). Native installs default to
* SQLite, so this path matters every release.
* Already a string → assume it's a parseable ISO/RFC3339 (Postgres
* driver may stringify under JSON serialization mid-pipeline).
*
* Returns null/undefined unchanged so an unset last_login surfaces as
* "Never" in the UI rather than 1970-01-01T00:00:00Z.
*/
function toIso(value) {
if (value === null || value === undefined || value === '') return value;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// Numeric-as-string ("1778752458666") happens when the SQLite
// driver stringifies large integers — re-coerce so the frontend
// doesn't try to parseISO('1778752458666').
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return value;
}
/**
* Transform user object from snake_case (DB) to camelCase (API)
*/
@@ -20,10 +51,10 @@ function transformUser(user) {
username: user.username,
email: user.email,
isActive: user.is_active,
lastLogin: user.last_login,
lastLogin: toIso(user.last_login),
lastLoginIp: user.last_login_ip,
createdAt: user.created_at,
updatedAt: user.updated_at,
createdAt: toIso(user.created_at),
updatedAt: toIso(user.updated_at),
roleId: user.role_id,
roleName: user.role_name,
roleDisplayName: user.role_display_name,
@@ -52,8 +83,8 @@ function transformInvitation(invitation) {
return {
id: invitation.id,
email: invitation.email,
expiresAt: invitation.expires_at,
createdAt: invitation.created_at,
expiresAt: toIso(invitation.expires_at),
createdAt: toIso(invitation.created_at),
roleName: invitation.role_name,
invitedBy: invitation.invited_by
};
@@ -212,4 +243,7 @@ router.post('/:id/reset-password', [
successResponse(res, { message: 'Password reset email sent', ...result });
}));
// Test surface: expose the date normaliser so the unit test can pin
// the contract without spinning up the full router.
module.exports = router;
module.exports.__test = { toIso, transformUser, transformInvitation };
@@ -1,6 +1,33 @@
import { api } from '../config/api';
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
/**
* Defence-in-depth normalisation for date fields (#485). The backend
* now always returns these as ISO strings via toIso() in adminUsers.js,
* but this wrapper:
*
* - Lets the page survive a stale backend during a mid-deploy window
* (older backend still returns SQLite epoch-ms numbers).
* - Lets the page survive an external API consumer's response cache
* that captured the pre-fix shape.
*
* The original crash was `parseISO(123456789)` in
* AdminUsersPage → `e.split is not a function`. Coercing here means
* the next consumer of the AdminUser type can rely on the field
* being a string regardless of how it landed.
*/
function normalizeDateValue(value: unknown): string | null | undefined {
if (value === null || value === undefined || value === '') return value as null | undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// SQLite via some drivers stringifies large integers — coerce back.
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return undefined;
}
// Transform snake_case API response to camelCase for frontend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformUser(user: any): AdminUser {
@@ -9,10 +36,10 @@ function transformUser(user: any): AdminUser {
username: user.username,
email: user.email,
isActive: user.isActive ?? user.is_active,
lastLogin: user.lastLogin ?? user.last_login,
lastLogin: normalizeDateValue(user.lastLogin ?? user.last_login),
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
createdAt: user.createdAt ?? user.created_at,
updatedAt: user.updatedAt ?? user.updated_at,
createdAt: normalizeDateValue(user.createdAt ?? user.created_at),
updatedAt: normalizeDateValue(user.updatedAt ?? user.updated_at),
roleId: user.roleId ?? user.role_id,
roleName: user.roleName ?? user.role_name,
roleDisplayName: user.roleDisplayName ?? user.role_display_name,
@@ -20,6 +47,18 @@ function transformUser(user: any): AdminUser {
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformInvitation(invitation: any): AdminInvitation {
return {
id: invitation.id,
email: invitation.email,
roleName: invitation.roleName ?? invitation.role_name,
invitedBy: invitation.invitedBy ?? invitation.invited_by,
expiresAt: normalizeDateValue(invitation.expiresAt ?? invitation.expires_at) as string,
createdAt: normalizeDateValue(invitation.createdAt ?? invitation.created_at) as string,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformRole(role: any): AdminRole {
return {
@@ -127,7 +166,7 @@ export const userManagementService = {
*/
async getInvitations(): Promise<AdminInvitation[]> {
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
return response.data.invitations;
return response.data.invitations.map(transformInvitation);
},
/**
@@ -135,7 +174,7 @@ export const userManagementService = {
*/
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
return response.data.invitation;
return transformInvitation(response.data.invitation);
},
/**