diff --git a/felsokning/app/package-lock.json b/felsokning/app/package-lock.json index 7cdbd70..aa2e2b9 100644 --- a/felsokning/app/package-lock.json +++ b/felsokning/app/package-lock.json @@ -72,6 +72,7 @@ "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", + "axe-core": "^4.12.1", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -83,7 +84,8 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "vitest-axe": "^0.1.0" } }, "node_modules/@adobe/css-tools": { @@ -3914,6 +3916,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4537,8 +4549,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -5677,6 +5688,13 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8007,6 +8025,37 @@ } } }, + "node_modules/vitest-axe": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/vitest-axe/-/vitest-axe-0.1.0.tgz", + "integrity": "sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.0.0", + "axe-core": "^4.4.2", + "chalk": "^5.0.1", + "dom-accessibility-api": "^0.5.14", + "lodash-es": "^4.17.21", + "redent": "^3.0.0" + }, + "peerDependencies": { + "vitest": ">=0.16.0" + } + }, + "node_modules/vitest-axe/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", diff --git a/felsokning/app/package.json b/felsokning/app/package.json index bda58fb..d822022 100644 --- a/felsokning/app/package.json +++ b/felsokning/app/package.json @@ -78,6 +78,7 @@ "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", + "axe-core": "^4.12.1", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -89,7 +90,8 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "vitest-axe": "^0.1.0" }, "description": "Guidad Felsökning — evidensbaserat diagnossystem för fordonsverkstäder." } diff --git a/felsokning/app/src/felsokning/__tests__/ai.test.ts b/felsokning/app/src/felsokning/__tests__/ai.test.ts index 11c9d09..060b4d0 100644 --- a/felsokning/app/src/felsokning/__tests__/ai.test.ts +++ b/felsokning/app/src/felsokning/__tests__/ai.test.ts @@ -201,3 +201,44 @@ describe("arbetsorderskanning", () => { } }); }); + +// m-4: de två orkesterkopiorna hålls i synk av test, inte av delad kod. +// Den riktiga åtgärden är att avveckla Supabase-vägen; tills dess måste +// paritetstestet jämföra *innehåll*, inte bara att en sträng finns +// någonstans i filen. Ett substrängtest hade inte fångat att en +// grundregel ändrats i ena kopian. +describe("orkesterkopiorna är semantiskt identiska", () => { + const edge = readFileSync("../supabase/functions/felsokning-ai/index.ts", "utf8"); + const tjanst = readFileSync("../services/ai-orkester/server.mjs", "utf8"); + + /** Plockar ut ett block mellan två markörer och normaliserar blanksteg. */ + const block = (kalla: string, start: string, slut: string) => { + const i = kalla.indexOf(start); + if (i < 0) return null; + const j = kalla.indexOf(slut, i + start.length); + return kalla.slice(i, j < 0 ? undefined : j).replace(/\s+/g, " ").trim(); + }; + + it("grundreglerna är ord för ord desamma", () => { + const a = block(edge, "Absoluta regler:", "`;"); + const b = block(tjanst, "Absoluta regler:", "`;"); + expect(a).not.toBeNull(); + expect(a).toBe(b); + }); + + it("metodikkatalogen har samma id i samma ordning", () => { + const idn = (kalla: string) => { + const k = block(kalla, "const METODIK_KATALOG", "];"); + return [...(k ?? "").matchAll(/\["([a-z_]+)",/g)].map((m) => m[1]); + }; + expect(idn(edge)).toEqual(idn(tjanst)); + expect(idn(edge).length).toBe(16); + }); + + it("modellvalet per uppgift är detsamma", () => { + const routing = (kalla: string) => + [...kalla.matchAll(/^\s{2}(\w+): \{\n\s+modell: "([^"]+)"/gm)].map((m) => `${m[1]}=${m[2]}`); + expect(routing(edge).sort()).toEqual(routing(tjanst).sort()); + expect(routing(tjanst).length).toBeGreaterThanOrEqual(6); + }); +}); diff --git a/felsokning/app/src/felsokning/__tests__/tillganglighet.test.tsx b/felsokning/app/src/felsokning/__tests__/tillganglighet.test.tsx new file mode 100644 index 0000000..5d8d45b --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/tillganglighet.test.tsx @@ -0,0 +1,72 @@ +// Tillgänglighet (QUALITY-AUDIT m-6). +// +// Gränssnittet är designat för handskar, buller och starkt solljus — +// stora knappar, hög kontrast, få val per skärm. Det är genomtänkt, men +// det var obevisat: ingenting testade att en knapp har ett tillgängligt +// namn, att färg inte är enda informationsbäraren eller att formulärfält +// har etiketter. +// +// EN 301 549 gäller vid offentlig upphandling, och en verkstadskedja med +// kommunala kunder kommer att fråga. Viktigare i praktiken: en tekniker +// med nedsatt syn ska kunna använda verktyget. +// +// Testet är automatiskt och fångar därför bara en del — ungefär en +// tredjedel av WCAG-kriterierna går att kontrollera maskinellt. Det som +// återstår är manuell granskning, och det står i revisionen. +import { render } from "@testing-library/react"; +import { axe } from "vitest-axe"; +import { describe, expect, it } from "vitest"; +import { Panel, StorKnapp, TextFalt } from "../ui"; +import { Bild } from "../Bilagevisning"; + +async function utanFel(ui: React.ReactElement) { + const { container } = render(ui); + const resultat = await axe(container); + return resultat.violations.map((v) => `${v.id}: ${v.help}`); +} + +describe("verkstadsgränssnittets grundkomponenter", () => { + it("knappen har ett tillgängligt namn och går att nå med tangentbord", async () => { + expect(await utanFel( {}}>Avsluta felsökning)).toEqual([]); + }); + + it("textfältet har en etikett kopplad till inmatningen", async () => { + expect( + await utanFel( {}} />), + ).toEqual([]); + }); + + it("panelen har en rubrikstruktur som går att navigera", async () => { + expect( + await utanFel( + +

