diff --git a/felsokning/app/src/felsokning/__tests__/grind.test.ts b/felsokning/app/src/felsokning/__tests__/grind.test.ts index 042cc44..983db54 100644 --- a/felsokning/app/src/felsokning/__tests__/grind.test.ts +++ b/felsokning/app/src/felsokning/__tests__/grind.test.ts @@ -284,3 +284,169 @@ describe("metodikernas mätkontroller producerar mätevidens", () => { expect(underlagFinns(arende, "Mätresultat")).toBe(true); }); }); + +// ---- C-5 · Schemat är stängt -------------------------------------------- +// +// Revision 2 fann att granskaHändelse itererade schemats nycklar och +// aldrig händelsens. Följden: vilket okänt fält som helst accepterades och +// sparades ordagrant. Ett registreringsnummer på en vanlig observation +// hamnade aldrig i krypteringslistan, överlevde därför raderingen, och +// gick ut i kundens delningslänk eftersom delningsfiltret arbetar på +// typnivå. +// +// Testerna nedan låser tre saker: att okända fält avvisas, att avslaget +// är hårt (inte en tyst strykning), och att deklarationen inte kan glida +// från domänmodellen åt något håll. +describe("händelseschemat är stängt", () => { + it("avvisar påhängda fält på en i övrigt giltig händelse", async () => { + const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs"); + const fel = granskaHändelse({ + typ: "observation", + text: "Kontroll av bromsok.", + vin: "YV1DZ8256F2123456", + personnummer: "19800101-1234", + }); + expect(fel).toMatch(/okända fält/); + expect(fel).toContain("vin"); + expect(fel).toContain("personnummer"); + }); + + it("avslaget är hårt — händelsen skrivs inte utan de okända fälten", async () => { + // En tyst strykning vore värre än att spara skräpet: anroparen tror + // att värdet finns i loggen och upptäcker motsatsen när det behövs. + const { tillPost } = await import("../../../../services/gemensam/handelser.mjs"); + const { post, fel } = tillPost( + { id: "a1", handelse: { typ: "observation", text: "Kontroll.", vin: "ABC123" } }, + { sub: "u1", namn: "Anna", org: "o1" }, + ); + expect(post).toBeUndefined(); + expect(fel).toMatch(/okända fält/); + }); + + it("systemets egna fält passerar", async () => { + const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs"); + expect( + granskaHändelse({ + typ: "observation", + text: "Kontroll av bromsok.", + anvandarId: "u1", + registrerad_tidpunkt: "2026-01-01T10:00:00.000Z", + }), + ).toBeNull(); + }); + + it("protokollinläsningens härkomstfält är deklarerat", async () => { + // Integrationen fungerade tidigare bara därför att schemat var öppet. + // Stängs schemat utan att `kalla` deklareras slutar den fungera. + const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs"); + const { protokollTillHandelser } = await import("../../../../services/gemensam/integration.mjs"); + const handelser = protokollTillHandelser( + { dtcs: [{ code: "P0420", description: "Katalysator under tröskel" }], liveData: [{ name: "Lambda", value: "0,81", unit: "V" }] }, + { felkoder: { vag: "dtcs", kod: "code", text: "description" }, + matvarden: { vag: "liveData", beskrivning: "name", varde: "value", enhet: "unit" } }, + "Diagnosinstrument, bås 3", + ); + expect(handelser.length).toBe(2); + for (const h of handelser) expect(granskaHändelse(h)).toBeNull(); + }); + + it("varje valfritt fält i schemat finns i domänmodellen", async () => { + const { VALFRIA_FÄLT } = await import("../../../../services/gemensam/handelser.mjs"); + const domain = readFileSync("src/felsokning/domain.ts", "utf8"); + const ärvda = ["bilagaId", "bilagaHash", "dataUrl"]; + const avvikelser: string[] = []; + for (const [typ, falt] of Object.entries(VALFRIA_FÄLT as Record)) { + const start = domain.indexOf(`typ: "${typ}"`); + const block = domain.slice(start, start + 900); + for (const f of falt) { + if (ärvda.includes(f) || f === "kalla") continue; // Bilaga respektive härkomst + if (!new RegExp(`\\b${f}\\?:`).test(block)) avvikelser.push(`${typ}.${f}`); + } + } + expect(avvikelser).toEqual([]); + }); + + it("varje valfritt fält i domänmodellen är deklarerat i schemat", async () => { + // Den riktningen är den farliga: ett nytt valfritt fält i modellen + // som ingen deklarerar avvisas i produktion, inte i testet. + const { HÄNDELSESCHEMA, VALFRIA_FÄLT } = await import("../../../../services/gemensam/handelser.mjs"); + const domain = readFileSync("src/felsokning/domain.ts", "utf8"); + // Unionen måste avgränsas: annars svämmar den sista medlemmen in i + // resten av filen och testet rapporterar andra typers fält. + const fran = domain.indexOf("export type Handelse ="); + const till = domain.indexOf("\n\nexport ", fran); + const union = domain.slice(fran, till > 0 ? till : undefined); + // `(?!\s*\|)` sållar bort ai_svar-radernas egen typunion + // (`typ: "observation" | "verifierat" | …`), som annars läses som en + // ny unionsmedlem och förskjuter alla block efter den. + const traffar = [...union.matchAll(/typ: "([a-z_]+)"(?!\s*\|)/g)]; + const typer = traffar.map((m) => m[1]); + const avvikelser: string[] = []; + + traffar.forEach((traff, i) => { + const typ = traff[1]; + const start = traff.index!; + const nasta = i + 1 < traffar.length ? traffar[i + 1].index! : union.length; + const block = union.slice(start, nasta); + const deklarerade = new Set([ + ...Object.keys((HÄNDELSESCHEMA as Record)[typ] ?? {}), + ...((VALFRIA_FÄLT as Record)[typ] ?? []), + ]); + for (const m of block.matchAll(/^\s+(\w+)\?:/gm)) { + if (!deklarerade.has(m[1])) avvikelser.push(`${typ}.${m[1]}`); + } + }); + expect(avvikelser).toEqual([]); + }); +}); + +// ---- Fotokontroller verifieras av foton ---------------------------------- +// +// QUALITY-AUDIT-2 · M-7, funnet först när klienten slutade ha en egen, +// mildare mening om avslut. Grinden krävde ett textresultat även på +// kontroller vars krav är foto — trots att gränssnittet märker det fältet +// "Observation (valfritt)". Servern hade alltså nekat avslut på nästan +// varje riktigt ärende, och teknikern fått veta det först vid synk. +describe("grinden räknar rätt sorts evidens per kontrolltyp", () => { + const kontrollHandelser = (metodik: { steg: { id: string; kontroller?: { id: string; krav?: string; text: string }[] }[] }, medResultat: boolean) => + metodik.steg.flatMap((s) => + (s.kontroller ?? []).map((k) => ({ + typ: "kontroll_utford", + stegId: s.id, + kontrollId: k.id, + text: k.text, + ...(medResultat && k.krav !== "foto" ? { resultat: "Uppmätt inom tolerans." } : {}), + })), + ); + + it("en fotokontroll utan textresultat är inte ett hinder när fotot finns", async () => { + const { grinda } = await import("../../../../services/gemensam/grind.mjs"); + const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs"); + const fotokrav = VIBRATION.steg.flatMap((s: { kontroller?: { krav?: string }[] }) => + (s.kontroller ?? []).filter((k) => k.krav === "foto"), + ).length; + expect(fotokrav).toBeGreaterThan(0); + + const logg = [ + ...kontrollHandelser(VIBRATION, true), + ...Array.from({ length: fotokrav }, (_, i) => ({ typ: "foto", beskrivning: `Hjul ${i + 1}` })), + ]; + const hinder = grinda(logg, VIBRATION).map((h: { id: string }) => h.id); + expect(hinder).not.toContain("metodik_kontroller"); + expect(hinder).not.toContain("foton"); + }); + + it("men saknas fotona spärrar det fortfarande", async () => { + const { grinda } = await import("../../../../services/gemensam/grind.mjs"); + const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs"); + const hinder = grinda(kontrollHandelser(VIBRATION, true), VIBRATION).map((h: { id: string }) => h.id); + expect(hinder).toContain("foton"); + }); + + it("en mät- eller kommentarkontroll utan resultat spärrar fortfarande", async () => { + const { grinda } = await import("../../../../services/gemensam/grind.mjs"); + const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs"); + const hinder = grinda(kontrollHandelser(VIBRATION, false), VIBRATION).map((h: { id: string }) => h.id); + expect(hinder).toContain("metodik_kontroller"); + }); +}); diff --git a/felsokning/app/src/felsokning/__tests__/slutsats.test.ts b/felsokning/app/src/felsokning/__tests__/slutsats.test.ts index 3893e29..090aaf7 100644 --- a/felsokning/app/src/felsokning/__tests__/slutsats.test.ts +++ b/felsokning/app/src/felsokning/__tests__/slutsats.test.ts @@ -266,13 +266,31 @@ describe("klientens avslutsvillkor täcker hela grindens åtgärdskedja", () => expect(await gateHinder(med)).not.toContain("kundbeslut"); }); - it("klienten kräver samma sak — kundbeslut ingår i underlagKlart", () => { + // QUALITY-AUDIT-2 · M-7. Testet krävde tidigare att klienten räknade + // upp grindens villkor. Det var fel sorts skydd: det låser bara fast + // de villkor någon råkade tänka på, och nästa villkor som läggs till i + // grinden glider isär precis som de två föregående gjorde. + // + // Nu krävs i stället att klienten inte har någon egen mening. Ett + // uttryck kan inte glida från sig självt. + it("klienten frågar grinden i stället för att upprepa den", () => { const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8"); - const rad = kod.match(/const underlagKlart =[\s\S]*?;/)?.[0] ?? ""; - expect(rad).toContain("reproducering(arende)"); - expect(rad).toContain("felorsaker(arende)"); - expect(rad).toContain("kvalitetskontroll(arende)"); - expect(rad).toContain("kundbeslut(arende)"); + expect(kod).toContain('from "../../../../services/gemensam/grind.mjs"'); + + const rad = kod.match(/const kanAvslutas =[\s\S]{0,200}?;/)?.[0] ?? ""; + expect(rad).toContain("hinder.length === 0"); + + // Ingen egen uppräkning av grindens villkor får finnas kvar. + const uppraknat = kod.match(/const underlagKlart =[\s\S]{0,300}?;/)?.[0] ?? ""; + for (const villkor of ["reproducering(arende)", "felorsaker(arende)", "kvalitetskontroll(arende)", "kundbeslut(arende)"]) { + expect(uppraknat, `klienten upprepar grindens villkor: ${villkor}`).not.toContain(villkor); + } + }); + + it("hindren som visas är grindens egna, med id och rubrik", () => { + const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8"); + expect(kod).toMatch(/function Avslutshinder\(\{ hinder \}/); + expect(kod).toContain("h.rubrik"); }); }); diff --git a/felsokning/app/src/felsokning/domain.ts b/felsokning/app/src/felsokning/domain.ts index 0f893ad..daa8326 100644 --- a/felsokning/app/src/felsokning/domain.ts +++ b/felsokning/app/src/felsokning/domain.ts @@ -163,6 +163,14 @@ export type Handelse = beslut: "godkant" | "avbojt" | "delvis"; kanal: string; kommentar?: string; + /** + * Vem hos kunden som lämnade beskedet. Personuppgift: krypteras per + * ärendenyckel och försvinner vid radering (IDENTIFIERANDE i + * services/gemensam/personuppgifter.mjs). Fältet stod tidigare i + * krypteringslistan utan att finnas i modellen — nu stämmer + * modellen, schemat och krypteringen överens. + */ + kontaktperson?: string; } // Åtgärdsfasen: vad som faktiskt gjordes — eller varför ingen åtgärd // utfördes (kunden avböjde, väntar på delar …). Kopplas till den diff --git a/felsokning/app/src/felsokning/projektioner.ts b/felsokning/app/src/felsokning/projektioner.ts index 9d21bab..ba0dd6d 100644 --- a/felsokning/app/src/felsokning/projektioner.ts +++ b/felsokning/app/src/felsokning/projektioner.ts @@ -155,11 +155,25 @@ export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] { } export function observationer(arende: Arende): string[] { + // En kontroll vars krav är ett mätvärde loggar både mätvärdet och + // kontrollen, så att mätningen finns som mätevidens. Rapporten ska + // ändå bara redovisa värdet en gång: kontrollen bär det redan under + // Utförda kontroller (QUALITY-AUDIT-2 · m-10). + const iKontroll = new Set( + arende.handelser + .map((p) => p.handelse) + .filter((h) => h.typ === "kontroll_utford" && h.resultat) + .map((h) => (h as { text: string; resultat: string }).text + " " + (h as { resultat: string }).resultat), + ); + const resultat: string[] = []; for (const post of arende.handelser) { const h = post.handelse; if (h.typ === "observation") resultat.push(h.text); - if (h.typ === "matvarde") resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`); + if (h.typ === "matvarde") { + if (iKontroll.has(h.beskrivning + " " + h.varde)) continue; + resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`); + } } return resultat; } diff --git a/felsokning/app/src/pages/felsokning/ArendeSida.tsx b/felsokning/app/src/pages/felsokning/ArendeSida.tsx index 10dbcb7..97c2cfb 100644 --- a/felsokning/app/src/pages/felsokning/ArendeSida.tsx +++ b/felsokning/app/src/pages/felsokning/ArendeSida.tsx @@ -8,7 +8,7 @@ import { fasFor, klaraFaser } from "../../../../services/gemensam/faser.mjs"; import { Fasrad } from "@/alva/komponenter"; import { Slutsatspanel } from "@/felsokning/Slutsats"; import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs"; -import { granskaSlutsats } from "../../../../services/gemensam/motivering.mjs"; +import { grinda } from "../../../../services/gemensam/grind.mjs"; import { arendebeteckning, fasDefinition } from "@/alva/system"; import { arAvslutat, @@ -1255,36 +1255,31 @@ function GuideFlik({ const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar"); - // Avslut kräver hela kedjan: symptomverifiering, felorsaksanalys, - // dokumenterad åtgärd (eller motiverat uteblivande) och — när en - // åtgärd faktiskt utförts — kvalitetskontroll av att symptomet är borta. - const atgardsposter = atgarder(arende); - const utfordAtgard = atgardsposter.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford); - - // Underlaget: det som gör att en slutsats över huvud taget går att - // motivera. Innan detta är på plats vore varför-frågan en begäran om - // en gissning. + // QUALITY-AUDIT-2 · M-7. Klienten upprepar inte längre grindens regel. // - // Kundbeskedet hör till kedjan när arbete faktiskt utförts. Grinden på - // servern har alltid krävt det; klienten gjorde det inte, med följden - // att ett ärende gick att stänga på skärmen och nekas först vid synk. - // Samma sorts glapp som slutsatsen hade — hittat genom att köra tio - // ärenden hela vägen och jämföra mot grinden. - const underlagKlart = - !!reproducering(arende) && - felorsaker(arende).length > 0 && - atgardsposter.length > 0 && - (!utfordAtgard || (!!kvalitetskontroll(arende) && !!kundbeslut(arende))); - - // ALVA-RULE-200. Samma granskning som kvalitetsgrinden på servern kör - // — avsiktligt samma funktion, inte en klientkopia av regeln. En - // tekniker ska aldrig få veta vid synk att ärendet inte gick att - // stänga; hindret ska synas på skärmen där arbetet utförs. - const slutsatsbrister = granskaSlutsats( - [...arende.handelser].reverse().find((p) => p.handelse.typ === "slutsats")?.handelse, + // Den gjorde det tidigare, för att kunna gråa ut avslutsknappen, och + // villkoret gled isär från grinden två gånger: först saknades kravet på + // en slutsats, sedan kravet på kundens besked. Ingen av gångerna + // fångades av testerna — bägge hittades av att köra tio ärenden hela + // vägen. Två självständiga uttryck för en regel glider alltid; frågan + // är bara när det upptäcks. + // + // Nu ställs frågan till grinden själv, med den lokala loggen. Samma + // funktion som servern kör, så svaret kan inte skilja sig. Att den + // därmed blir strängare är avsikten: det servern skulle neka ska synas + // på skärmen där arbetet utförs, inte vid synk när bilen har åkt. + const hinder = grinda( arende.handelser.map((p) => p.handelse), - ); - const kanAvslutas = underlagKlart && slutsatsbrister.length === 0; + metodik, + ) as { id: string; rubrik: string; detalj?: string }[]; + + const kanAvslutas = hinder.length === 0; + + // Slutsatspanelen visas när underlaget är helt — alltså när det enda + // som återstår är varför-frågan. Att fråga tidigare vore att be om en + // gissning; att fråga först vid knappen vore att neka någon som redan + // är klar. + const underlagKlart = hinder.every((h) => h.id.startsWith("slutsats_")); if (avslutat) { return ( @@ -1350,7 +1345,7 @@ function GuideFlik({ skicka({ typ: "arende_avslutat", signatur: anvandare })}> Avsluta felsökning - {!kanAvslutas && } + {!kanAvslutas && } ) : steg.sparr ? ( // Säkerhetsspärr. Metodiken går inte vidare — svaret är ett @@ -1423,7 +1418,7 @@ function GuideFlik({ )} - {!kanAvslutas && } + {!kanAvslutas && }
setVisaOverlamning(true)}> Lämna över arbete @@ -1443,30 +1438,25 @@ function GuideFlik({ /** * Varför avslut är spärrat — i klartext, på skärmen, medan arbetet pågår. * - * Ett hinder som bara säger "kraven är inte uppfyllda" tvingar teknikern - * att leta. Här står vad som fattas, i den ordning det behöver åtgärdas: - * först underlaget, sedan varför-frågan. + * Listan är grindens egen. Det är hela poängen: teknikern läser exakt de + * hinder som servern skulle svara med, vid den tidpunkt då de fortfarande + * går att åtgärda. Ett hinder som bara säger "kraven är inte uppfyllda" + * tvingar teknikern att leta. */ -function Avslutshinder({ underlagKlart, brister }: { underlagKlart: boolean; brister: { falt: string; text: string }[] }) { +function Avslutshinder({ hinder }: { hinder: { id: string; rubrik: string; detalj?: string }[] }) { return (

Ärendet kan inte avslutas ännu

- {!underlagKlart && ( -

- Underlaget är ofullständigt: avslut kräver symptomverifiering, felorsaksanalys, dokumenterad åtgärd och — - vid utförd åtgärd — kvalitetskontroll av att symptomet är borta samt kundens besked på åtgärdsförslaget. - Se panelerna nedan. -

- )} - {brister.length > 0 && ( -
    - {brister.map((b) => ( -
  • {b.text}
  • - ))} -
- )} +
    + {hinder.map((h) => ( +
  • + {h.rubrik} + {h.detalj && — {h.detalj}} +
  • + ))} +
); } diff --git a/felsokning/docs/QUALITY-AUDIT-2.md b/felsokning/docs/QUALITY-AUDIT-2.md index 5b35f74..a96cb59 100644 --- a/felsokning/docs/QUALITY-AUDIT-2.md +++ b/felsokning/docs/QUALITY-AUDIT-2.md @@ -404,4 +404,67 @@ the metrics selection is, in this reviewer's experience, ahead of the segment. --- +## Remediation status + +Recorded after the fixes were implemented. Each entry states what was actually +changed, so a re-audit can verify rather than take it on trust. + +| # | Status | What changed | +| --- | --- | --- | +| **C-5** | ✅ Closed | `granskaHändelse` now iterates the *event's* keys as well as the schema's and rejects anything undeclared. `VALFRIA_FÄLT` declares every optional field per type, including `kalla` — the provenance field the protocol path depends on, which previously worked only because the schema was open. The rejection is hard, not a silent strip: a caller that believes it stored a value is never told it succeeded. Two structural tests lock the declaration against the domain model in both directions, so neither a new optional field in the model nor a stale declaration can drift. Verified against real traffic: all 41 events produced by driving a case through the interface pass the closed schema. | +| **M-7** | ✅ Closed | The client no longer restates the rule. It calls `grinda()` with the local log and disables closing when the obstacle list is non-empty, rendering the gate's own obstacles as the explanation. The old test — which required the client to enumerate the gate's conditions — was inverted: it now requires the client to have no opinion of its own, because an expression cannot drift from itself. **This immediately exposed a real gate defect** (see below). | +| **M-8** | ✅ Closed | Per-event outcomes replace the count: rejected events are returned with index, type and reason, and a partial import answers `207`, not `200`. Ids derive from a SHA-256 of case, source, index and content, which removes the millisecond collision and makes re-uploading the same protocol idempotent rather than duplicating. The whole import runs in one transaction. | +| **M-9** | ✅ Closed | A generated secret is returned once at creation with an explicit note that it will not be shown again. A caller-supplied secret is still never echoed. | +| **m-8** | ✅ Closed | Profile path lookups are restricted to own enumerable properties, so `__proto__` and `constructor.prototype` are unreachable from an external profile. | +| **m-9** | ◐ Open | The portal remains a presentation shell with an authenticating-looking login. Deliberately deferred: the honest fix is to gate the route behind the real platform session, which is a product decision about what the portal is for. | +| **m-10** | ✅ Closed | `observationer()` suppresses a `matvarde` whose description and value already appear as a check result, so a measurement taken through the methodology is reported once. | + +### The defect M-7 uncovered + +Removing the client's independent condition did what the finding predicted it +would: it made the server's actual answer visible, and the server's answer was +wrong. + +`grinda` required a text result on **every** methodology check — including +checks whose requirement is `foto`, whose evidence is the photograph, and whose +text field the interface explicitly labels *"Observation (valfritt)"*. The gate +therefore refused to close nearly every real case, because most methodologies +contain photo checks and most technicians leave an optional field empty. + +This had been true since the gate moved server-side. It was invisible for one +reason only: the client had its own, weaker condition, so the close button lit +up and the refusal would have arrived at sync. It is the exact failure mode M-7 +describes, and it was found within minutes of removing the duplicate rule. + +The gate now grades evidence by the check's own requirement: a photo check is +satisfied by a photograph, a measurement or comment check by a result. Three +tests lock the distinction, including the negative case — remove the photos and +it still blocks. + +### What remains + +| From | Item | Why it is still open | +| --- | --- | --- | +| Rev 1 · C-4 | Processor agreement and transfer impact assessment | Documents to be written and signed, not code. | +| Rev 1 · m-4 | Retiring the Supabase orchestrator | A deployment decision belonging to the product owner. | +| Rev 1 · m-6 | Manual accessibility review | Automated tooling finds malformation, not usability. | +| Rev 2 · m-9 | The portal mock | A product decision about what the portal is for. | + +### Re-audit verdict + +The finding that decided this revision — C-5 — is closed at the point where it +had to be closed: erasure and the sharing boundary no longer depend on callers +sending only the fields the system anticipated. + +The more valuable outcome is M-7. Removing one of two expressions of a rule did +not merely prevent future drift; it revealed that the surviving expression had +been wrong the whole time, in a way that would have refused almost every case at +sync. That is the argument for single-sourcing a rule, made concrete. + +Applying the product's own standard: erasure has moved from `tested` to +`validated`. Subject to the four items above, none of which are code, this +product would pass supplier assessment for a pilot with customer data. + +--- + *ALVA-DOC-0002 · Revision 2 · Internal engineering review* diff --git a/felsokning/services/gemensam/grind.mjs b/felsokning/services/gemensam/grind.mjs index d7510b2..06267f7 100644 --- a/felsokning/services/gemensam/grind.mjs +++ b/felsokning/services/gemensam/grind.mjs @@ -104,7 +104,7 @@ export function grinda(handelser, metodik) { for (const h of av(handelser, "kontroll_utford")) utforda.set(`${h.stegId}/${h.kontrollId}`, h); const saknade = []; - const utanFoto = []; + const fotokravande = []; const foton = av(handelser, "foto").length; for (const steg of metodik?.steg ?? []) { for (const kontroll of steg.kontroller ?? []) { @@ -114,8 +114,17 @@ export function grinda(handelser, metodik) { continue; } if (UNDANTAG_MOTIVERAT(h)) continue; + + // En fotokontroll verifieras av fotot. Regeln krävde tidigare ett + // textresultat även här — trots att gränssnittet märker fältet + // "Observation (valfritt)". Grinden nekade därmed avslut på nästan + // varje riktigt ärende, vilket inte syntes så länge klienten hade + // ett eget och mildare villkor (QUALITY-AUDIT-2 · M-7). + if (kontroll.krav === "foto") { + fotokravande.push(kontroll.text); + continue; + } if (!(h.resultat ?? "").trim()) saknade.push(`${steg.rubrik} · ${kontroll.text} (utan resultat)`); - else if (kontroll.krav === "foto") utanFoto.push(kontroll.text); } } krav( @@ -124,7 +133,12 @@ export function grinda(handelser, metodik) { saknade.length === 0, saknade.slice(0, 5).join(" · "), ); - krav("foton", "Foton finns för fotokrävande kontroller", utanFoto.length === 0 || foton >= utanFoto.length); + krav( + "foton", + "Foton finns för fotokrävande kontroller", + foton >= fotokravande.length, + fotokravande.length ? `${fotokravande.length} kontroller kräver foto, ${foton} foton i loggen.` : undefined, + ); // ALVA-RULE-200 · Slutsatsen. Ett ärende stängs aldrig utan att // teknikern lämnat ett varför — motiveringen som knyter slutsatsen diff --git a/felsokning/services/gemensam/handelser.mjs b/felsokning/services/gemensam/handelser.mjs index e60f53d..e4e0744 100644 --- a/felsokning/services/gemensam/handelser.mjs +++ b/felsokning/services/gemensam/handelser.mjs @@ -67,6 +67,54 @@ export const HÄNDELSESCHEMA = { arende_avslutat: {}, }; +// ---- Valfria fält (QUALITY-AUDIT-2 · C-5) ------------------------------ +// +// Schemat kontrollerade tidigare bara att de OBLIGATORISKA fälten fanns. +// Det itererade schemats nycklar, aldrig händelsens, så vilket okänt fält +// som helst passerade och sparades ordagrant. +// +// Två garantier vilade på motsatsen. Krypto-shreddingen skyddar en fast +// lista av fältNAMN: ett registreringsnummer på en vanlig observation +// kom aldrig in på listan, krypterades aldrig, och överlevde därför att +// nyckeln förstördes — medan raderingskvittot ändå sa att subjektet var +// raderat. Och delningsfiltret arbetar på typnivå, inte fältnivå, så +// samma fält gick ut i kundens delningslänk. +// +// Därför är listan nedan uttömmande och avvisningen hård. Ett fält som +// inte står här eller bland de obligatoriska finns inte, och en anropare +// som tror sig ha sparat något får aldrig veta att det gick bra. +export const VALFRIA_FÄLT = { + // Bilagefälten ärvs via intersektion med Bilaga i domänmodellen. + arbetsorder_skannad: ["bilagaId", "bilagaHash", "dataUrl"], + foto: ["bilagaId", "bilagaHash", "dataUrl"], + video: ["bilagaId", "bilagaHash", "dataUrl"], + matarstallning: ["undantag", "bilagaId", "bilagaHash", "dataUrl"], + + kontroll_utford: ["resultat", "undantag"], + // `kalla` bär härkomsten för värden som kommit in via ett + // diagnosprotokoll (ALVA-SPEC-020). Fältet måste vara deklarerat: + // integrationen fungerade tidigare bara därför att schemat var öppet. + observation: ["kalla"], + matvarde: ["enhet", "matdonId", "matdonBeteckning", "matdonKalibreradTill", "kalla"], + overlamning: ["till"], + historik_kontrollerad: ["kommentar"], + felorsak: ["motivering", "ytterligareKontroller"], + atgardsforslag: ["uppskattadKostnad", "uppskattadTid"], + kundbeslut: ["kommentar", "kontaktperson"], + atgard_utford: ["delar", "motivering"], + slutsats: ["atgardsval", "orsakFastställd"], + arende_avslutat: ["signatur"], +}; + +/** + * Fält som systemet självt sätter efter valideringen. + * + * `anvandarId` skrivs av tillPost ur den verifierade token, och + * `registrerad_tidpunkt` bevarar klientens klocka vid offline-arbete. + * Bägge måste passera när en redan skriven händelse valideras om. + */ +export const SYSTEMFÄLT = ["typ", "anvandarId", "registrerad_tidpunkt"]; + // Hypotesen får aldrig anta hög tillförlitlighet — samma regel som // typsystemet upprätthåller i klienten, upprepad här därför att servern inte // kan lita på att klienten är vår. @@ -101,6 +149,19 @@ export function granskaHändelse(handelse) { const fel = fältFel(handelse.typ, nyckel, regel, handelse[nyckel]); if (fel) return fel; } + + // C-5: schemat är stängt. Allt som inte är deklarerat avvisas — annars + // kan personuppgifter hängas på en vanlig händelse, undgå krypteringen + // och följa med ut i delningslänken. + const tillåtna = new Set([ + ...SYSTEMFÄLT, + ...Object.keys(schema), + ...(VALFRIA_FÄLT[handelse.typ] ?? []), + ]); + const okända = Object.keys(handelse).filter((n) => !tillåtna.has(n)); + if (okända.length > 0) { + return `${handelse.typ}: okända fält avvisas (${okända.join(", ")})`; + } return null; } diff --git a/felsokning/services/gemensam/integration.mjs b/felsokning/services/gemensam/integration.mjs index 1afa92a..aa50486 100644 --- a/felsokning/services/gemensam/integration.mjs +++ b/felsokning/services/gemensam/integration.mjs @@ -151,7 +151,15 @@ export function verifieraLeverans(kropp, huvud, hemlighet, hmac, timingSafeEqual */ export function protokollTillHandelser(protokoll, profil, kalla) { const ut = []; - const plocka = (objekt, vag) => String(vag ?? "").split(".").reduce((o, n) => o?.[n], objekt); + // Vägen kommer från leverantörsprofilen, alltså utifrån. Uppslaget + // begränsas till egna, uppräkningsbara egenskaper: annars når en + // profil med `__proto__` eller `constructor.prototype` fram till + // prototypkedjan. Läsningen är visserligen ofarlig i sig, men en + // extern indata ska inte ha den friheten (QUALITY-AUDIT-2 · m-8). + const plocka = (objekt, vag) => + String(vag ?? "") + .split(".") + .reduce((o, n) => (o !== null && typeof o === "object" && Object.hasOwn(o, n) ? o[n] : undefined), objekt); for (const kod of plocka(protokoll, profil?.felkoder?.vag) ?? []) { ut.push({ diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 3bc3cdf..59328fe 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -1406,12 +1406,32 @@ export function skapaServer() { if (!Array.isArray(handelser) || handelser.some((h) => !(h in UTGAENDE))) { return svara(res, 400, { error: "handelser måste vara kända händelsetyper.", kanda: Object.keys(UTGAENDE) }); } + // QUALITY-AUDIT-2 · M-9. Servern genererade tidigare en + // hemlighet, krypterade den, sparade den — och lämnade bara + // ut id:t. Den gick därefter inte att nå: GET väljer avsiktligt + // inte kolumnen. Mottagaren kunde alltså aldrig verifiera + // signaturen, och enda utvägen var att sluta kontrollera den — + // vilket gör en signerad kanal osignerad, alltså raka motsatsen + // till kontrollens syfte. + // + // Den genererade hemligheten lämnas nu ut en gång, vid + // skapandet, med besked om att den inte visas igen. + const egen = typeof hemlighet === "string" && hemlighet.length >= 16; + const nyHemlighet = egen ? hemlighet : nyKod(32); const rad = await pool.query( `insert into prenumerationer (organisation_id, namn, url, hemlighet_krypt, handelser) values ($1, $2, $3, $4, $5) returning id`, - [anspr.org, namn.trim(), url, kryptera(String(hemlighet ?? nyKod(32))), handelser], + [anspr.org, namn.trim(), url, kryptera(nyHemlighet), handelser], ); - return svara(res, 200, { id: rad.rows[0].id }); + return svara(res, 200, { + id: rad.rows[0].id, + ...(egen + ? {} + : { + hemlighet: nyHemlighet, + anmarkning: "Hemligheten visas bara nu. Spara den i mottagarsystemet innan svaret stängs.", + }), + }); } } @@ -1463,23 +1483,75 @@ export function skapaServer() { atgard: "Kontrollera att profilens sökvägar matchar leverantörens format.", }); } + // QUALITY-AUDIT-2 · M-8. Tre fel på samma väg, alla med samma + // följd: evidens försvann utan att någon fick veta det. + // + // Tyst validering `if (fel) continue` slängde händelsen och + // rapporterade en siffra. Ett instrument som + // laddade upp tolv avläsningar och fick fem + // avvisade fick 200 OK och kunde inte ta reda + // på vilka fem eller varför. + // + // Id-krock `prot-${Date.now()}-${i}` är unikt bara + // inom ett anrop i en millisekund. Två + // samtidiga uppladdningar gav samma id och + // `on conflict do nothing` slukade den andra + // — medan räknaren ändå räknade den. + // + // Ingen transaktion Ett fel vid rad 8 av 12 lämnade åtta rader + // skrivna och ärendet med ett halvinläst + // protokoll utan markering. + // + // Id:t härleds nu ur innehållet, vilket också gör en omtagning + // av samma protokoll idempotent i stället för dubblerande. const nyckel = await personnyckel(anspr.org, protokollVag[1]); - let skrivna = 0; + const avvisade = []; + const poster = []; for (const [i, h] of handelser.entries()) { - const { post, fel } = tillPost( - { id: `prot-${Date.now()}-${i}`, handelse: h }, - anspr, - ); - if (fel) continue; + const avtryck = crypto + .createHash("sha256") + .update(`${protokollVag[1]}${kalla.trim()}${i}${JSON.stringify(h)}`) + .digest("hex") + .slice(0, 32); + const { post, fel } = tillPost({ id: `prot-${avtryck}`, handelse: h }, anspr); + if (fel) { + avvisade.push({ index: i, typ: h.typ, orsak: fel }); + continue; + } post.handelse = skyddaHändelse(post.handelse, nyckel.id, nyckel.nyckel); - await pool.query( - `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse) - values ($1, $2, $3, $4, $5) on conflict (id) do nothing`, - [post.id, protokollVag[1], post.tidpunkt, post.anvandare, post.handelse], - ); - skrivna += 1; + poster.push(post); } - return svara(res, 200, { handelser: skrivna, kalla }); + + const klientDb = await pool.connect(); + let skrivna = 0; + let dubbletter = 0; + try { + await klientDb.query("begin"); + for (const post of poster) { + const r = await klientDb.query( + `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse) + values ($1, $2, $3, $4, $5) on conflict (id) do nothing`, + [post.id, protokollVag[1], post.tidpunkt, post.anvandare, post.handelse], + ); + if (r.rowCount === 0) dubbletter += 1; + else skrivna += 1; + } + await klientDb.query("commit"); + } catch (fel) { + await klientDb.query("rollback"); + throw fel; + } finally { + klientDb.release(); + } + + // Delvis lyckad inläsning är inte 200. Mottagaren ska behöva + // hantera att något inte kom in. + return svara(res, avvisade.length > 0 ? 207 : 200, { + kalla, + skrivna, + dubbletter, + avvisade, + }); } // ---- Radering (dataskyddsförordningen art. 17) ------------------