fix(workouts): read a route that does not open as no track

A damaged or foreign-key route ciphertext threw out of readRouteGeometry, so
one bad track answered the workout detail with a 500 and failed the activity
insight job. It now reads as no track with a warning on the request event, and
still never falls back to the readable column.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:56:01 +02:00
1 parent e7f3385374
commit c1aa235e27
2 files changed
+30 -7

No files matched your search

@@ -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",
);
});
});
+17 -4
View File
@@ -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;
}