From 9132e7ade89556afbb9f74bf2d84dd2892e3b1a3 Mon Sep 17 00:00:00 2001 From: AlbertoV Date: Sat, 17 Oct 2026 06:36:26 +0200 Subject: [PATCH 1/2] fix: shed oversized client errors and pause on rejected ingest keys - Validate the endpoint with a pattern instead of URL getters, which throw in React Native without a URL polyfill. - Shrink events above 60 KB in two steps (breadcrumbs, then context and raw stack) before dropping them; context.truncated records what was removed. - Normalise caller frames and beforeSend output to the server contract (at most 100 valid frames). - On 401/403 drop the queue and pause capture for 1 minute, doubling up to 32 minutes; expose getStats().paused and a "paused" diagnostic. - installBrowserErrors returns the existing installation for the same window and throws when no window exists. - Add typesVersions so subpath imports resolve with moduleResolution node. Co-Authored-By: Claude Sonnet 4.5 --- js/ERRORS.md | 22 ++++-- js/package.json | 8 ++ js/src/__tests__/errors.test.ts | 133 ++++++++++++++++++++++++++++++++ js/src/browser.ts | 33 +++++++- js/src/errors/core.ts | 130 +++++++++++++++++++++++++++---- 5 files changed, 300 insertions(+), 26 deletions(-) diff --git a/js/ERRORS.md b/js/ERRORS.md index 1ba7d59..da16308 100644 --- a/js/ERRORS.md +++ b/js/ERRORS.md @@ -26,7 +26,10 @@ try { } ``` -Install once, early in application startup. Global errors and unhandled promise +Install once, early in application startup. A second `installBrowserErrors` call +on the same window returns the existing installation instead of duplicating +listeners; dispose it first to reinstall with different options. Calling it where +no `window` exists throws. Global errors and unhandled promise rejections are captured without suppressing the browser's handlers. Page URLs, user agent and language are included; query strings, fragments and URL credentials are removed. Popstate/hash changes add navigation breadcrumbs. No form values, @@ -117,12 +120,19 @@ value in free text. The hook is synchronous; a thrown exception drops that event `sampleRate` is 0–1, default 1, and sampling happens before queueing. The in-memory queue holds 30 events by default (`maxQueueSize`, maximum 100). -Events exceeding 60 KB or a full queue are dropped. Delivery retries network -failures, `408`, `429` and `5xx` up to five attempts, preserving exact bytes and ID. -It respects `Retry-After` and uses exponential backoff. Events older than five -minutes are discarded; other `4xx` responses are terminal. `getStats()` returns +An event above 60 KB first loses its breadcrumbs, then its context and most of +its raw stack; `context.truncated` records what was removed. Only an exception +that is still too large is dropped. A full queue drops the new event. Frames +passed to `captureException` are validated and limited to 100 entries. Delivery +retries network failures, `408`, `429` and `5xx` up to five attempts, preserving +exact bytes and ID. It respects `Retry-After` and uses exponential backoff. +Events older than five minutes are discarded; other `4xx` responses are terminal. +A `401` or `403` means the key or origin is rejected for every event: queued +events are dropped and capture pauses for one minute, doubling up to 32 minutes +while rejections continue (`getStats().paused`, diagnostic type `paused`). The +pause ends on its own; fix the ingestion key or origin configuration. `getStats()` returns sent, dropped, retried and queued counts, plus `droppedByReason`. An optional -`onDiagnostic` callback reports drops and retries without event payloads. Its +`onDiagnostic` callback reports drops, retries and pauses without event payloads. Its exceptions are ignored and error capture inside that callback is suppressed to avoid recursion. Use a separate health channel to monitor delivery losses. No delivery failure creates another telemetry error. Browser visibility changes request a flush with `keepalive`. Flush deadlines diff --git a/js/package.json b/js/package.json index f91c1e2..2a5fc06 100644 --- a/js/package.json +++ b/js/package.json @@ -70,6 +70,14 @@ "esbuild": "^0.28.1", "brace-expansion": "^5.0.8" }, + "typesVersions": { + "*": { + "browser": ["./dist/browser.d.ts"], + "react-native": ["./dist/react-native.d.ts"], + "nuxt": ["./dist/nuxt.d.ts"], + "react": ["./dist/react.d.ts"] + } + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/js/src/__tests__/errors.test.ts b/js/src/__tests__/errors.test.ts index a11c292..b9e1364 100644 --- a/js/src/__tests__/errors.test.ts +++ b/js/src/__tests__/errors.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ErrorClient, parseStack, sanitize } from "../errors/core"; +import type { ErrorDiagnostic } from "../errors/core"; import { installReactNativeErrors, type GlobalErrorHandler, @@ -335,3 +336,135 @@ it("native fatal errors start delivery before invoking the existing crash handle await reporting.client.flush(); reporting.dispose(); }); + +it("rejects endpoints with credentials, queries or non-HTTP schemes without URL getters", () => { + for (const endpoint of [ + "https://u:p@host/x", + "https://host/x?y=1", + "https://host/x#f", + "ftp://host/x", + "host/x", + ]) + expect(() => new ErrorClient({ ...options, endpoint })).toThrow( + "endpoint", + ); + clients.push( + new ErrorClient({ + ...options, + endpoint: "http://localhost:3009/api/v1/client-errors", + }), + ); +}); + +it("pauses capture after a rejected key and resumes after the backoff", async () => { + const diagnostics: ErrorDiagnostic[] = []; + const fetch = vi.fn().mockResolvedValue(new Response("", { status: 401 })); + const client = new ErrorClient({ + ...options, + fetch, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + clients.push(client); + client.captureException(new Error("one")); + client.captureException(new Error("two")); + await client.flush(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(client.getStats()).toMatchObject({ + paused: true, + queued: 0, + droppedByReason: { rejected: 2 }, + }); + expect(client.captureException(new Error("three"))).toBeUndefined(); + expect(client.getStats().droppedByReason.paused).toBe(1); + expect(diagnostics.find((d) => d.type === "paused")).toMatchObject({ + status: 401, + until: expect.any(Number), + }); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 61000); + fetch.mockResolvedValue(new Response("{}", { status: 201 })); + expect(client.captureException(new Error("four"))).toBeDefined(); + await client.flush(); + expect(client.getStats()).toMatchObject({ sent: 1, paused: false }); +}); + +it("sheds breadcrumbs, then context and raw stack, before dropping an oversize event", async () => { + const { client, fetch } = setup(); + for (let i = 0; i < 50; i++) + client.addBreadcrumb({ category: "log", message: "x".repeat(1000) }); + client.setContext( + Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`k${i}`, "y".repeat(2000)]), + ), + ); + client.captureException(new Error("big")); + await client.flush(); + const first = JSON.parse(fetch.mock.calls[0][1].body); + expect(first.breadcrumbs).toEqual([]); + expect(first.context.truncated).toBe("breadcrumbs"); + expect(first.context.k0).toHaveLength(2000); + + client.clearContext(); + client.setContext( + Object.fromEntries( + Array.from({ length: 30 }, (_, i) => [`k${i}`, "y".repeat(2000)]), + ), + ); + const error = new Error("bigger"); + error.stack = + "Error: bigger\n" + " at f (https://app.test/a.js:1:1)\n".repeat(900); + client.captureException(error); + await client.flush(); + const second = JSON.parse(fetch.mock.calls[1][1].body); + expect(second.context).toEqual({ + session_id: expect.any(String), + truncated: "context", + }); + expect(second.exception.stack.length).toBeLessThanOrEqual(8000); + expect(second.exception.frames).toHaveLength(100); + expect(client.getStats().dropped).toBe(0); +}); + +it("normalizes caller-supplied frames to the server contract", async () => { + const { client, fetch } = setup(); + const frames = Array.from({ length: 120 }, (_, i) => ({ + filename: `https://app.test/a.js?v=${i}`, + line: i + 1, + column: i, + })); + client.captureException(new Error("frames"), { + frames: [ + { filename: "", line: 1, column: 0 }, + { filename: "x", line: 0, column: 0 }, + ...frames, + ], + }); + await client.flush(); + const body = JSON.parse(fetch.mock.calls[0][1].body); + expect(body.exception.frames).toHaveLength(100); + expect(body.exception.frames[0]).toEqual({ + filename: "https://app.test/a.js", + line: 1, + column: 0, + }); +}); + +it("installs browser reporting once per window", () => { + const target = Object.assign(new EventTarget(), { + location: { href: "https://app.test/" }, + document: Object.assign(new EventTarget(), { + visibilityState: "visible", + }), + }) as unknown as Window; + const first = installBrowserErrors({ ...options, fetch: vi.fn() }, target); + clients.push(first.client); + expect(installBrowserErrors({ ...options, fetch: vi.fn() }, target)).toBe( + first, + ); + first.dispose(); + const second = installBrowserErrors({ ...options, fetch: vi.fn() }, target); + clients.push(second.client); + expect(second).not.toBe(first); + second.dispose(); + expect(() => installBrowserErrors(options)).toThrow("window"); +}); diff --git a/js/src/browser.ts b/js/src/browser.ts index fa5b803..f0d5dd3 100644 --- a/js/src/browser.ts +++ b/js/src/browser.ts @@ -2,10 +2,34 @@ import { ErrorClient, cleanUrl } from "./errors/core"; import type { ErrorClientOptions } from "./errors/core"; export * from "./errors/core"; +export interface BrowserErrorReporting { + client: ErrorClient; + dispose(): void; +} +const installations = new WeakMap(); +/** + * Installs once per window. A second call (hot module replacement, a plugin + * registered twice) returns the existing installation instead of duplicating + * listeners and deliveries. Dispose it first to reinstall with new options. + */ export function installBrowserErrors( options: Omit, - target: Window = window, -) { + target?: Window, +): BrowserErrorReporting { + const resolved = + target ?? (typeof window === "undefined" ? undefined : window); + if (!resolved) + throw new Error( + "installBrowserErrors requires a window; call it from client-only code", + ); + const existing = installations.get(resolved); + if (existing) return existing; + return install(options, resolved); +} +function install( + options: Omit, + target: Window, +): BrowserErrorReporting { const client = new ErrorClient({ ...options, platform: "browser" }); const page = () => { client.setContext({ @@ -66,9 +90,10 @@ export function installBrowserErrors( target.addEventListener("hashchange", onNavigation); target.addEventListener("online", onOnline); target.document.addEventListener("visibilitychange", onHidden); - return { + const reporting: BrowserErrorReporting = { client, dispose() { + if (installations.get(target) === reporting) installations.delete(target); target.removeEventListener("error", onError); target.removeEventListener("unhandledrejection", onRejection); target.removeEventListener("popstate", onNavigation); @@ -78,4 +103,6 @@ export function installBrowserErrors( client.close(); }, }; + installations.set(target, reporting); + return reporting; } diff --git a/js/src/errors/core.ts b/js/src/errors/core.ts index b3d452c..d13edc0 100644 --- a/js/src/errors/core.ts +++ b/js/src/errors/core.ts @@ -43,13 +43,21 @@ export type DropReason = | "expired" | "rejected" | "retry_exhausted" + | "paused" | "closed"; export interface ErrorDiagnostic { - type: "dropped" | "retry"; + type: "dropped" | "retry" | "paused"; reason?: DropReason; status?: number; + /** For `paused`: epoch milliseconds when capture resumes. */ + until?: number; queued: number; } +/** Server body limit is 64 KiB; keep a margin for headers and encoding. */ +const MAX_EVENT_BYTES = 60000; +const MAX_FRAMES = 100; +/** Pause lengths after a rejected key or origin: 1, 2, 4, 8, 16, 32 minutes. */ +const MAX_AUTH_FAILURES = 6; export interface ErrorClientOptions { endpoint: string; ingestKey: string; @@ -155,10 +163,41 @@ export function parseStack(stack: string): ErrorFrame[] { line: Number(match[2]), column: Math.max(0, Number(match[3]) - (hermes ? 0 : 1)), }); - if (frames.length === 100) break; + if (frames.length === MAX_FRAMES) break; } return frames; } +/** Caller-supplied frames are untrusted input: apply the server contract before queueing. */ +export function normalizeFrames(frames: unknown): ErrorFrame[] | undefined { + if (!Array.isArray(frames)) return undefined; + const result: ErrorFrame[] = []; + for (const frame of frames as Partial[]) { + if ( + !frame || + typeof frame.filename !== "string" || + !frame.filename || + !Number.isInteger(frame.line) || + (frame.line as number) < 1 || + !Number.isInteger(frame.column) || + (frame.column as number) < 0 + ) + continue; + result.push({ + filename: cleanUrl(frame.filename).slice(0, 2048), + ...(typeof frame.function === "string" && frame.function + ? { function: frame.function.slice(0, 500) } + : {}), + line: Math.min(frame.line as number, 10000000), + column: Math.min(frame.column as number, 100000000), + }); + if (result.length === MAX_FRAMES) break; + } + return result; +} +/** Conservative UTF-8 bound without relying on TextEncoder in React Native. */ +function utf8Length(value: string): number { + return encodeURIComponent(value).replace(/%[A-F\d]{2}/g, "_").length; +} function uuid(): string { const bytes = new Uint8Array(16); if (globalThis.crypto?.getRandomValues) @@ -188,6 +227,8 @@ export class ErrorClient { private closed = false; private capturing = false; private reporting = false; + private pausedUntil = 0; + private authFailures = 0; private abort?: AbortController; private readonly droppedByReason: Partial> = {}; private readonly send: typeof fetch; @@ -204,13 +245,12 @@ export class ErrorClient { if (!options[key]?.trim() || options[key].length > 200) throw new Error(`Invalid Traceflow ${key}`); } - const endpoint = new URL(options.endpoint); + // Pattern check instead of URL getters: React Native's built-in URL throws + // "not implemented" for several of them unless a polyfill is installed. if ( - !["http:", "https:"].includes(endpoint.protocol) || - endpoint.username || - endpoint.password || - endpoint.search || - endpoint.hash + typeof options.endpoint !== "string" || + options.endpoint.length > 2048 || + !/^https?:\/\/[^\s/?#@]+(?:\/[^\s?#]*)?$/i.test(options.endpoint) ) throw new Error("Invalid Traceflow error endpoint"); if ( @@ -265,6 +305,10 @@ export class ErrorClient { this.capturing = true; try { const now = Date.now(); + if (now < this.pausedUntil) { + this.drop("paused"); + return; + } if (error && typeof error === "object") { const previous = this.seen.get(error); if (previous && now - previous.at < 1000) return previous.id; @@ -309,7 +353,7 @@ export class ErrorClient { ), ).slice(0, 4000), stack, - frames: capture.frames ?? parseStack(stack), + frames: normalizeFrames(capture.frames) ?? parseStack(stack), }, mechanism: capture.mechanism ?? "manual", handled: capture.handled ?? true, @@ -324,14 +368,45 @@ export class ErrorClient { this.drop("filtered"); return; } - const body = JSON.stringify(event); - // Conservative UTF-8 bound without relying on TextEncoder in React Native. - const bytes = encodeURIComponent(body).replace( - /%[A-F\d]{2}/g, - "_", - ).length; - if (bytes > 60000 || this.queue.length >= this.maxQueue) { - this.drop(bytes > 60000 ? "oversize" : "queue_full"); + // beforeSend output is untrusted: re-apply the frame contract. + event = { + ...event, + exception: { + ...event.exception, + frames: normalizeFrames(event.exception.frames) ?? [], + }, + }; + let body = JSON.stringify(event); + if (utf8Length(body) > MAX_EVENT_BYTES && event.breadcrumbs.length) { + // Keep the exception; shed diagnostic context before dropping the event. + event = { + ...event, + breadcrumbs: [], + context: { ...event.context, truncated: "breadcrumbs" }, + }; + body = JSON.stringify(event); + } + if (utf8Length(body) > MAX_EVENT_BYTES) { + const session = event.context.session_id; + event = { + ...event, + context: { + ...(typeof session === "string" ? { session_id: session } : {}), + truncated: "context", + }, + exception: { + ...event.exception, + stack: event.exception.stack.slice(0, 8000), + }, + }; + body = JSON.stringify(event); + } + if (utf8Length(body) > MAX_EVENT_BYTES) { + this.drop("oversize"); + return; + } + if (this.queue.length >= this.maxQueue) { + this.drop("queue_full"); return; } this.queue.push({ body, attempts: 0, next: now, created: now }); @@ -433,6 +508,7 @@ export class ErrorClient { if (response.ok) { this.queue.shift(); this.stats.sent++; + this.authFailures = 0; return; } if ( @@ -442,6 +518,8 @@ export class ErrorClient { ) { this.queue.shift(); this.drop("rejected", response.status); + if (response.status === 401 || response.status === 403) + this.pause(response.status); return; } const header = response.headers.get("Retry-After"); @@ -471,6 +549,23 @@ export class ErrorClient { this.stats.retries++; this.notify({ type: "retry", status, queued: this.queue.length }); } + /** + * A rejected key or origin applies to every event from this client. Stop + * sending for a growing interval instead of repeating the same failure. + */ + private pause(status: number) { + const count = this.queue.length; + this.queue = []; + if (count) this.drop("rejected", status, count); + this.authFailures = Math.min(MAX_AUTH_FAILURES, this.authFailures + 1); + this.pausedUntil = Date.now() + 60000 * 2 ** (this.authFailures - 1); + this.notify({ + type: "paused", + status, + until: this.pausedUntil, + queued: 0, + }); + } private notify(diagnostic: ErrorDiagnostic) { if (this.reporting) return; this.reporting = true; @@ -491,6 +586,7 @@ export class ErrorClient { return { ...this.stats, queued: this.queue.length, + paused: Date.now() < this.pausedUntil, droppedByReason: { ...this.droppedByReason }, }; } From 3bb092889642564860c7f93c08149079541781a7 Mon Sep 17 00:00:00 2001 From: AlbertoV Date: Sat, 17 Oct 2026 06:36:26 +0200 Subject: [PATCH 2/2] fix: validate audit envelopes against the backend contract - An enum cast in auditFields() made diff() throw and failed the model save. Snapshots and canonical() now record the backing value or case name. - Validate description, actor.label, redacted_fields and origin fields, and reject unknown keys in subject, actor, origin and changes, so an invalid envelope fails the write instead of being stuck in the outbox. - Count identifier and text lengths in UTF-16 code units like the backend. Co-Authored-By: Claude Sonnet 4.5 --- php/laravel/README.md | 5 +++ php/laravel/src/Audit/AuditEvent.php | 58 ++++++++++++++++++++++++---- php/laravel/src/Audit/LogsAudit.php | 7 +++- php/laravel/tests/Unit/AuditTest.php | 45 +++++++++++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/php/laravel/README.md b/php/laravel/README.md index 994fee0..716ebc8 100644 --- a/php/laravel/README.md +++ b/php/laravel/README.md @@ -63,6 +63,11 @@ class Quote extends Model } ``` +Enum casts are recorded as their backing value or case name, dates as ISO strings. +Free text (`description`, `actor.label`, `redacted_fields`) and nested objects are +validated against the backend contract when the event is created, so an invalid +envelope fails the write instead of the later publish. + For this model on the default connection, update it inside a transaction: ```php diff --git a/php/laravel/src/Audit/AuditEvent.php b/php/laravel/src/Audit/AuditEvent.php index 6745c83..3be2fb1 100644 --- a/php/laravel/src/Audit/AuditEvent.php +++ b/php/laravel/src/Audit/AuditEvent.php @@ -51,15 +51,33 @@ public static function fromJson(string $json): self self::identifier($payload->{$field}); } } - foreach (['subject', 'actor'] as $field) { + foreach (['subject' => ['type', 'id'], 'actor' => ['type', 'id', 'label']] as $field => $keys) { if (property_exists($payload, $field)) { - if (! is_object($payload->{$field})) { + if (! is_object($payload->{$field}) || array_diff(array_keys(get_object_vars($payload->{$field})), $keys)) { throw new InvalidArgumentException("Invalid audit {$field}"); } self::identifier($payload->{$field}->type ?? null); self::identifier($payload->{$field}->id ?? null); } } + if (property_exists($payload, 'description')) { + self::text($payload->description, 2000, 'description'); + } + if (property_exists($payload->actor ?? new \stdClass, 'label')) { + self::text($payload->actor->label, 300, 'actor.label'); + } + if (property_exists($payload, 'redacted_fields')) { + $fields = $payload->redacted_fields; + if (! is_array($fields) || ! array_is_list($fields) || count($fields) > 200) { + throw new InvalidArgumentException('Audit redacted_fields must be a list of at most 200 field paths'); + } + foreach ($fields as $path) { + if (! is_string($path) || $path === '') { + throw new InvalidArgumentException('Audit redacted_fields must be a list of at most 200 field paths'); + } + self::text($path, 300, 'redacted_fields'); + } + } if (! is_string($payload->occurred_at ?? null) || ! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/', $payload->occurred_at) || strtotime($payload->occurred_at) === false) { throw new InvalidArgumentException('Audit occurred_at must be an ISO timestamp with timezone'); } @@ -68,7 +86,8 @@ public static function fromJson(string $json): self } if (property_exists($payload, 'changes')) { $changes = $payload->changes; - if (! is_object($changes) || ! in_array($changes->mode ?? null, ['delta', 'snapshot'], true) || ! is_object($changes->before ?? null) || ! is_object($changes->after ?? null)) { + if (! is_object($changes) || array_diff(array_keys(get_object_vars($changes)), ['mode', 'before', 'after']) + || ! in_array($changes->mode ?? null, ['delta', 'snapshot'], true) || ! is_object($changes->before ?? null) || ! is_object($changes->after ?? null)) { throw new InvalidArgumentException('Audit changes require object images and delta or snapshot mode'); } if ($payload->action === 'created' && (array) $changes->before !== [] || $payload->action === 'deleted' && (array) $changes->after !== []) { @@ -82,10 +101,15 @@ public static function fromJson(string $json): self throw new InvalidArgumentException('Audit metadata must be an object'); } if (property_exists($payload, 'origin')) { - if (! is_object($payload->origin)) { + if (! is_object($payload->origin) || array_diff(array_keys(get_object_vars($payload->origin)), ['channel', 'client_source', 'release'])) { throw new InvalidArgumentException('Invalid audit origin'); } self::identifier($payload->origin->channel ?? null); + foreach (['client_source', 'release'] as $field) { + if (property_exists($payload->origin, $field)) { + self::identifier($payload->origin->{$field}); + } + } } self::validateNumbers($payload); @@ -97,11 +121,27 @@ public static function fromJson(string $json): self private static function identifier(mixed $value): void { - if (! is_string($value) || trim($value) === '' || strlen($value) > 200) { - throw new InvalidArgumentException('Audit identifiers must be non-empty strings of at most 200 bytes'); + if (! is_string($value) || trim($value) === '' || self::utf16Length($value) > 200) { + throw new InvalidArgumentException('Audit identifiers must be non-empty strings of at most 200 characters'); } } + /** Free text accepted by the backend contract; the limit mirrors its character count. */ + private static function text(mixed $value, int $max, string $field): void + { + if (! is_string($value) || self::utf16Length($value) > $max) { + throw new InvalidArgumentException("Audit {$field} must be a string of at most {$max} characters"); + } + } + + /** The backend counts UTF-16 code units, as JavaScript string length does. */ + private static function utf16Length(string $value): int + { + $encoded = mb_convert_encoding($value, 'UTF-16LE', 'UTF-8'); + + return intdiv(strlen($encoded), 2); + } + private static function validateNumbers(mixed $value): void { if ((is_int($value) || is_float($value) && floor($value) === $value) && abs($value) > 9007199254740991) { @@ -161,7 +201,11 @@ public static function canonical(mixed $value, int $depth = 0): string if ($depth > 32) { throw new InvalidArgumentException('Audit JSON exceeds depth 32'); } - if ($value instanceof Arrayable) { + if ($value instanceof \BackedEnum) { + $value = $value->value; + } elseif ($value instanceof \UnitEnum) { + $value = $value->name; + } elseif ($value instanceof Arrayable) { $value = $value->toArray(); } elseif ($value instanceof JsonSerializable) { $value = $value->jsonSerialize(); diff --git a/php/laravel/src/Audit/LogsAudit.php b/php/laravel/src/Audit/LogsAudit.php index 9c7d418..4c5534c 100644 --- a/php/laravel/src/Audit/LogsAudit.php +++ b/php/laravel/src/Audit/LogsAudit.php @@ -59,7 +59,12 @@ private function auditSnapshot(bool $original = false): array $values = []; foreach ($this->auditFields() as $field) { $value = $original ? $this->getOriginal($field) : $this->getAttribute($field); - if ($value instanceof Arrayable) { + // Enum casts must not fail the save: record the stored value or case name. + if ($value instanceof \BackedEnum) { + $value = $value->value; + } elseif ($value instanceof \UnitEnum) { + $value = $value->name; + } elseif ($value instanceof Arrayable) { $value = $value->toArray(); } elseif ($value instanceof JsonSerializable) { $value = $value->jsonSerialize(); diff --git a/php/laravel/tests/Unit/AuditTest.php b/php/laravel/tests/Unit/AuditTest.php index 4548b79..a6998d9 100644 --- a/php/laravel/tests/Unit/AuditTest.php +++ b/php/laravel/tests/Unit/AuditTest.php @@ -99,4 +99,49 @@ public function test_authentication_and_missing_route_failures_remain_recoverabl } } } + + public function test_envelope_free_text_and_nested_fields_follow_the_backend_contract(): void + { + $valid = ['action' => 'viewed', 'description' => str_repeat('é', 2000), 'actor' => ['type' => 'user', 'id' => '1', 'label' => str_repeat('😀', 150)], 'redacted_fields' => ['guest.phone'], 'origin' => ['channel' => 'api', 'client_source' => 'cb3', 'release' => '1.0']]; + $this->assertSame('viewed', AuditEvent::create('test', $valid)->jsonSerialize()->action); + foreach ([ + ['description' => str_repeat('a', 2001)], + ['description' => 42], + ['actor' => ['type' => 'user', 'id' => '1', 'label' => str_repeat('😀', 151)]], + ['actor' => ['type' => 'user', 'id' => '1', 'email' => 'x']], + ['redacted_fields' => ['']], + ['redacted_fields' => 'guest.phone'], + ['redacted_fields' => array_fill(0, 201, 'f')], + ['origin' => ['channel' => 'api', 'ip' => '10.0.0.1']], + ['origin' => ['channel' => 'api', 'release' => '']], + ['subject' => ['type' => 'x', 'id' => '1', 'name' => 'n']], + ['changes' => ['mode' => 'snapshot', 'before' => [], 'after' => [], 'extra' => 1]], + ] as $invalid) { + try { + AuditEvent::create('test', array_merge(['action' => 'viewed'], $invalid)); + $this->fail('Expected rejection of '.json_encode($invalid)); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } + + public function test_enums_are_recorded_as_their_stored_value_or_case_name(): void + { + $diff = AuditEvent::diff(['status' => AuditTestStatus::Draft, 'kind' => AuditTestKind::Room], ['status' => AuditTestStatus::Sent, 'kind' => AuditTestKind::Room]); + $this->assertSame(['status' => 'draft'], json_decode(json_encode($diff['before']), true)); + $this->assertSame(['status' => 'sent'], json_decode(json_encode($diff['after']), true)); + $this->assertSame('"Room"', AuditEvent::canonical(AuditTestKind::Room)); + } +} + +enum AuditTestStatus: string +{ + case Draft = 'draft'; + case Sent = 'sent'; +} + +enum AuditTestKind +{ + case Room; }