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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user