diff --git a/src/lib/workouts/__tests__/route-geometry-cipher.test.ts b/src/lib/workouts/__tests__/route-geometry-cipher.test.ts index 9432d0976..ff0338186 100644 --- a/src/lib/workouts/__tests__/route-geometry-cipher.test.ts +++ b/src/lib/workouts/__tests__/route-geometry-cipher.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Buffer } from "node:buffer"; +const addWarning = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning }), +})); + import { _resetCryptoCacheForTests, encryptBytes } from "@/lib/crypto"; import { decryptRouteGeometry, @@ -56,11 +61,16 @@ describe("route geometry cipher", () => { expect(() => decryptRouteGeometry(new Uint8Array(other))).toThrow(); }); - it("throws on a ciphertext that does not open instead of using the legacy column", () => { + it("reads a ciphertext that does not open as no track, with a warning, never the legacy column", () => { + addWarning.mockClear(); const sealed = encryptRouteGeometry(TRACK); sealed[sealed.byteLength - 1] ^= 0xff; - expect(() => + expect( readRouteGeometry({ geometry: TRACK, geometryEncrypted: sealed }), - ).toThrow(); + ).toBeNull(); + expect(addWarning).toHaveBeenCalledTimes(1); + expect(String(addWarning.mock.calls[0][0])).toContain( + "workout route decrypt failed", + ); }); }); diff --git a/src/lib/workouts/route-geometry-cipher.ts b/src/lib/workouts/route-geometry-cipher.ts index 7b52fc99e..80ed5f4fb 100644 --- a/src/lib/workouts/route-geometry-cipher.ts +++ b/src/lib/workouts/route-geometry-cipher.ts @@ -11,8 +11,10 @@ * * Every reader goes through {@link readRouteGeometry}: the ciphertext when a * row has one, the legacy readable column for a row the boot-time backfill has - * not reached yet. A ciphertext that does not open throws (fail-closed), the - * same posture as `readNote`; it is never replaced by the readable column. + * not reached yet. A ciphertext that does not open reads as no track, with a + * warning on the request's event: it is never replaced by the readable column, + * and one damaged track must not fail the workout detail or the insight job + * that reads it. `decryptRouteGeometry` itself still throws. * * The wire shape is unchanged: readers hand back the same GeoJSON object the * JSONB column used to return. @@ -21,6 +23,7 @@ import { Buffer } from "node:buffer"; import { decryptBytes, encryptBytes } from "@/lib/crypto"; import { WORKOUT_ROUTE_GEOMETRY_AAD } from "@/lib/crypto/encrypted-columns"; +import { getEvent } from "@/lib/logging/context"; /** Seal a route's GeoJSON geometry for `WorkoutRoute.geometryEncrypted`. */ export function encryptRouteGeometry( @@ -43,14 +46,24 @@ export function decryptRouteGeometry(sealed: Uint8Array): unknown { /** * The geometry a route row holds: the ciphertext first, the legacy readable - * column only when there is no ciphertext. Null when the row holds neither. + * column only when there is no ciphertext. Null when the row holds neither, + * and null (with a warning) when the ciphertext does not open. */ export function readRouteGeometry(row: { geometry?: unknown; geometryEncrypted?: Uint8Array | null; }): unknown { if (row.geometryEncrypted && row.geometryEncrypted.byteLength > 0) { - return decryptRouteGeometry(row.geometryEncrypted); + try { + return decryptRouteGeometry(row.geometryEncrypted); + } catch (err) { + getEvent()?.addWarning( + `workout route decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } } return row.geometry ?? null; }