mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(admin): tidy CSV export — column order + escaping + ISO-with-offset
The legacy export emitted snake_case English headers and raw UTC timestamps which Excel/LibreOffice misread once the `Z` suffix was stripped (issue #167 territory) and forced admins to mentally map `actor_username` → "Marc" every time they triaged a row. The export now mirrors the on-screen column order — `timestamp → user → IP → location → provider → outcome` — with `action` + `details` appended so the full audit payload still rides along. Headers are translated through the active locale; timestamps go through `formatInUserTz(..., "iso-with-offset")` so they round-trip correctly in any spreadsheet; `toCSV` now treats `\r` as an escape trigger alongside `\n,"` so Windows-newline detail strings stay on their row. `buildAuditLogCsvRecords` and `auditLogCsvHeaderLabels` live in `_shared.tsx` so the column-order/escape contract is pinned by a pure unit test independent of the React render.
This commit is contained in:
1 parent
feeff38ba2
commit
d87a6314e6
6 files changed
+424
-18
No files matched your search
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
type AuditCsvEntry,
|
||||
type AuditCsvLabels,
|
||||
} from "../_shared";
|
||||
import { toCSV } from "@/lib/export";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Login-Übersicht CSV export contract.
|
||||
*
|
||||
* The previous CSV emitted snake_case English headers and the raw UTC
|
||||
* `createdAt` string, which Excel/LibreOffice misread once the `Z`
|
||||
* suffix was stripped. This test pins:
|
||||
*
|
||||
* - The column order Marc requested:
|
||||
* `timestamp → user → IP → location → provider → outcome` (email
|
||||
* is absent from the audit-log API so it's intentionally skipped)
|
||||
* followed by `action` + `details` for triage completeness.
|
||||
* - The header row uses the translated labels from the active
|
||||
* locale, not the structural keys.
|
||||
* - The timestamp formatter is honoured (production passes
|
||||
* `formatInUserTz` with `iso-with-offset`).
|
||||
* - CSV special characters in `details` are RFC 4180-escaped.
|
||||
*/
|
||||
|
||||
const LABELS_EN: AuditCsvLabels = {
|
||||
timestamp: "Time",
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
details: "Details",
|
||||
outcomeFailed: "Failed",
|
||||
outcomeSuccess: "Success",
|
||||
unknownUser: "Unknown",
|
||||
providerLabels: {
|
||||
password: "Password",
|
||||
passkey: "Passkey",
|
||||
api_token: "API token",
|
||||
withings: "Withings",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
};
|
||||
|
||||
const LABELS_DE: AuditCsvLabels = {
|
||||
...LABELS_EN,
|
||||
timestamp: "Zeitpunkt",
|
||||
user: "Benutzer",
|
||||
ip: "IP",
|
||||
location: "Standort",
|
||||
provider: "Anbieter",
|
||||
outcome: "Ergebnis",
|
||||
action: "Aktion",
|
||||
details: "Details",
|
||||
outcomeFailed: "Fehlgeschlagen",
|
||||
outcomeSuccess: "Erfolgreich",
|
||||
unknownUser: "Unbekannt",
|
||||
providerLabels: {
|
||||
password: "Passwort",
|
||||
passkey: "Passkey",
|
||||
api_token: "API-Token",
|
||||
withings: "Withings",
|
||||
unknown: "Unbekannt",
|
||||
},
|
||||
};
|
||||
|
||||
function deterministicFormatter(iso: string): string {
|
||||
// Strip milliseconds, swap `Z` for `+00:00` so the test asserts the
|
||||
// ISO-with-offset shape without being coupled to a specific zone.
|
||||
return iso.replace(/\.\d{3}Z$/, "+00:00").replace(/Z$/, "+00:00");
|
||||
}
|
||||
|
||||
describe("buildAuditLogCsvRecords", () => {
|
||||
it("emits Marc's column order: timestamp → user → ip → location → provider → outcome (+ action, details)", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: "203.0.113.7",
|
||||
location: "Berlin, DE",
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
];
|
||||
const [record] = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
// Object.keys preserves insertion order in V8 / every modern JS engine
|
||||
// — the column order is therefore guaranteed by `Object.keys(record)`.
|
||||
expect(Object.keys(record)).toEqual([
|
||||
"timestamp",
|
||||
"user",
|
||||
"ip",
|
||||
"location",
|
||||
"provider",
|
||||
"outcome",
|
||||
"action",
|
||||
"details",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps the action to a coarse provider label using the i18n labels", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:06:00.000Z",
|
||||
action: "auth.bearer.success",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: { id: "u2", username: "ios" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:07:00.000Z",
|
||||
action: "auth.login.failed",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(records[0].provider).toBe("Passkey");
|
||||
expect(records[1].provider).toBe("API token");
|
||||
expect(records[2].provider).toBe("Password");
|
||||
// Failed login records the failed outcome.
|
||||
expect(records[0].outcome).toBe("Success");
|
||||
expect(records[2].outcome).toBe("Failed");
|
||||
// No user → unknown placeholder.
|
||||
expect(records[2].user).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("uses the German label set when the active locale is German", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const [record] = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_DE,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(record.provider).toBe("Passkey");
|
||||
expect(record.outcome).toBe("Erfolgreich");
|
||||
expect(record.user).toBe("Unbekannt");
|
||||
});
|
||||
|
||||
it("applies the injected timestamp formatter (production uses formatInUserTz)", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
(iso) => `formatted:${iso}`,
|
||||
);
|
||||
expect(records[0].timestamp).toBe("formatted:2026-05-11T09:05:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auditLogCsvHeaderLabels", () => {
|
||||
it("returns translated headers keyed by the record-key contract", () => {
|
||||
const headers = auditLogCsvHeaderLabels(LABELS_EN);
|
||||
expect(headers).toEqual({
|
||||
timestamp: "Time",
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
details: "Details",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCSV(records, headerLabels) — audit-log integration", () => {
|
||||
it("writes a translated header row + correct column order", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: "203.0.113.7",
|
||||
location: "Berlin, DE",
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_DE,
|
||||
deterministicFormatter,
|
||||
);
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
|
||||
const lines = csv.split("\n");
|
||||
expect(lines[0]).toBe(
|
||||
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
|
||||
);
|
||||
expect(lines[1]).toBe(
|
||||
"2026-05-11T09:05:00+00:00,marc,203.0.113.7,\"Berlin, DE\",Passkey,Erfolgreich,auth.login.passkey,",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes commas, quotes, and newlines in the details column", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.failed",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
// Three RFC 4180 escape triggers in one value: a comma, a
|
||||
// double quote, and a newline.
|
||||
details: 'reason="invalid_password", attempts: 3\nfollowup: lock',
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_EN));
|
||||
// The cell is wrapped in double quotes and embedded `"` is doubled.
|
||||
expect(csv).toContain(
|
||||
'"reason=""invalid_password"", attempts: 3\nfollowup: lock"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -380,6 +380,99 @@ export function useAuthProviderLabels(): Record<AuthProvider, string> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Build the CSV record set for the audit-log export.
|
||||
*
|
||||
* Pulled out of the section component so the column order, header
|
||||
* mapping, and provider/outcome derivation are reachable from a
|
||||
* pure unit test without rendering the React tree. Marc's spec pins
|
||||
* the column order at `timestamp → user → IP → location → provider
|
||||
* → outcome` (email is absent from the audit-log API and would
|
||||
* require schema/API changes that are out of scope). `action` and
|
||||
* `details` follow so the export keeps the full triage payload.
|
||||
*
|
||||
* `formatTimestamp` is injected (not imported) so the production
|
||||
* callsite passes the user-tz formatter (`formatInUserTz`) while the
|
||||
* unit test passes a deterministic stub.
|
||||
*/
|
||||
export interface AuditCsvEntry {
|
||||
createdAt: string;
|
||||
action: string;
|
||||
ipAddress: string | null;
|
||||
location: string | null;
|
||||
details: string | null;
|
||||
user: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export interface AuditCsvLabels {
|
||||
timestamp: string;
|
||||
user: string;
|
||||
ip: string;
|
||||
location: string;
|
||||
provider: string;
|
||||
outcome: string;
|
||||
action: string;
|
||||
details: string;
|
||||
outcomeFailed: string;
|
||||
outcomeSuccess: string;
|
||||
unknownUser: string;
|
||||
providerLabels: Record<AuthProvider, string>;
|
||||
}
|
||||
|
||||
export interface AuditCsvRecord {
|
||||
timestamp: string;
|
||||
user: string;
|
||||
ip: string;
|
||||
location: string;
|
||||
provider: string;
|
||||
outcome: string;
|
||||
action: string;
|
||||
details: string;
|
||||
// Index signature so the record satisfies the structural
|
||||
// `ExportableRecord` contract used by `toCSV`. Fixed columns above
|
||||
// still drive the order of the emitted CSV.
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export function buildAuditLogCsvRecords(
|
||||
entries: AuditCsvEntry[],
|
||||
labels: AuditCsvLabels,
|
||||
formatTimestamp: (iso: string) => string,
|
||||
): AuditCsvRecord[] {
|
||||
return entries.map((entry) => {
|
||||
const provider = providerForAction(entry.action);
|
||||
const isFailed =
|
||||
entry.action === "auth.login.failed" ||
|
||||
entry.action === "auth.bearer.failure" ||
|
||||
entry.action === "auth.token.refresh.failed";
|
||||
return {
|
||||
timestamp: formatTimestamp(entry.createdAt),
|
||||
user: entry.user?.username ?? labels.unknownUser,
|
||||
ip: entry.ipAddress ?? "",
|
||||
location: entry.location ?? "",
|
||||
provider: labels.providerLabels[provider],
|
||||
outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess,
|
||||
action: entry.action,
|
||||
details: entry.details ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function auditLogCsvHeaderLabels(
|
||||
labels: AuditCsvLabels,
|
||||
): Record<keyof AuditCsvRecord, string> {
|
||||
return {
|
||||
timestamp: labels.timestamp,
|
||||
user: labels.user,
|
||||
ip: labels.ip,
|
||||
location: labels.location,
|
||||
provider: labels.provider,
|
||||
outcome: labels.outcome,
|
||||
action: labels.action,
|
||||
details: labels.details,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSystemStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toCSV } from "@/lib/export";
|
||||
import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
type AdminAuditEntry,
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
iconForAuthProvider,
|
||||
providerForAction,
|
||||
useAuthActionLabels,
|
||||
@@ -57,6 +61,12 @@ function rangeToSince(range: DateRangePreset): string | undefined {
|
||||
|
||||
export function LoginOverviewSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
// v1.4.25 W7 — every CSV-emitted timestamp uses the admin's display
|
||||
// timezone so the resulting `2026-05-11T11:05:00+02:00` cell stays
|
||||
// legible after Excel/LibreOffice strip the `Z` suffix from the
|
||||
// legacy UTC export.
|
||||
const userTz = user?.timezone ?? DEFAULT_TIMEZONE;
|
||||
// v1.5 phase-4b moved this to a dedicated route
|
||||
// (`/admin/login-overview`), so the user has already opted into the
|
||||
// audit log by visiting the page. Default to expanded; the toggle
|
||||
@@ -144,16 +154,24 @@ export function LoginOverviewSection() {
|
||||
|
||||
function downloadCsv() {
|
||||
if (entries.length === 0) return;
|
||||
const records = entries.map((entry) => ({
|
||||
timestamp: entry.createdAt,
|
||||
actor_id: entry.user?.id ?? "",
|
||||
actor_username: entry.user?.username ?? "",
|
||||
action: entry.action,
|
||||
ip_address: entry.ipAddress ?? "",
|
||||
location: entry.location ?? "",
|
||||
details: entry.details ?? "",
|
||||
}));
|
||||
const csv = toCSV(records);
|
||||
const labels = {
|
||||
timestamp: t("admin.timestamp"),
|
||||
user: t("admin.users"),
|
||||
ip: t("admin.ip"),
|
||||
location: t("admin.location"),
|
||||
provider: t("admin.provider"),
|
||||
outcome: t("admin.outcome"),
|
||||
action: t("admin.action"),
|
||||
details: t("admin.auditDetails"),
|
||||
outcomeFailed: t("admin.outcomeFailed"),
|
||||
outcomeSuccess: t("admin.outcomeSuccess"),
|
||||
unknownUser: t("common.unknown"),
|
||||
providerLabels: AUTH_PROVIDER_LABELS,
|
||||
};
|
||||
const records = buildAuditLogCsvRecords(entries, labels, (iso) =>
|
||||
formatInUserTz(new Date(iso), userTz, "iso-with-offset"),
|
||||
);
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(labels));
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
|
||||
Reference in new issue
Block a user