diff --git a/messages/de.json b/messages/de.json index 30d655577..823ce93a1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9708,7 +9708,35 @@ "cyclePeriodBoundary": "{name} hat den Beginn oder das Ende einer Periode geändert", "cycleSymptomCreate": "{name} hat ein Zyklussymptom angelegt", "nutrientWaterWrite": "{name} hat die Trinkmenge erfasst", - "healthRecordExport": "{name} hat die Akte exportiert" + "healthRecordExport": "{name} hat die Akte exportiert", + "rejectedMeasurementQuery": "{name} hat eine Messwert-Suche gesendet, die diese Akte nicht lesen konnte", + "rejectedDoseQuery": "{name} hat eine Einnahme-Suche gesendet, die diese Akte nicht lesen konnte", + "rejectedMoodQuery": "{name} hat eine Stimmungs-Suche gesendet, die diese Akte nicht lesen konnte", + "rejectedMeasurementCreate": "{name} wollte einen Messwert eintragen und wurde abgewiesen", + "rejectedMeasurementBatch": "{name} wollte mehrere Messwerte eintragen und wurde abgewiesen", + "rejectedMeasurementUpdate": "{name} wollte einen Messwert ändern und wurde abgewiesen", + "rejectedLabCreate": "{name} wollte einen Laborwert eintragen und wurde abgewiesen", + "rejectedLabUpdate": "{name} wollte einen Laborwert ändern und wurde abgewiesen", + "rejectedBiomarkerCreate": "{name} wollte einen Biomarker anlegen und wurde abgewiesen", + "rejectedBiomarkerUpdate": "{name} wollte einen Biomarker ändern und wurde abgewiesen", + "rejectedReminderCreate": "{name} wollte eine Messerinnerung anlegen und wurde abgewiesen", + "rejectedReminderUpdate": "{name} wollte eine Messerinnerung ändern und wurde abgewiesen", + "rejectedMoodCreate": "{name} wollte einen Stimmungseintrag anlegen und wurde abgewiesen", + "rejectedMoodUpdate": "{name} wollte einen Stimmungseintrag ändern und wurde abgewiesen", + "rejectedDoseCreate": "{name} wollte eine Einnahme eintragen und wurde abgewiesen", + "rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen", + "rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen", + "rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen", + "deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden", + "anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen", + "customMetricCreate": "{name} hat eine eigene Messgröße angelegt", + "customMetricUpdate": "{name} hat eine eigene Messgröße geändert", + "customMetricDelete": "{name} hat eine eigene Messgröße gelöscht", + "documentStore": "{name} hat ein Dokument hinzugefügt", + "documentUpdate": "{name} hat ein Dokument geändert", + "documentDiscard": "{name} hat ein Dokument verworfen", + "medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig", + "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index d82410d75..2225ac156 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9708,7 +9708,35 @@ "cyclePeriodBoundary": "{name} changed the start or end of a period", "cycleSymptomCreate": "{name} added a cycle symptom", "nutrientWaterWrite": "{name} recorded water intake", - "healthRecordExport": "{name} exported the record" + "healthRecordExport": "{name} exported the record", + "rejectedMeasurementQuery": "{name} sent a reading search this record could not read", + "rejectedDoseQuery": "{name} sent a dose search this record could not read", + "rejectedMoodQuery": "{name} sent a mood search this record could not read", + "rejectedMeasurementCreate": "{name} tried to add a reading and was refused", + "rejectedMeasurementBatch": "{name} tried to add several readings and was refused", + "rejectedMeasurementUpdate": "{name} tried to change a reading and was refused", + "rejectedLabCreate": "{name} tried to add a lab result and was refused", + "rejectedLabUpdate": "{name} tried to change a lab result and was refused", + "rejectedBiomarkerCreate": "{name} tried to add a biomarker and was refused", + "rejectedBiomarkerUpdate": "{name} tried to change a biomarker and was refused", + "rejectedReminderCreate": "{name} tried to add a measurement reminder and was refused", + "rejectedReminderUpdate": "{name} tried to change a measurement reminder and was refused", + "rejectedMoodCreate": "{name} tried to add a mood entry and was refused", + "rejectedMoodUpdate": "{name} tried to change a mood entry and was refused", + "rejectedDoseCreate": "{name} tried to record a dose and was refused", + "rejectedDoseUpdate": "{name} tried to change a dose and was refused", + "rejectedDoseImport": "{name} tried to import doses and was refused", + "rejectedTrackedValue": "{name} tried to log a tracked value and was refused", + "deniedDoseImport": "A dose import {name} started could not be queued", + "anamnesisFactCreate": "{name} added a health background fact", + "customMetricCreate": "{name} added a tracked value type", + "customMetricUpdate": "{name} changed a tracked value type", + "customMetricDelete": "{name} removed a tracked value type", + "documentStore": "{name} added a document", + "documentUpdate": "{name} changed a document", + "documentDiscard": "{name} discarded a document", + "medicationIntakeImportFinished": "The dose import {name} started has finished", + "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index 089f1ca4f..11b290b17 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9708,7 +9708,35 @@ "cyclePeriodBoundary": "{name} cambió el inicio o el final de una regla", "cycleSymptomCreate": "{name} añadió un síntoma del ciclo", "nutrientWaterWrite": "{name} registró la ingesta de agua", - "healthRecordExport": "{name} exportó el historial" + "healthRecordExport": "{name} exportó el historial", + "rejectedMeasurementQuery": "{name} envió una búsqueda de mediciones que este historial no pudo leer", + "rejectedDoseQuery": "{name} envió una búsqueda de dosis que este historial no pudo leer", + "rejectedMoodQuery": "{name} envió una búsqueda de ánimo que este historial no pudo leer", + "rejectedMeasurementCreate": "{name} intentó añadir una medición y fue rechazado", + "rejectedMeasurementBatch": "{name} intentó añadir varias mediciones y fue rechazado", + "rejectedMeasurementUpdate": "{name} intentó cambiar una medición y fue rechazado", + "rejectedLabCreate": "{name} intentó añadir un resultado de laboratorio y fue rechazado", + "rejectedLabUpdate": "{name} intentó cambiar un resultado de laboratorio y fue rechazado", + "rejectedBiomarkerCreate": "{name} intentó añadir un biomarcador y fue rechazado", + "rejectedBiomarkerUpdate": "{name} intentó cambiar un biomarcador y fue rechazado", + "rejectedReminderCreate": "{name} intentó añadir un recordatorio de medición y fue rechazado", + "rejectedReminderUpdate": "{name} intentó cambiar un recordatorio de medición y fue rechazado", + "rejectedMoodCreate": "{name} intentó añadir una entrada de ánimo y fue rechazado", + "rejectedMoodUpdate": "{name} intentó cambiar una entrada de ánimo y fue rechazado", + "rejectedDoseCreate": "{name} intentó registrar una dosis y fue rechazado", + "rejectedDoseUpdate": "{name} intentó cambiar una dosis y fue rechazado", + "rejectedDoseImport": "{name} intentó importar dosis y fue rechazado", + "rejectedTrackedValue": "{name} intentó registrar un valor propio y fue rechazado", + "deniedDoseImport": "Una importación de dosis iniciada por {name} no se pudo poner en cola", + "anamnesisFactCreate": "{name} añadió un dato de antecedentes de salud", + "customMetricCreate": "{name} añadió un tipo de valor propio", + "customMetricUpdate": "{name} cambió un tipo de valor propio", + "customMetricDelete": "{name} eliminó un tipo de valor propio", + "documentStore": "{name} añadió un documento", + "documentUpdate": "{name} cambió un documento", + "documentDiscard": "{name} descartó un documento", + "medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado", + "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 53f77823a..664d71d1f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9708,7 +9708,35 @@ "cyclePeriodBoundary": "{name} a modifié le début ou la fin de règles", "cycleSymptomCreate": "{name} a ajouté un symptôme de cycle", "nutrientWaterWrite": "{name} a enregistré la consommation d'eau", - "healthRecordExport": "{name} a exporté le dossier" + "healthRecordExport": "{name} a exporté le dossier", + "rejectedMeasurementQuery": "{name} a envoyé une recherche de mesures que ce dossier n'a pas pu lire", + "rejectedDoseQuery": "{name} a envoyé une recherche de prises que ce dossier n'a pas pu lire", + "rejectedMoodQuery": "{name} a envoyé une recherche d'humeur que ce dossier n'a pas pu lire", + "rejectedMeasurementCreate": "{name} a tenté d'ajouter une mesure et a été refusé", + "rejectedMeasurementBatch": "{name} a tenté d'ajouter plusieurs mesures et a été refusé", + "rejectedMeasurementUpdate": "{name} a tenté de modifier une mesure et a été refusé", + "rejectedLabCreate": "{name} a tenté d'ajouter un résultat de laboratoire et a été refusé", + "rejectedLabUpdate": "{name} a tenté de modifier un résultat de laboratoire et a été refusé", + "rejectedBiomarkerCreate": "{name} a tenté d'ajouter un biomarqueur et a été refusé", + "rejectedBiomarkerUpdate": "{name} a tenté de modifier un biomarqueur et a été refusé", + "rejectedReminderCreate": "{name} a tenté d'ajouter un rappel de mesure et a été refusé", + "rejectedReminderUpdate": "{name} a tenté de modifier un rappel de mesure et a été refusé", + "rejectedMoodCreate": "{name} a tenté d'ajouter une entrée d'humeur et a été refusé", + "rejectedMoodUpdate": "{name} a tenté de modifier une entrée d'humeur et a été refusé", + "rejectedDoseCreate": "{name} a tenté d'enregistrer une prise et a été refusé", + "rejectedDoseUpdate": "{name} a tenté de modifier une prise et a été refusé", + "rejectedDoseImport": "{name} a tenté d'importer des prises et a été refusé", + "rejectedTrackedValue": "{name} a tenté d'enregistrer une valeur personnalisée et a été refusé", + "deniedDoseImport": "Un import de prises lancé par {name} n'a pas pu être mis en file", + "anamnesisFactCreate": "{name} a ajouté un élément d'antécédents de santé", + "customMetricCreate": "{name} a ajouté un type de valeur personnalisée", + "customMetricUpdate": "{name} a modifié un type de valeur personnalisée", + "customMetricDelete": "{name} a supprimé un type de valeur personnalisée", + "documentStore": "{name} a ajouté un document", + "documentUpdate": "{name} a modifié un document", + "documentDiscard": "{name} a écarté un document", + "medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé", + "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index 91cfcacba..90cd6c579 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9708,7 +9708,35 @@ "cyclePeriodBoundary": "{name} ha modificato l'inizio o la fine di una mestruazione", "cycleSymptomCreate": "{name} ha aggiunto un sintomo del ciclo", "nutrientWaterWrite": "{name} ha registrato l'assunzione di acqua", - "healthRecordExport": "{name} ha esportato la cartella" + "healthRecordExport": "{name} ha esportato la cartella", + "rejectedMeasurementQuery": "{name} ha inviato una ricerca di misurazioni che questa cartella non ha potuto leggere", + "rejectedDoseQuery": "{name} ha inviato una ricerca di dosi che questa cartella non ha potuto leggere", + "rejectedMoodQuery": "{name} ha inviato una ricerca dell'umore che questa cartella non ha potuto leggere", + "rejectedMeasurementCreate": "{name} ha provato ad aggiungere una misurazione ed è stato rifiutato", + "rejectedMeasurementBatch": "{name} ha provato ad aggiungere più misurazioni ed è stato rifiutato", + "rejectedMeasurementUpdate": "{name} ha provato a modificare una misurazione ed è stato rifiutato", + "rejectedLabCreate": "{name} ha provato ad aggiungere un risultato di laboratorio ed è stato rifiutato", + "rejectedLabUpdate": "{name} ha provato a modificare un risultato di laboratorio ed è stato rifiutato", + "rejectedBiomarkerCreate": "{name} ha provato ad aggiungere un biomarcatore ed è stato rifiutato", + "rejectedBiomarkerUpdate": "{name} ha provato a modificare un biomarcatore ed è stato rifiutato", + "rejectedReminderCreate": "{name} ha provato ad aggiungere un promemoria di misurazione ed è stato rifiutato", + "rejectedReminderUpdate": "{name} ha provato a modificare un promemoria di misurazione ed è stato rifiutato", + "rejectedMoodCreate": "{name} ha provato ad aggiungere una voce dell'umore ed è stato rifiutato", + "rejectedMoodUpdate": "{name} ha provato a modificare una voce dell'umore ed è stato rifiutato", + "rejectedDoseCreate": "{name} ha provato a registrare una dose ed è stato rifiutato", + "rejectedDoseUpdate": "{name} ha provato a modificare una dose ed è stato rifiutato", + "rejectedDoseImport": "{name} ha provato a importare delle dosi ed è stato rifiutato", + "rejectedTrackedValue": "{name} ha provato a registrare un valore personalizzato ed è stato rifiutato", + "deniedDoseImport": "Un'importazione di dosi avviata da {name} non è stata messa in coda", + "anamnesisFactCreate": "{name} ha aggiunto un dato di anamnesi", + "customMetricCreate": "{name} ha aggiunto un tipo di valore personalizzato", + "customMetricUpdate": "{name} ha modificato un tipo di valore personalizzato", + "customMetricDelete": "{name} ha eliminato un tipo di valore personalizzato", + "documentStore": "{name} ha aggiunto un documento", + "documentUpdate": "{name} ha modificato un documento", + "documentDiscard": "{name} ha scartato un documento", + "medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata", + "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index e8eb37c86..bdbd39496 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9708,7 +9708,35 @@ "cyclePeriodBoundary": "{name} zmienił początek lub koniec miesiączki", "cycleSymptomCreate": "{name} dodał objaw cyklu", "nutrientWaterWrite": "{name} zapisał ilość wypitej wody", - "healthRecordExport": "{name} wyeksportował kartę" + "healthRecordExport": "{name} wyeksportował kartę", + "rejectedMeasurementQuery": "{name} wysłał zapytanie o pomiary, którego ta karta nie mogła odczytać", + "rejectedDoseQuery": "{name} wysłał zapytanie o dawki, którego ta karta nie mogła odczytać", + "rejectedMoodQuery": "{name} wysłał zapytanie o nastrój, którego ta karta nie mogła odczytać", + "rejectedMeasurementCreate": "{name} próbował dodać pomiar i został odrzucony", + "rejectedMeasurementBatch": "{name} próbował dodać kilka pomiarów i został odrzucony", + "rejectedMeasurementUpdate": "{name} próbował zmienić pomiar i został odrzucony", + "rejectedLabCreate": "{name} próbował dodać wynik badania i został odrzucony", + "rejectedLabUpdate": "{name} próbował zmienić wynik badania i został odrzucony", + "rejectedBiomarkerCreate": "{name} próbował dodać biomarker i został odrzucony", + "rejectedBiomarkerUpdate": "{name} próbował zmienić biomarker i został odrzucony", + "rejectedReminderCreate": "{name} próbował dodać przypomnienie o pomiarze i został odrzucony", + "rejectedReminderUpdate": "{name} próbował zmienić przypomnienie o pomiarze i został odrzucony", + "rejectedMoodCreate": "{name} próbował dodać wpis nastroju i został odrzucony", + "rejectedMoodUpdate": "{name} próbował zmienić wpis nastroju i został odrzucony", + "rejectedDoseCreate": "{name} próbował zapisać przyjęcie dawki i został odrzucony", + "rejectedDoseUpdate": "{name} próbował zmienić przyjęcie dawki i został odrzucony", + "rejectedDoseImport": "{name} próbował zaimportować przyjęcia dawek i został odrzucony", + "rejectedTrackedValue": "{name} próbował zapisać własny pomiar i został odrzucony", + "deniedDoseImport": "Import przyjęć rozpoczęty przez {name} nie mógł zostać zakolejkowany", + "anamnesisFactCreate": "{name} dodał fakt z wywiadu zdrowotnego", + "customMetricCreate": "{name} dodał własny typ pomiaru", + "customMetricUpdate": "{name} zmienił własny typ pomiaru", + "customMetricDelete": "{name} usunął własny typ pomiaru", + "documentStore": "{name} dodał dokument", + "documentUpdate": "{name} zmienił dokument", + "documentDiscard": "{name} odrzucił dokument", + "medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się", + "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}" }, "section": { "measurements": { diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts index 6dace765f..626d32e58 100644 --- a/src/__tests__/manage-activity-inventory.test.ts +++ b/src/__tests__/manage-activity-inventory.test.ts @@ -1,5 +1,5 @@ -import { readFileSync, readdirSync } from "node:fs"; -import { join, relative } from "node:path"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; import ts from "typescript"; import { describe, expect, it } from "vitest"; @@ -53,7 +53,8 @@ import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb"; * both halves against synthetic modules, including the one shape that silenced * the sibling guard: a resolver call prettier broke across lines. */ -const API = join(process.cwd(), "src/app/api"); +const SRC = join(process.cwd(), "src"); +const API = join(SRC, "app/api"); const MESSAGES = join(process.cwd(), "messages"); const ACTIVITY_ROUTE = join( process.cwd(), @@ -64,10 +65,31 @@ const GRANTS_GIVEN_CARD = join( "src/components/settings/access/grants-given-card.tsx", ); -/** The declaration a MANAGE-level route makes. */ +/** The declaration a record-delegated route makes, at any level. */ const RECORD_RESOLVER = "requireRecordAuth"; -/** The level this file is the inventory of. */ -const MANAGE = "manage"; + +/** + * Rows the feed cannot show, and the reason each one cannot. + * + * `src/lib/api-handler.ts` is a direct import of every route, so the walk + * below reaches the three actions the auth kit files itself. None of them can + * appear in a record owner's activity view, and the reason is the same for all + * three: they are filed under `auth.user.id` — the CALLER — at a point where + * no record has been resolved. The feed selects rows where `userId` is the + * owner and `actorUserId` is somebody else, so a caller-filed row is excluded + * twice over. + * + * Frozen at three. A fourth entry is a claim about a row nobody looked at, and + * the leg below fails rather than letting one join quietly. + */ +const NOT_IN_THE_FEED = [ + // The bearer path never resolves a record at all. + "auth.bearer.failure", + // Step-up is cookie-only and filed under the account being elevated. + "auth.stepup.elevation.rejected", + // `auditRefusal` files under the refused caller, before a record exists. + "sharing.access.denied", +] as const; /** What an action with no sentence of its own renders as. */ const FALLBACK_KEY = "recordSharing.activity.acted"; /** The six bundles the product ships. */ @@ -86,15 +108,24 @@ function routeFiles(dir: string): string[] { } interface ModuleFacts { - /** Does the module pass `"manage"` to the record resolver anywhere. */ - managesRecord: boolean; + /** Does the module reach the record resolver at ANY level. */ + delegatesRecord: boolean; /** - * The action names of the module's AWAITED `auditLog("…")` calls. + * Every action the module hands to `auditLog("…")`, awaited or not. * - * Awaited on purpose: a fire-and-forget breadcrumb on a validation failure - * is not something to narrate to the owner. + * v1.37.0 — this used to keep only the AWAITED calls, on the reasoning that + * a fire-and-forget breadcrumb is not worth narrating. The feed disagrees, + * and the feed is what the owner reads: `/api/account/activity` selects on + * `actorUserId IS NOT NULL` with no action filter and no await in sight, so + * a `void auditLog(…)` breadcrumb lands in the view exactly like an awaited + * row. Ten `*.validation-failed` actions were invisible to this guard for + * that reason and rendered to owners as "made a change in your record" — + * including one a READ delegate can trigger. A guard whose inclusion rule + * differs from its subject's proves a property about the wrong set. */ - awaitedAuditActions: string[]; + auditActions: string[]; + /** First-party modules this one imports, resolved to files. */ + imports: string[]; } /** @@ -119,8 +150,15 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts { const aliases = new Map(); const namespaces = new Set(); + const importPaths: string[] = []; parsed.forEachChild((node) => { if (!ts.isImportDeclaration(node)) return; + if (ts.isStringLiteral(node.moduleSpecifier)) { + const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName); + if (resolved !== null && !resolved.includes("__tests__")) { + importPaths.push(resolved); + } + } const bindings = node.importClause?.namedBindings; if (!bindings) return; if (ts.isNamespaceImport(bindings)) { @@ -151,28 +189,69 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts { return first && ts.isStringLiteralLike(first) ? first.text : null; }; - let managesRecord = false; - const awaitedAuditActions: string[] = []; + let delegatesRecord = false; + const auditActions: string[] = []; const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const name = calleeName(node.expression); - if (name === RECORD_RESOLVER && firstStringArg(node) === MANAGE) { - managesRecord = true; - } - } - if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) { - const call = node.expression; - if (calleeName(call.expression) === "auditLog") { - const action = firstStringArg(call); - if (action !== null) awaitedAuditActions.push(action); + if (name === RECORD_RESOLVER) delegatesRecord = true; + if (name === "auditLog") { + const action = firstStringArg(node); + if (action !== null) auditActions.push(action); } } node.forEachChild(visit); }; parsed.forEachChild(visit); - return { managesRecord, awaitedAuditActions }; + return { delegatesRecord, auditActions, imports: importPaths }; +} + +/** + * A module specifier resolved to a first-party file, or null. + * + * `@/…` and relative paths only. A package import is somebody else's tree and + * cannot be walked usefully. + */ +export function resolveFirstParty( + specifier: string, + fromFile: string, +): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC, specifier.slice(2)); + else if (specifier.startsWith(".")) base = join(dirname(fromFile), specifier); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + ]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Every action one route module can file, including through what it imports. + * + * One level deep, deliberately. The transitive closure pulls in the whole auth + * and grant machinery through `api-handler`, which reports actions no request + * to this route can file; one level is where a route's own collaborators live + * — the import-admission module and the medication lifecycle module are both + * direct imports of the routes that use them, and both were invisible to a + * walk that read only `route.ts`. + */ +function actionsReachableFrom( + file: string, + read: (path: string) => ModuleFacts, +): string[] { + const own = read(file); + const out = [...own.auditActions]; + for (const imported of own.imports) { + out.push(...read(imported).auditActions); + } + return out; } /** The message key one action resolves to, or the fallback. */ @@ -232,8 +311,8 @@ describe("the matcher sees what it looks for", () => { // own line and a substring test goes quiet with nothing else changed. expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false); const facts = moduleFacts(REFORMATTED, "r.ts"); - expect(facts.managesRecord).toBe(true); - expect(facts.awaitedAuditActions).toEqual(["thing.delete"]); + expect(facts.delegatesRecord).toBe(true); + expect(facts.auditActions).toEqual(["thing.delete"]); }); it("matches an aliased and a namespace-qualified resolver call", () => { @@ -247,10 +326,9 @@ describe("the matcher sees what it looks for", () => { return apiSuccess({ id: user.id }); }); `; - expect(moduleFacts(ALIASED, "a.ts")).toEqual({ - managesRecord: true, - awaitedAuditActions: ["thing.aliased"], - }); + const facts = moduleFacts(ALIASED, "a.ts"); + expect(facts.delegatesRecord).toBe(true); + expect(facts.auditActions).toEqual(["thing.aliased"]); const NAMESPACED = ` import * as auth from "@/lib/api-handler"; @@ -260,27 +338,28 @@ describe("the matcher sees what it looks for", () => { return apiSuccess({ id: user.id }); }); `; - expect(moduleFacts(NAMESPACED, "n.ts").managesRecord).toBe(true); + expect(moduleFacts(NAMESPACED, "n.ts").delegatesRecord).toBe(true); }); - it("does not enrol a write route that only talks about managing", () => { - const WRITE_ONLY = ` - /** Delegable for writes. It must never take requireRecordAuth("manage"). */ - import { requireRecordAuth } from "@/lib/api-handler"; - - const LEVEL = "manage"; + it("does not enrol a route that only talks about delegating", () => { + const ACTOR_ONLY = ` + /** An actor surface. It must never take requireRecordAuth("manage"). */ + import { requireActorAuth } from "@/lib/api-handler"; export const POST = apiHandler(async () => { - const { user } = await requireRecordAuth("write", "labs"); - return apiSuccess({ id: user.id, level: LEVEL }); + const { user } = await requireActorAuth(); + return apiSuccess({ id: user.id }); }); `; // The raw text carries the exact substring a naive membership test uses. - expect(WRITE_ONLY.includes('requireRecordAuth("manage")')).toBe(true); - expect(moduleFacts(WRITE_ONLY, "wo.ts").managesRecord).toBe(false); + expect(ACTOR_ONLY.includes('requireRecordAuth("manage")')).toBe(true); + expect(moduleFacts(ACTOR_ONLY, "ao.ts").delegatesRecord).toBe(false); }); - it("reads only the awaited audit calls", () => { + it("reads the fire-and-forget audit calls too", () => { + // The defect this file shipped with. `void auditLog(…)` was invisible + // here and perfectly visible in the owner's feed, so ten refusal + // breadcrumbs rendered as "made a change in your record". const MIXED = ` import { requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; @@ -292,11 +371,28 @@ describe("the matcher sees what it looks for", () => { return apiSuccess({ id: user.id }); }); `; - expect(moduleFacts(MIXED, "m.ts").awaitedAuditActions).toEqual([ + expect(moduleFacts(MIXED, "m.ts").auditActions).toEqual([ + "thing.breadcrumb", "thing.delete", ]); }); + it("resolves a first-party import and skips a package one", () => { + // The other half of the same defect: two of the twelve unmapped actions + // were filed by library modules the routes import, which a walk over + // `route.ts` alone could not see. + const admission = resolveFirstParty( + "@/lib/medications/intake-import-admission", + join(API, "medications/route.ts"), + ); + expect(admission).not.toBeNull(); + expect(readFileSync(admission!, "utf8")).toContain( + "medication.intake.import.kickoff.denied", + ); + expect(resolveFirstParty("next/server", "x.ts")).toBeNull(); + expect(resolveFirstParty("@/lib/nothing-here", "x.ts")).toBeNull(); + }); + it("still reports the fallback for an action nobody mapped", () => { // The positive control for the recording translator below. Without it, a // `verbKeyFor` that always returned the same string would report every @@ -305,30 +401,62 @@ describe("the matcher sees what it looks for", () => { }); }); -describe("every MANAGE action has a sentence of its own", () => { +describe("every delegated action has a sentence of its own", () => { const routes = routeFiles(API); - const manageModules = routes - .map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) })) - .filter((m) => m.managesRecord); - const actions = [ - ...new Set(manageModules.flatMap((m) => m.awaitedAuditActions)), - ].sort(); + const cache = new Map(); + const read = (file: string): ModuleFacts => { + let facts = cache.get(file); + if (!facts) { + facts = moduleFacts(readFileSync(file, "utf8"), file); + cache.set(file, facts); + } + return facts; + }; - it("matches the MANAGE modules, and not zero of them", () => { + const delegatedModules = routes.filter((file) => read(file).delegatesRecord); + const excluded = new Set(NOT_IN_THE_FEED); + const reachable = [ + ...new Set( + delegatedModules.flatMap((file) => actionsReachableFrom(file, read)), + ), + ].sort(); + const actions = reachable.filter((action) => !excluded.has(action)); + + it("matches the delegated modules, and not zero of them", () => { // A walk that finds nothing agrees with an inventory that covers nothing. expect(routes.length).toBeGreaterThan(300); - expect(manageModules.length).toBeGreaterThan(0); + expect(delegatedModules.length).toBeGreaterThan(0); // Named anchors, so a matcher that collapsed to one stray file fails // rather than clearing the floor. Deleting a reading and purging a dose - // history are the two acts the level exists for and the two an owner most - // needs named. - const found = manageModules.map((m) => relative(process.cwd(), m.file)); + // history are the two acts MANAGE exists for; the mood list is the READ + // route whose refusal breadcrumb reaches the feed, and it is here because + // the level is no longer part of the inclusion rule. + const found = delegatedModules.map((file) => relative(process.cwd(), file)); expect(found).toContain("src/app/api/measurements/[id]/route.ts"); expect(found).toContain( "src/app/api/medications/[id]/intake/purge/route.ts", ); + expect(found).toContain("src/app/api/mood-entries/route.ts"); // And the actions themselves were read, not merely looked for. - expect(actions.length).toBeGreaterThan(40); + expect(actions.length).toBeGreaterThan(60); + }); + + it("reaches the actions a route files through what it imports", () => { + // Both halves of the fix, pinned against the two that were invisible. + expect(actions).toContain("medication.intake.import.kickoff.denied"); + expect(actions).toContain("medication.oneShot.reconciled"); + // And the fire-and-forget half, on the READ route that started this. + expect(actions).toContain("mood-entries.list.validation-failed"); + }); + + it("excludes exactly the three rows the feed cannot show", () => { + // The exclusion list is the one place a real action could be hidden, so + // its size is pinned and every member is asserted to have been REACHED — + // an entry naming an action nothing files would be a claim about nothing. + expect(NOT_IN_THE_FEED).toHaveLength(3); + for (const action of NOT_IN_THE_FEED) { + expect(reachable, action).toContain(action); + } }); it("gives every one of them a specific verb rather than the fallback", () => { @@ -338,12 +466,46 @@ describe("every MANAGE action has a sentence of its own", () => { expect( generic, - "a MANAGE action with no verb line renders to the owner as the generic " + - "'made a change'. Add a case to activity-verb.ts and a sentence to " + - "all six bundles.", + "a delegated action with no verb line renders to the owner as the " + + "generic 'made a change'. Add a case to activity-verb.ts and a " + + "sentence to all six bundles — or, if the row genuinely should not " + + "reach the feed, stop stamping the actor on it.", ).toEqual([]); }); + it("never reports a refused attempt as something that happened", () => { + // The half of the HIGH finding that a coverage count cannot express. Every + // one of these rows is an attempt that changed NOTHING, and the generic + // line told the owner their record had been edited. A future arm that maps + // `…validation-failed` onto an ordinary verb would be the same defect with + // coverage, so the shape of the key is asserted rather than its presence. + const attempts = actions.filter( + (action) => + action.endsWith(".validation-failed") || action.endsWith(".denied"), + ); + expect(attempts.length).toBeGreaterThan(10); + + const dishonest = attempts.filter( + (action) => !/\.(rejected|denied)[A-Z]/.test(verbKeyFor(action).key), + ); + expect( + dishonest, + "an attempt that was refused must not render as an act that happened", + ).toEqual([]); + + // And the sentences themselves say so, in the source language. A key named + // `rejected…` whose EN text read "added a reading" would pass the check + // above and be exactly the lie it exists to stop. + const en = bundle("en"); + for (const action of attempts) { + const sentence = leaf(en, verbKeyFor(action).key); + expect(typeof sentence, action).toBe("string"); + expect(String(sentence), action).toMatch( + /tried to|could not|was refused|search this record/, + ); + } + }); + it("resolves every verb key in all six bundles", () => { const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const)); const missing: string[] = []; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index d4c78eab0..3d4b1f334 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -256,6 +256,111 @@ export function recordActivityVerbLine( case "health-record.export": return t("recordSharing.activityVerb.healthRecordExport", { name }); + /* ---------------------------------------------------------------- */ + /* Attempts that were REFUSED, and things that happened around them. */ + /* */ + /* Every delegable route files a `*.validation-failed` breadcrumb */ + /* when a request does not parse, and it files it through */ + /* `auditLog()` deliberately — that helper is what stamps */ + /* `actorUserId`, and without the stamp a delegate's malformed */ + /* request would read as the owner's own. So the rows are MEANT to */ + /* reach the feed, and until this block existed every one of them */ + /* rendered as "made a change in your record". Nothing changed. The */ + /* one on a READ route is the sharpest case: a person with */ + /* view-only access was reported to the owner as having edited */ + /* something. */ + /* */ + /* The language below never says "changed" or "added". A refusal is */ + /* an attempt, and the sentence has to be one an owner can read */ + /* without reaching for a phone. */ + /* ---------------------------------------------------------------- */ + + case "measurements.list.validation-failed": + case "measurements.series.validation-failed": + return t("recordSharing.activityVerb.rejectedMeasurementQuery", { name }); + case "medications.intake.list.validation-failed": + return t("recordSharing.activityVerb.rejectedDoseQuery", { name }); + case "mood-entries.list.validation-failed": + return t("recordSharing.activityVerb.rejectedMoodQuery", { name }); + + case "measurements.create.validation-failed": + return t("recordSharing.activityVerb.rejectedMeasurementCreate", { + name, + }); + case "measurements.create.batch.validation-failed": + return t("recordSharing.activityVerb.rejectedMeasurementBatch", { name }); + case "measurements.update.validation-failed": + return t("recordSharing.activityVerb.rejectedMeasurementUpdate", { + name, + }); + case "labs.create.validation-failed": + return t("recordSharing.activityVerb.rejectedLabCreate", { name }); + case "labs.update.validation-failed": + return t("recordSharing.activityVerb.rejectedLabUpdate", { name }); + case "labs.biomarker.create.validation-failed": + return t("recordSharing.activityVerb.rejectedBiomarkerCreate", { name }); + case "labs.biomarker.update.validation-failed": + return t("recordSharing.activityVerb.rejectedBiomarkerUpdate", { name }); + case "measurement-reminders.create.validation-failed": + return t("recordSharing.activityVerb.rejectedReminderCreate", { name }); + case "measurement-reminders.update.validation-failed": + return t("recordSharing.activityVerb.rejectedReminderUpdate", { name }); + case "mood-entries.create.validation-failed": + return t("recordSharing.activityVerb.rejectedMoodCreate", { name }); + case "mood-entries.update.validation-failed": + return t("recordSharing.activityVerb.rejectedMoodUpdate", { name }); + case "medications.intake.create.validation-failed": + return t("recordSharing.activityVerb.rejectedDoseCreate", { name }); + // Both spellings of the same refusal, for the same reason the admitted + // pair above carries both: the canonical route and the per-medication + // one audit under different names and have since v1.0.0. + case "medications.intake.update.validation-failed": + case "medications.intake.event.update.validation-failed": + return t("recordSharing.activityVerb.rejectedDoseUpdate", { name }); + case "medications.intake.import.validation-failed": + return t("recordSharing.activityVerb.rejectedDoseImport", { name }); + case "custom-metric.entry.create.validation-failed": + return t("recordSharing.activityVerb.rejectedTrackedValue", { name }); + // Not a validation failure: the request was well-formed and admitted, + // and the queue would not take it. The owner sees an import that never + // ran rather than one that was refused, because those need different + // next steps. + case "medication.intake.import.kickoff.denied": + return t("recordSharing.activityVerb.deniedDoseImport", { name }); + + /* ---------------------------------------------------------------- */ + /* Acts a delegable module performs outside its own route file. */ + /* */ + /* The two below are filed by library modules the routes import — a */ + /* background worker's completion row and a lifecycle reconciliation */ + /* — and a guard that walked only `route.ts` could not see either. */ + /* Both carry the actor through, so both reach the owner's feed. */ + /* ---------------------------------------------------------------- */ + + case "medication.intake.import": + return t("recordSharing.activityVerb.medicationIntakeImportFinished", { + name, + }); + case "medication.oneShot.reconciled": + return t("recordSharing.activityVerb.medicationOneShotReconciled", { + name, + }); + + case "anamnesis.fact.create": + return t("recordSharing.activityVerb.anamnesisFactCreate", { name }); + case "customMetric.create": + return t("recordSharing.activityVerb.customMetricCreate", { name }); + case "customMetric.update": + return t("recordSharing.activityVerb.customMetricUpdate", { name }); + case "customMetric.delete": + return t("recordSharing.activityVerb.customMetricDelete", { name }); + case "documents.inbound.store": + return t("recordSharing.activityVerb.documentStore", { name }); + case "documents.inbound.update": + return t("recordSharing.activityVerb.documentUpdate", { name }); + case "documents.inbound.discard": + return t("recordSharing.activityVerb.documentDiscard", { name }); + default: return t("recordSharing.activity.acted", { name }); } diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 80cc88377..d3499c2e5 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -14,6 +14,9 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + import { NextRequest, NextResponse } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; @@ -330,3 +333,74 @@ describe("the lifecycle verbs leave their own trail", () => { expect(trail.every((r) => r.actorUserId === null)).toBe(true); }); }); + +/** + * A view-only delegate sends a query the route cannot parse. + * + * This is the sharpest case the activity feed has, and it shipped saying the + * wrong thing. `GET /api/mood-entries` resolves `requireRecordAuth("read", + * "mind")` and files `mood-entries.list.validation-failed` through `auditLog` + * on a malformed query — deliberately through the helper, because that is what + * stamps `actorUserId` and the row is meant to be attributable. The feed + * selects every actor-stamped row with no action allowlist, and the verb map + * had no arm for any `*.validation-failed` action, so the owner was told that + * a person holding VIEW-ONLY access had made a change in their record. + * + * Nothing had changed. Nothing could have: the request was refused at the + * schema. So the whole round trip is asserted here rather than only the map — + * that the row lands, that it is attributed, and that the sentence the owner + * would read is an attempt rather than an act. + */ +describe("a refused read is reported as a refused read", () => { + it("files an attributable row and renders it as an attempt, not a change", async () => { + const { owner, delegate } = await household(); + + const { GET } = await import("@/app/api/mood-entries/route"); + const response = await GET( + new NextRequest("http://localhost/api/mood-entries?limit=not-a-number", { + method: "GET", + }), + ); + expect(response.status).toBe(422); + + // The breadcrumb is fire-and-forget, which is the property that made it + // invisible to a guard reading only awaited calls. + await settle(); + const rows = await rowsFor("mood-entries.list.validation-failed"); + expect(rows, "no breadcrumb row was written").toHaveLength(1); + expect(rows[0]?.userId).toBe(owner.id); + expect(rows[0]?.actorUserId).toBe(delegate.id); + + // Which means it reaches the owner's feed: same predicate the route uses. + const feedRows = await getPrismaClient().auditLog.findMany({ + where: { + userId: owner.id, + actorUserId: { not: null }, + NOT: { actorUserId: owner.id }, + }, + }); + expect(feedRows.map((r) => r.action)).toContain( + "mood-entries.list.validation-failed", + ); + + // And what it says there. The English bundle is read rather than a key + // asserted, because the defect was the SENTENCE, not the wiring. + const { recordActivityVerbLine } = + await import("@/lib/record-activity/activity-verb"); + const en = JSON.parse( + await readFile(join(process.cwd(), "messages/en.json"), "utf8"), + ) as Record>>; + const sentence = recordActivityVerbLine( + (key, params) => + (en.recordSharing.activityVerb[key.split(".").pop()!] ?? key).replace( + "{name}", + String(params?.name ?? ""), + ), + rows[0]!.action, + "Alex", + ); + + expect(sentence).not.toContain("made a change"); + expect(sentence).toBe("Alex sent a mood search this record could not read"); + }); +});