mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(coach): log swallowed self-context decrypt failures
The Coach self-context loader failed soft per field (undecryptable allergies/conditions → null) with no signal, so a key-rotation gap made the Coach generate guidance without ever seeing the user's recorded allergies, indistinguishable from an empty profile. Keep the per-field fail-closed posture but emit a wide-event on each swallowed decrypt and on the DB-read guard, so a systemic failure surfaces instead of masquerading as 'the user never wrote anything'.
This commit is contained in:
1 parent
9eadd0755f
commit
3faa4ee76e
2 files changed
+116
-5
No files matched your search
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* F-CRYPTO-4 — an undecryptable Coach self-context field (allergies /
|
||||
* conditions) must NOT vanish silently. It still fails closed per field (never
|
||||
* surfaces ciphertext, never throws into prompt assembly), but a swallowed
|
||||
* decrypt now emits a wide-event so a key-rotation gap surfaces instead of
|
||||
* masquerading as "the user never wrote anything".
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { decryptMock, addWarningMock, findUniqueMock } = vi.hoisted(() => ({
|
||||
decryptMock: vi.fn(),
|
||||
addWarningMock: vi.fn(),
|
||||
findUniqueMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { userHealthProfile: { findUnique: findUniqueMock } },
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: decryptMock,
|
||||
encryptToBytes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: addWarningMock }),
|
||||
}));
|
||||
|
||||
import { getSelfContextForUser } from "../about-me";
|
||||
|
||||
beforeEach(() => {
|
||||
decryptMock.mockReset();
|
||||
addWarningMock.mockReset();
|
||||
findUniqueMock.mockReset();
|
||||
});
|
||||
|
||||
describe("getSelfContextForUser — undecryptable field", () => {
|
||||
it("fails closed to null AND logs a wide-event on a decrypt failure", async () => {
|
||||
findUniqueMock.mockResolvedValue({
|
||||
aboutMeEncrypted: new Uint8Array([1]),
|
||||
conditionsEncrypted: null,
|
||||
allergiesEncrypted: new Uint8Array([2]), // safety-relevant, undecryptable
|
||||
coachFocusEncrypted: null,
|
||||
});
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("bad ciphertext");
|
||||
});
|
||||
|
||||
const ctx = await getSelfContextForUser("u1");
|
||||
|
||||
// Never surfaces ciphertext; the field is null (fail-closed).
|
||||
expect(ctx.aboutMe).toBeNull();
|
||||
expect(ctx.allergies).toBeNull();
|
||||
// But the swallowed decrypt is now visible — one warning per undecryptable
|
||||
// field (aboutMe + allergies here).
|
||||
expect(addWarningMock).toHaveBeenCalledTimes(2);
|
||||
expect(addWarningMock.mock.calls[0][0]).toContain("self-context");
|
||||
});
|
||||
|
||||
it("does not log when the fields are genuinely unset", async () => {
|
||||
findUniqueMock.mockResolvedValue({
|
||||
aboutMeEncrypted: null,
|
||||
conditionsEncrypted: null,
|
||||
allergiesEncrypted: null,
|
||||
coachFocusEncrypted: null,
|
||||
});
|
||||
|
||||
const ctx = await getSelfContextForUser("u1");
|
||||
expect(ctx).toEqual({
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
expect(addWarningMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a wide-event when the DB read itself throws (no silent empty profile)", async () => {
|
||||
findUniqueMock.mockRejectedValue(new Error("db down"));
|
||||
|
||||
const ctx = await getSelfContextForUser("u1");
|
||||
expect(ctx.allergies).toBeNull();
|
||||
expect(addWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("self-context load failed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
SELF_REPORT_FENCE_START,
|
||||
@@ -48,9 +49,18 @@ function decryptOrNull(payload: Uint8Array | null): string | null {
|
||||
try {
|
||||
const text = decryptFromBytes(payload).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
} catch {
|
||||
// Fail closed per field — never surface ciphertext, never throw
|
||||
// into a prompt-assembly path.
|
||||
} catch (err) {
|
||||
// Fail closed per field — never surface ciphertext, never throw into a
|
||||
// prompt-assembly path. But do NOT swallow silently: a stored-but-
|
||||
// undecryptable safety-relevant field (allergies / conditions) that
|
||||
// vanishes from the Coach's self-context with no signal is exactly the
|
||||
// F-CRYPTO-4 gap. Emit a wide-event so a key-rotation gap surfaces instead
|
||||
// of masquerading as "the user never wrote anything".
|
||||
getEvent()?.addWarning(
|
||||
`coach self-context: field decrypt failed (${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +93,15 @@ export async function getSelfContextForUser(
|
||||
allergies: decryptOrNull(row?.allergiesEncrypted ?? null),
|
||||
coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null),
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Per-field decrypt already fails closed above; this catch is the DB-read
|
||||
// guard. Surface it (rather than masquerading as an empty profile) so a
|
||||
// systemic read/key-config failure is visible in wide-events.
|
||||
getEvent()?.addWarning(
|
||||
`coach self-context load failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
@@ -390,7 +408,15 @@ export async function getSelfContextTextForUser(
|
||||
);
|
||||
if (!selfText && !recordsText) return null;
|
||||
return [selfText, recordsText].filter(Boolean).join("\n");
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Per-field decrypt fails closed inside the composer; this catch is the
|
||||
// DB-read guard. Log it so a systemic failure surfaces rather than the
|
||||
// Coach silently generating guidance without the user's recorded profile.
|
||||
getEvent()?.addWarning(
|
||||
`coach self-context text load failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user