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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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 userManagementService = require('../services/userManagementService');
|
||||||
const router = express.Router();
|
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)
|
* Transform user object from snake_case (DB) to camelCase (API)
|
||||||
*/
|
*/
|
||||||
@@ -20,10 +51,10 @@ function transformUser(user) {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isActive: user.is_active,
|
isActive: user.is_active,
|
||||||
lastLogin: user.last_login,
|
lastLogin: toIso(user.last_login),
|
||||||
lastLoginIp: user.last_login_ip,
|
lastLoginIp: user.last_login_ip,
|
||||||
createdAt: user.created_at,
|
createdAt: toIso(user.created_at),
|
||||||
updatedAt: user.updated_at,
|
updatedAt: toIso(user.updated_at),
|
||||||
roleId: user.role_id,
|
roleId: user.role_id,
|
||||||
roleName: user.role_name,
|
roleName: user.role_name,
|
||||||
roleDisplayName: user.role_display_name,
|
roleDisplayName: user.role_display_name,
|
||||||
@@ -52,8 +83,8 @@ function transformInvitation(invitation) {
|
|||||||
return {
|
return {
|
||||||
id: invitation.id,
|
id: invitation.id,
|
||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
expiresAt: invitation.expires_at,
|
expiresAt: toIso(invitation.expires_at),
|
||||||
createdAt: invitation.created_at,
|
createdAt: toIso(invitation.created_at),
|
||||||
roleName: invitation.role_name,
|
roleName: invitation.role_name,
|
||||||
invitedBy: invitation.invited_by
|
invitedBy: invitation.invited_by
|
||||||
};
|
};
|
||||||
@@ -212,4 +243,7 @@ router.post('/:id/reset-password', [
|
|||||||
successResponse(res, { message: 'Password reset email sent', ...result });
|
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 = router;
|
||||||
|
module.exports.__test = { toIso, transformUser, transformInvitation };
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
|
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
|
// Transform snake_case API response to camelCase for frontend
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function transformUser(user: any): AdminUser {
|
function transformUser(user: any): AdminUser {
|
||||||
@@ -9,10 +36,10 @@ function transformUser(user: any): AdminUser {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isActive: user.isActive ?? user.is_active,
|
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,
|
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
|
||||||
createdAt: user.createdAt ?? user.created_at,
|
createdAt: normalizeDateValue(user.createdAt ?? user.created_at),
|
||||||
updatedAt: user.updatedAt ?? user.updated_at,
|
updatedAt: normalizeDateValue(user.updatedAt ?? user.updated_at),
|
||||||
roleId: user.roleId ?? user.role_id,
|
roleId: user.roleId ?? user.role_id,
|
||||||
roleName: user.roleName ?? user.role_name,
|
roleName: user.roleName ?? user.role_name,
|
||||||
roleDisplayName: user.roleDisplayName ?? user.role_display_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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function transformRole(role: any): AdminRole {
|
function transformRole(role: any): AdminRole {
|
||||||
return {
|
return {
|
||||||
@@ -127,7 +166,7 @@ export const userManagementService = {
|
|||||||
*/
|
*/
|
||||||
async getInvitations(): Promise<AdminInvitation[]> {
|
async getInvitations(): Promise<AdminInvitation[]> {
|
||||||
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
|
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> {
|
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
|
||||||
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
|
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