Lufttryck kontrollerat

+
, + ), + ).toEqual([]); + }); + + it("bilder bär alternativtext — evidensen måste gå att förstå utan att se den", async () => { + // En bild utan alternativtext är i den här produkten inte bara ett + // tillgänglighetsfel: bilden *är* bevisningen, och beskrivningen är + // det som säger vad den visar. + expect(await utanFel()).toEqual( + [], + ); + }); +}); + +describe("färg är aldrig enda informationsbäraren", () => { + it("status anges med text eller symbol, inte bara med färgpunkt", () => { + // Tillförlitlighets- och statusnivåer visas som färgpunkter. En + // tekniker med färgblindhet ska ändå kunna skilja verifierat från + // hypotes — därför måste texten finnas. + const { container } = render( + +

🔴 Hypotes — kräver verifiering

+
, + ); + expect(container.textContent).toMatch(/Hypotes/); + expect(container.textContent).toMatch(/verifiering/); + }); +}); diff --git a/felsokning/docs/QUALITY-AUDIT.md b/felsokning/docs/QUALITY-AUDIT.md index 7aa9ff7..4c3f20e 100644 --- a/felsokning/docs/QUALITY-AUDIT.md +++ b/felsokning/docs/QUALITY-AUDIT.md @@ -351,3 +351,56 @@ can bypass with `curl`. Close C-1 and C-2, and the honest description of this product changes from "a well-built diagnostic app" to "an evidentiary system". That is a different market and a different price. + +--- + +## 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-1** | ✅ Closed | `anvandare` derives from the verified JWT and `tidpunkt` from the server clock (`services/gemensam/handelser.mjs`). The client's timestamp is preserved as `registrerad_tidpunkt` so offline work is not lost and the gap is visible. | +| **C-2** | ✅ Closed | The gate moved to `services/gemensam/grind.mjs` and runs on the server for `arende_avslutat`; a failing close returns 409 with the actual blockers. The methodology library moved to `services/gemensam/metodiker.mjs`; the client re-exports it typed, so there is still one source. | +| **C-3** | ✅ Closed | Crypto-shredding (`services/gemensam/personuppgifter.mjs`): identifying fields encrypted per case key, erasure by destroying the key. A blinded HMAC index on the case row makes erasure reach a vehicle's whole history without storing the identifier in clear. Retention dates set at close per case type. `POST /api/radering` requires an exact confirmation and logs the erasure without recording the subject. | +| **C-4** | ✅ Closed | `ai_tillaten` per organisation, carried in the JWT; the orchestrator refuses with 403 when false. The methodology engine works alone, so a workshop that cannot accept the transfer can still use the product. **Still required and not a code change:** the processor agreement and transfer impact assessment. | +| **M-1** | ✅ Closed | `matvarde` carries `matdonId`, designation and calibration date; an instrument register lives at `/api/matdon`. Without a traceable, in-calibration instrument the value is graded E1 rather than E4, and the report says why. Calibration is judged at the time of measurement, not today. | +| **M-2** | ✅ Closed | `SPARRFRAGOR` makes the high-voltage questions hard interlocks: a "No" stops the methodology with an explicit instruction to hand over, and the server refuses to close. An unanswered safety question also blocks — silence is not a yes. | +| **M-3** | ✅ Closed | Every event is validated against `HÄNDELSESCHEMA` before writing. Unknown types are rejected. A test additionally requires every schema type to be classified in the sharing allowlist. | +| **M-4** | ✅ Closed | `atkomstlogg` records reads with user, case, route, source and share code; readable at `/api/atkomstlogg` for supervisors and admins. | +| **M-5** | ✅ Closed | `services/plattform/aterstallningstest.sh` runs in CI: dump, restore into an empty database, then verify events, provenance, ordering, attachment hashes, person keys — and that the append-only triggers both survived and still bite. | +| **M-6** | ✅ Closed | The rule pack is verified against an HMAC. An invalid signature blocks closing; a missing key gives audit mode with a warning rather than an outage, because that is how security features get switched off. | +| **m-1** | ✅ Closed | Rejection sampling replaces modulo in `nyKod`. | +| **m-2** | ✅ Closed | Id collisions are counted and surfaced in the trace rather than passing silently. | +| **m-3** | ✅ Closed | EXIF/GPS stripping is now documented as deliberate and locked by a test that reads the code path, not just the output. | +| **m-4** | ◐ Partial | The parity test now compares content — base rules word for word, catalogue ids in order, model routing per task — instead of checking that strings appear. **The real fix is retiring the Supabase path**, which is a deployment decision, not a code change. | +| **m-5** | ✅ Closed | `npm audit` at high severity plus a CycloneDX SBOM per build, archived 400 days. | +| **m-6** | ◐ Partial | Automated accessibility tests with axe on the core workshop components. Roughly a third of WCAG is machine-checkable; the manual review remains outstanding. | +| **m-7** | ✅ Closed | Each response carries a `promptversion` — a short stable hash of model, effort and system prompt — so two answers can be compared without storing the prompt. | + +### What remains + +Two items are honestly open, and neither is a code change: + +1. **The processor agreement and transfer impact assessment (C-4).** The + technical control exists; the legal basis is a document that has to be + written and signed, and a DPIA that has to be performed. +2. **Retiring the Supabase orchestrator (m-4).** Duplication defended by tests + is a liability that grows. The path is documented as the older one; the + decision to remove it belongs to the product owner. + +A manual accessibility review (m-6) should also be scheduled. Automated tooling +does not find whether the interface is usable, only whether it is malformed. + +### Re-audit verdict + +The two findings that decided the assessment — C-1 and C-2 — are closed at the +API boundary, which is where they had to be closed. The record now establishes +who did the work and when from the authenticated session rather than from the +caller, and a case cannot be closed without the evidence the product claims to +require. + +The honest description of this product has changed from "a well-built diagnostic +app" to "an evidentiary system." Subject to the two open documents above, it +would pass supplier assessment for a pilot with customer data.