diff --git a/docs/DRIFT.md b/docs/DRIFT.md index c8b4a0b..02bc017 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -22,7 +22,7 @@ flowchart LR | `plattform` | Självhostad backend (`services/plattform`): **multi-tenant** — registrering skapar organisation + systemadministratör, admin hanterar användare (tekniker/arbetsledare/admin), all ärendedata organisationsisolerad. Inloggning (bcrypt via pgcrypto, HS256-JWT med roll + org i anspråken), append-only händelse-API, publik delningsendpoint | Deployment + Service + HPA + PDB | | `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB | | `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR | -| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | +| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord`, `integration-nyckel` (krypterar kundernas märkesspecifika credentials) | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | **Klienten har två driftlägen**, valda vid bygget: med `VITE_PLATTFORM_URL` går inloggning, synk, Live Share och AI mot klustret (helt självhostat); utan den används Supabase-läget (edge-funktion + managerad Postgres/Auth) som tidigare. Samma händelsemodell, samma orkester — låst av paritetstester. @@ -43,7 +43,8 @@ kubectl create namespace guidad-felsokning kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \ --from-literal=anthropic-api-key='sk-ant-…' \ --from-literal=jwt-secret="$(openssl rand -base64 48)" \ - --from-literal=postgres-losenord="$(openssl rand -base64 24)" + --from-literal=postgres-losenord="$(openssl rand -base64 24)" \ + --from-literal=integration-nyckel="$(openssl rand -hex 32)" # 3. Applicera manifesten (Postgres initieras med schema + append-only-triggers) kubectl apply -k infra/k8s @@ -56,6 +57,29 @@ curl https://app.exempel.se/api/openapi.yaml # API-first: hela API-specen Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör. +## Märkesspecifika kopplingar + +Varje verkstad har sina egna avtal med tillverkare och dataleverantörer. +Kopplingarna konfigureras därför av kunden själv under **Inställningar → +Märkesspecifika kopplingar**: systemadministratören väljer leverantör och +fyller i sina credentials. + +* **Uppgifterna når aldrig webbläsaren.** De krypteras med AES-256-GCM + (`INTEGRATION_NYCKEL`, 32 byte hex eller base64) innan de skrivs till + tabellen `integrationer`, och API:t returnerar hemliga fält maskerade + (`••••3456`). Alla uppslag mot leverantören görs av servern. +* **Fail closed.** Saknas `INTEGRATION_NYCKEL` sparas ingenting — API:t + svarar 503 och inställningssidan säger varför. Inga uppgifter hamnar + någonsin i klartext. +* **Leverantörer är data, inte kod.** Registret ligger i + `services/plattform/integrationer.json` och kan bytas mot en + ConfigMap-mount via `INTEGRATIONER_FIL`. Nya märken läggs till genom + att beskriva URL-mall, autentiseringstyp och svarsmappning — ingen + ombyggnad av applikationen krävs. +* **Testresultat loggas på kopplingen.** Varje uppslag skriver + `senast_testad` och `senaste_status`, så ett trasigt abonnemang syns i + inställningarna i stället för att tyst ge tomma svar. + ## Multi-tenant och roller Enligt Master Prompt: varje kund är en egen tenant, ingen data blandas mellan kunder. diff --git a/docs/MVP.md b/docs/MVP.md index 3e71d5b..671d863 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -43,6 +43,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. | | Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. | +| Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). | | Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | ## Arkitekturprinciper i koden @@ -57,4 +58,4 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st ## Medvetna avgränsningar -- VIN-avkodning mot fordonsdatabaser (utrustningsnivå, återkallelser, TSB:er) och tillverkarintegrationer ingår inte ännu — arbetsorderskanningen och QR-/VIN-avläsningen ger strukturen de kopplas in i. +- Ramen för tillverkarintegrationer finns (kunden lägger in sina egna credentials och slår upp fordonsuppgifter), men de anrikade datamängderna — utrustningsnivå, återkallelser, TSB:er — mappas inte ännu; registret behöver fler svarsfält och leverantörsprofiler innan det är meningsfullt. diff --git a/docs/moduler/markesspecifika-kopplingar.md b/docs/moduler/markesspecifika-kopplingar.md new file mode 100644 index 0000000..6b35736 --- /dev/null +++ b/docs/moduler/markesspecifika-kopplingar.md @@ -0,0 +1,101 @@ +# Märkesspecifika kopplingar + +Verkstaden har redan sina avtal. Volvo-verkstaden har VIDA, VAG-verkstaden +har erWin, den fria verkstaden har en fordonsdataleverantör. Ingen av dem +vill att vi ska vara mellanhand för deras abonnemang — och ingen av dem +har samma uppsättning som grannen. + +Därför konfigurerar **kunden själv** sina kopplingar under +**Inställningar → Märkesspecifika kopplingar**, med sina egna credentials. +Vi tillhandahåller ramen, inte kontot. + +## Principer + +**Uppgifterna når aldrig webbläsaren.** Samma regel som för +plattformens egna API-nycklar: hemligheter bor på servern. Credentials +krypteras med AES-256-GCM innan de skrivs till databasen, och API:t +returnerar hemliga fält maskerade (`••••3456`). Klienten kan se *att* en +koppling finns och när den senast fungerade — aldrig vad nyckeln är. + +**Alla uppslag görs av servern.** Klienten skickar en identifierare +(VIN eller regnr); servern hämtar uppgifterna, dekrypterar dem i minnet, +anropar leverantören och returnerar bara de mappade fordonsfälten. + +**Fail closed.** Saknas krypteringsnyckeln (`INTEGRATION_NYCKEL`) sparas +ingenting — API:t svarar 503 och inställningssidan förklarar varför. +Alternativet, att lagra i klartext "så länge", finns inte. + +**Endast systemadministratören.** Att lägga till, ändra och ta bort +kopplingar kräver rollen `admin`. Teknikern kan läsa registret över +tillgängliga leverantörer (annars kan inställningssidan inte visa dem) +men aldrig någon organisations uppgifter. + +**Organisationsknutet.** Kopplingarna hör till organisationen, precis +som ärendedata. Ingen tenant ser en annans. + +## Leverantörer är data, inte kod + +Registret ligger i `services/plattform/integrationer.json` och kan bytas +mot en ConfigMap-mount via `INTEGRATIONER_FIL`. En leverantör beskrivs +helt deklarativt: + +```json +{ + "id": "volvo_vida", + "namn": "Volvo VIDA", + "falt": [ + { "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false }, + { "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true } + ], + "uppslag": { + "urlFalt": "bas_url", + "auth": "header", + "authHeader": "X-Api-Key", + "authFalt": "api_nyckel", + "svarsfalt": { "marke": "make", "modell": "model", "arsmodell": "year" } + } +} +``` + +* `falt` — vad administratören ska fylla i. `hemlig: true` styr både + kryptering och maskering. +* `uppslag.auth` — `bearer`, `header`, `basic` eller `query`. Inga + leverantörsspecifika kodgrenar; all variation ligger i registret. +* `svarsfalt` — mappning från leverantörens JSON (punktnotation stöds) + till våra fordonsfält. +* `nyckeltyp: "regnr"` — uppslaget sker på registreringsnummer i stället + för VIN. `{vin}`/`{regnr}` i URL-mallen ersätts URL-kodat. + +Ett nytt märke läggs alltså till genom att beskriva det — inte genom att +bygga om applikationen. + +## Vad ett uppslag gör och inte gör + +Uppslaget fyller i **fordonsbeskrivningen** (märke, modell, årsmodell, +motor, växellåda). Det är kontextdata, inte evidens: ett svar från en +leverantör är aldrig en utförd kontroll och räknas inte i +[evidensmotorn](evidensmotor.md). Returnerar leverantören inga kända fält +säger systemet det rakt ut i stället för att visa tomma rader. + +Varje uppslag skriver `senast_testad` och `senaste_status` på +kopplingen. Ett utgånget abonnemang syns därför i inställningarna som ett +felmeddelande från leverantören, inte som tysta tomma svar. + +## API + +| Väg | Metod | Roll | Vad | +| --- | --- | --- | --- | +| `/api/integrationer/leverantorer` | GET | inloggad | Registret (fältdefinitioner, inga uppgifter) | +| `/api/integrationer` | GET | admin | Organisationens kopplingar, hemligheter maskerade | +| `/api/integrationer` | POST | admin | Spara/uppdatera credentials (krypteras) | +| `/api/integrationer/{leverantor}` | DELETE | admin | Ta bort | +| `/api/integrationer/{leverantor}/uppslag` | POST | inloggad | Slå upp VIN/regnr via servern | + +Fullständigt dokumenterat i `services/plattform/openapi.yaml`. + +## Drift + +`INTEGRATION_NYCKEL` är 32 byte hex eller base64 (`openssl rand -hex 32`), +levererad via secret:en `felsokning-hemligheter` — se +[DRIFT.md](../DRIFT.md). Byts nyckeln måste kopplingarna sparas om; +tjänsten visar då inga värden i stället för att gissa. diff --git a/infra/k8s/hemligheter.exempel.yaml b/infra/k8s/hemligheter.exempel.yaml index 9ed333b..633aead 100644 --- a/infra/k8s/hemligheter.exempel.yaml +++ b/infra/k8s/hemligheter.exempel.yaml @@ -3,7 +3,8 @@ # kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \ # --from-literal=anthropic-api-key='sk-ant-…' \ # --from-literal=jwt-secret="$(openssl rand -base64 48)" \ -# --from-literal=postgres-losenord="$(openssl rand -base64 24)" +# --from-literal=postgres-losenord="$(openssl rand -base64 24)" \ +# --from-literal=integration-nyckel="$(openssl rand -hex 32)" # # I produktion: använd extern secrets-hantering (External Secrets Operator, # Sealed Secrets eller molnleverantörens secret manager). @@ -17,3 +18,7 @@ stringData: anthropic-api-key: ERSATT-MIG jwt-secret: ERSATT-MIG postgres-losenord: ERSATT-MIG + # 32 byte hex (openssl rand -hex 32) — krypterar kundernas + # märkesspecifika credentials i vila. Byts nyckeln måste kopplingarna + # sparas om; tjänsten visar då inga värden i stället för att gissa. + integration-nyckel: ERSATT-MIG diff --git a/infra/k8s/plattform.yaml b/infra/k8s/plattform.yaml index 8271a47..a034bbe 100644 --- a/infra/k8s/plattform.yaml +++ b/infra/k8s/plattform.yaml @@ -24,6 +24,12 @@ spec: - name: JWT_SECRET valueFrom: secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret } + # Krypteringsnyckel för kundernas märkesspecifika credentials + # (32 byte hex eller base64). Saknas den sparas inga uppgifter + # alls — tjänsten failar closed i stället för att lagra i klartext. + - name: INTEGRATION_NYCKEL + valueFrom: + secretKeyRef: { name: felsokning-hemligheter, key: integration-nyckel } - name: POSTGRES_LOSENORD valueFrom: secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord } diff --git a/infra/k8s/postgres-init.sql b/infra/k8s/postgres-init.sql index ff1b305..62d8dee 100644 --- a/infra/k8s/postgres-init.sql +++ b/infra/k8s/postgres-init.sql @@ -16,6 +16,21 @@ create table if not exists organisationer ( ); alter table organisationer add column if not exists installningar jsonb not null default '{}'::jsonb; +-- Märkesspecifika kopplingar per organisation. Uppgifterna lagras +-- krypterade (AES-256-GCM, nyckel ur INTEGRATION_NYCKEL) och lämnar +-- aldrig servern i klartext — klienten ser bara maskerade värden. +create table if not exists integrationer ( + organisation_id uuid not null references organisationer(id), + leverantor text not null, + uppgifter_krypt text not null, + aktiv boolean not null default true, + skapad timestamptz not null default now(), + uppdaterad timestamptz not null default now(), + senast_testad timestamptz, + senaste_status text, + primary key (organisation_id, leverantor) +); + create table if not exists anvandare ( id uuid primary key default gen_random_uuid(), organisation_id uuid not null references organisationer(id), diff --git a/package.json b/package.json index e27141f..7bb4cb0 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build:dev": "vite build --mode development", "lint": "eslint .", "preview": "vite preview", + "typkontroll": "tsc --noEmit -p tsconfig.app.json", "test": "vitest run", "test:watch": "vitest" }, diff --git a/services/plattform/Dockerfile b/services/plattform/Dockerfile index 2715dad..da6bd88 100644 --- a/services/plattform/Dockerfile +++ b/services/plattform/Dockerfile @@ -4,7 +4,7 @@ FROM node:22-alpine WORKDIR /app COPY package.json ./ RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force -COPY server.mjs openapi.yaml ecm-regler.json ./ +COPY server.mjs openapi.yaml ecm-regler.json integrationer.json ./ ENV NODE_ENV=production PORT=8080 USER node diff --git a/services/plattform/integrationer.json b/services/plattform/integrationer.json new file mode 100644 index 0000000..931ff90 --- /dev/null +++ b/services/plattform/integrationer.json @@ -0,0 +1,96 @@ +{ + "version": "1.0", + "beskrivning": "Register över märkesspecifika kopplingar. Leverantörer är data, inte kod: nya märken/dataleverantörer läggs till här (eller via ConfigMap-mount, INTEGRATIONER_FIL) utan att applikationen byggs om. Fält märkta hemlig=true maskeras alltid i API-svar och lagras krypterat.", + "leverantorer": [ + { + "id": "generisk_vin", + "namn": "Generisk VIN-tjänst", + "beskrivning": "Valfri dataleverantör med VIN-uppslag över HTTP. Ange bas-URL och nyckel enligt leverantörens dokumentation.", + "falt": [ + { "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false }, + { "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true } + ], + "uppslag": { + "urlFalt": "bas_url", + "auth": "bearer", + "authFalt": "api_nyckel", + "svarsfalt": { + "marke": "make", + "modell": "model", + "arsmodell": "year", + "motor": "engine", + "vaxellada": "transmission" + } + } + }, + { + "id": "vag_erwin", + "namn": "Volkswagen Group erWin", + "beskrivning": "OEM-portal för VW, Audi, Škoda och SEAT. Kräver eget abonnemang hos tillverkaren.", + "falt": [ + { "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false }, + { "nyckel": "anvandarnamn", "etikett": "Användarnamn", "hemlig": false }, + { "nyckel": "losenord", "etikett": "Lösenord", "hemlig": true } + ], + "uppslag": { + "urlFalt": "bas_url", + "auth": "basic", + "authFalt": "anvandarnamn", + "authFalt2": "losenord", + "svarsfalt": { + "marke": "brand", + "modell": "model", + "arsmodell": "modelYear", + "motor": "engineCode", + "vaxellada": "gearbox" + } + } + }, + { + "id": "volvo_vida", + "namn": "Volvo VIDA", + "beskrivning": "Volvos verkstadsportal. Kräver eget abonnemang och verkstads-ID.", + "falt": [ + { "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false }, + { "nyckel": "verkstadsid", "etikett": "Verkstads-ID", "hemlig": false }, + { "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true } + ], + "uppslag": { + "urlFalt": "bas_url", + "auth": "header", + "authHeader": "X-Api-Key", + "authFalt": "api_nyckel", + "svarsfalt": { + "marke": "make", + "modell": "model", + "arsmodell": "year", + "motor": "engine", + "vaxellada": "transmission" + } + } + }, + { + "id": "fordonsregister", + "namn": "Fordonsregisteruppslag (regnr → fordon)", + "beskrivning": "Nationell fordonsdata via vald leverantör. Uppslag sker på registreringsnummer i stället för VIN.", + "nyckeltyp": "regnr", + "falt": [ + { "nyckel": "bas_url", "etikett": "Bas-URL (använd {regnr} som platshållare)", "hemlig": false }, + { "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true } + ], + "uppslag": { + "urlFalt": "bas_url", + "auth": "query", + "authParam": "key", + "authFalt": "api_nyckel", + "svarsfalt": { + "marke": "make", + "modell": "model", + "arsmodell": "year", + "vin": "vin", + "motor": "engine" + } + } + } + ] +} diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 5b46d83..cd411e4 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -253,6 +253,88 @@ for i in 1 2 3 4 5 6 7; do done kontroll "takt-begränsning stoppar upprepade försök" "$SISTA" "429" +# 10d. Märkesspecifika kopplingar: kundens egna credentials +# Registret är läsbart för alla inloggade (inställningssidan behöver +# veta vilka leverantörer som finns) men innehåller inga uppgifter. +LEV=$(curl -s "$BAS/api/integrationer/leverantorer" -H "Authorization: Bearer $TOKEN_J") +kontroll "leverantörsregistret är läsbart för tekniker" \ + "$(echo "$LEV" | falt '.leverantorer.some(l=>l.id==="generisk_vin")')" "true" +kontroll "registret pekar ut hemliga fält" \ + "$(echo "$LEV" | falt '.leverantorer.every(l=>l.falt.some(f=>f.hemlig===true))')" "true" + +# Uppgifterna är administratörens ensak +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_J") +kontroll "tekniker ser inte kopplingarnas uppgifter" "$KOD" "403" + +# Utan konfigurerad krypteringsnyckel sparas ingenting — fail closed +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' \ + -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}","api_nyckel":"k"}}') +kontroll "utan krypteringsnyckel sparas inga uppgifter" "$KOD" "503" + +# Starta om tjänsten med krypteringsnyckel konfigurerad +kill "$SERVER_PID" 2>/dev/null || true +wait "$SERVER_PID" 2>/dev/null || true +DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \ + JWT_SECRET=integrationshemlighet PORT=$APPPORT \ + INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \ + node server.mjs & +SERVER_PID=$! +sleep 1 + +# Okänd leverantör och ofullständiga uppgifter avvisas +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"leverantor":"hittepa","uppgifter":{"a":"b"}}') +kontroll "okänd leverantör avvisas" "$KOD" "400" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}"}}') +kontroll "ofullständiga uppgifter avvisas" "$KOD" "400" + +# Administratören sparar organisationens egna credentials +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' \ + -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"http://127.0.0.1:9/vin/{vin}","api_nyckel":"sk-verkstad-123456"}}') +kontroll "administratören kan spara credentials" "$KOD" "200" + +# Hemligheten lämnar aldrig servern i klartext +INT=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A") +kontroll "krypteringen är konfigurerad" "$(echo "$INT" | falt .krypteringKonfigurerad)" "true" +kontroll "hemligt fält maskeras i svaret" "$(echo "$INT" | falt '.integrationer[0].uppgifter.api_nyckel')" "••••3456" +kontroll "öppet fält visas som det är" "$(echo "$INT" | falt '.integrationer[0].uppgifter.bas_url')" "http://127.0.0.1:9/vin/{vin}" + +# … och ligger krypterad i databasen +RAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -tAc "select uppgifter_krypt from integrationer where leverantor='generisk_vin'") +case "$RAD" in + *sk-verkstad-123456*) echo "✗ uppgifterna ligger i klartext i databasen"; exit 1 ;; + *) echo "✓ uppgifterna ligger krypterade i databasen" ;; +esac + +# Uppslag: identifieraren valideras, okonfigurerad koppling ger 404 +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"x"}') +kontroll "ogiltig identifierare avvisas" "$KOD" "400" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/volvo_vida/uppslag" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}') +kontroll "okonfigurerad koppling ger 404" "$KOD" "404" + +# Leverantören är onåbar i testmiljön — felet rapporteras ärligt, inte tyst +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}') +kontroll "onåbar leverantör rapporteras som 502" "$KOD" "502" +STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \ + | falt '.integrationer[0].senaste_status.slice(0,3)') +kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel" + +# Kopplingar är organisationsknutna +ANTAL_B=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_B" | falt .integrationer.length) +kontroll "org B ser inte org A:s kopplingar" "$ANTAL_B" "0" + +# Borttagning +curl -s -X DELETE "$BAS/api/integrationer/generisk_vin" -H "Authorization: Bearer $TOKEN_A" >/dev/null +ANTAL=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" | falt .integrationer.length) +kontroll "kopplingen kan tas bort" "$ANTAL" "0" + # 11. API-first: OpenAPI-specen serveras live, utan inloggning SPEC=$(curl -s "$BAS/api/openapi.yaml") case "$SPEC" in diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index d42fd1b..af8af79 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -26,6 +26,7 @@ tags: - name: Översikt - name: Delning - name: AI + - name: Integrationer - name: Drift paths: @@ -229,6 +230,185 @@ paths: "401": { $ref: "#/components/responses/Fel" } "403": { $ref: "#/components/responses/Fel" } + /api/integrationer/leverantorer: + get: + tags: [Integrationer] + summary: Registret över märkesspecifika kopplingar + description: > + Leverantörer är data, inte kod: registret läses ur + `integrationer.json` (eller filen i `INTEGRATIONER_FIL`) och kan + bytas via ConfigMap utan att applikationen byggs om. Innehåller + endast fältdefinitioner — aldrig någon organisations uppgifter. + responses: + "200": + description: Leverantörsdefinitioner. + content: + application/json: + schema: + type: object + properties: + version: { type: string } + leverantorer: + type: array + items: + type: object + properties: + id: { type: string } + namn: { type: string } + beskrivning: { type: string } + nyckeltyp: + type: string + enum: [vin, regnr] + description: Vad uppslaget sker på. Utelämnat betyder VIN. + falt: + type: array + items: + type: object + properties: + nyckel: { type: string } + etikett: { type: string } + hemlig: + type: boolean + description: > + Hemliga fält maskeras alltid i svar och + visas aldrig igen efter sparande. + "401": { $ref: "#/components/responses/Fel" } + + /api/integrationer: + get: + tags: [Integrationer] + summary: Organisationens konfigurerade kopplingar + description: > + Kräver rollen `admin`. Uppgifterna lagras krypterade + (AES-256-GCM) och returneras alltid maskerade — hemliga värden + lämnar aldrig servern i klartext. + responses: + "200": + description: Konfigurerade kopplingar med maskerade uppgifter. + content: + application/json: + schema: + type: object + properties: + krypteringKonfigurerad: + type: boolean + description: Falskt om `INTEGRATION_NYCKEL` saknas — då kan inget sparas. + integrationer: + type: array + items: + type: object + properties: + leverantor: { type: string } + namn: { type: string } + aktiv: { type: boolean } + uppdaterad: { type: string, format: date-time } + senast_testad: { type: string, format: date-time, nullable: true } + senaste_status: { type: string, nullable: true } + uppgifter: + type: object + additionalProperties: { type: string } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + post: + tags: [Integrationer] + summary: Spara eller uppdatera en kopplings uppgifter + description: > + Kräver rollen `admin`. Endast leverantörens definierade fält + sparas och samtliga måste fyllas i. Uppgifterna krypteras innan + de skrivs. Ett sparande nollställer tidigare testresultat. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [leverantor, uppgifter] + properties: + leverantor: { type: string } + aktiv: { type: boolean, default: true } + uppgifter: + type: object + additionalProperties: { type: string } + responses: + "200": { description: Sparad. } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + "503": + description: Kryptering är inte konfigurerad (`INTEGRATION_NYCKEL` saknas). + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + + /api/integrationer/{leverantor}: + delete: + tags: [Integrationer] + summary: Ta bort en kopplings uppgifter + description: Kräver rollen `admin`. + parameters: + - name: leverantor + in: path + required: true + schema: { type: string } + responses: + "200": { description: Borttagen. } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + + /api/integrationer/{leverantor}/uppslag: + post: + tags: [Integrationer] + summary: Slå upp ett fordon hos leverantören + description: > + Anropet görs alltid av servern — kundens leverantörsnycklar når + aldrig webbläsaren. Svaret mappas till våra fält enligt + registrets `svarsfalt`. Resultatet skrivs som kopplingens + senaste teststatus. + parameters: + - name: leverantor + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [identifierare] + properties: + identifierare: + type: string + description: VIN eller registreringsnummer beroende på leverantörens `nyckeltyp`. + responses: + "200": + description: Fordonsuppgifter från leverantören. + content: + application/json: + schema: + type: object + properties: + fordon: + type: object + additionalProperties: { type: string } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "404": + description: Kopplingen är inte konfigurerad för organisationen. + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + "502": + description: Leverantören svarade med fel eller inga kända fält. + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + "503": + description: Kryptering är inte konfigurerad. + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + /api/delad/{delningskod}/beslut: post: tags: [Delning] @@ -617,6 +797,10 @@ components: properties: error: { type: string } schemas: + Fel: + type: object + properties: + error: { type: string } Roll: type: string enum: [tekniker, arbetsledare, admin] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index 6163352..cc5a05d 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -15,7 +15,7 @@ // PORT default 8080 import { createServer } from "node:http"; -import crypto, { createHmac, timingSafeEqual } from "node:crypto"; +import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -33,6 +33,16 @@ const ECM_REGLER = readFileSync( "utf8", ); +// Register över märkesspecifika kopplingar — data, inte kod. Nya +// leverantörer läggs till i filen (eller via ConfigMap-mount) utan att +// applikationen byggs om. +const INTEGRATIONER = JSON.parse( + readFileSync( + process.env.INTEGRATIONER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "integrationer.json"), + "utf8", + ), +); + const PORT = Number(process.env.PORT ?? 8080); const MAX_KROPP = 4 * 1024 * 1024; const TOKEN_LIVSTID_S = 12 * 60 * 60; @@ -84,6 +94,46 @@ export function verifieraJwt(token, hemlighet) { } } +// ---- Integrationsuppgifter: kryptering i vila ------------------------ +// +// Kundens egna leverantörsnycklar lagras krypterade med AES-256-GCM. +// Nyckeln kommer ur INTEGRATION_NYCKEL (32 byte, hex eller base64) och +// finns bara i driftens hemlighetshantering. Saknas nyckeln kan +// integrationer varken sparas eller användas — fail closed. + +function integrationsNyckel() { + const ra = process.env.INTEGRATION_NYCKEL; + if (!ra) return null; + const buf = /^[0-9a-fA-F]{64}$/.test(ra) ? Buffer.from(ra, "hex") : Buffer.from(ra, "base64"); + return buf.length === 32 ? buf : null; +} + +export function kryptera(klartext, nyckel) { + const iv = randomBytes(12); + const chiffer = createCipheriv("aes-256-gcm", nyckel, iv); + const data = Buffer.concat([chiffer.update(klartext, "utf8"), chiffer.final()]); + return `${iv.toString("base64")}.${chiffer.getAuthTag().toString("base64")}.${data.toString("base64")}`; +} + +export function dekryptera(paket, nyckel) { + const [iv, tagg, data] = paket.split("."); + const dechiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64")); + dechiffer.setAuthTag(Buffer.from(tagg, "base64")); + return Buffer.concat([dechiffer.update(Buffer.from(data, "base64")), dechiffer.final()]).toString("utf8"); +} + +// Hemliga fält lämnar aldrig servern i klartext — klienten ser bara att +// ett värde finns och dess sista tecken. +export function maskera(varde) { + if (typeof varde !== "string" || varde.length === 0) return ""; + if (varde.length <= 4) return "••••"; + return `••••${varde.slice(-4)}`; +} + +function leverantorsDef(id) { + return INTEGRATIONER.leverantorer.find((l) => l.id === id); +} + // ---- Hjälpare --------------------------------------------------------- function svara(res, status, kropp) { @@ -127,6 +177,44 @@ async function arendeIOrg(arendeId, organisationId) { return rader.rowCount > 0; } +// Generiskt uppslag mot en leverantör. All variation ligger i registret +// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs- +// specifika kodgrenar. +export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) { + const u = def.uppslag ?? {}; + const mall = uppgifter[u.urlFalt ?? "bas_url"]; + if (typeof mall !== "string" || !/^https?:\/\//.test(mall)) { + return { ok: false, fel: "Bas-URL saknas eller är ogiltig." }; + } + let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare)); + const headers = { Accept: "application/json" }; + + if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`; + if (u.auth === "header") headers[u.authHeader ?? "X-Api-Key"] = uppgifter[u.authFalt]; + if (u.auth === "basic") { + const par = `${uppgifter[u.authFalt]}:${uppgifter[u.authFalt2]}`; + headers.Authorization = `Basic ${Buffer.from(par).toString("base64")}`; + } + if (u.auth === "query") { + url += `${url.includes("?") ? "&" : "?"}${encodeURIComponent(u.authParam ?? "key")}=${encodeURIComponent(uppgifter[u.authFalt])}`; + } + + try { + const svarFran = await hamtare(url, { headers, signal: AbortSignal.timeout(10_000) }); + if (!svarFran.ok) return { ok: false, fel: `Leverantören svarade ${svarFran.status}.` }; + const data = await svarFran.json(); + const fordon = {}; + for (const [vart, deras] of Object.entries(u.svarsfalt ?? {})) { + const varde = deras.split(".").reduce((niva, del) => (niva == null ? niva : niva[del]), data); + if (varde !== undefined && varde !== null && `${varde}`.trim()) fordon[vart] = `${varde}`.trim(); + } + if (Object.keys(fordon).length === 0) return { ok: false, fel: "Leverantören returnerade inga kända fält." }; + return { ok: true, fordon }; + } catch (fel) { + return { ok: false, fel: fel?.name === "TimeoutError" ? "Leverantören svarade inte i tid." : "Anropet misslyckades." }; + } +} + // ---- Server ----------------------------------------------------------- export function skapaServer() { @@ -364,6 +452,135 @@ export function skapaServer() { return res.end(ECM_REGLER); } + // -- Märkesspecifika kopplingar (integrationer) -- + // Registret läses av alla inloggade (så inställningssidan kan visa + // vilka leverantörer som finns); uppgifterna hanteras endast av + // systemadministratören och returneras alltid maskerade. + if (req.method === "GET" && vag === "/api/integrationer/leverantorer") { + return svara(res, 200, INTEGRATIONER); + } + + if (vag === "/api/integrationer") { + if (req.method === "GET") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + const nyckel = integrationsNyckel(); + const rader = await pool.query( + `select leverantor, uppgifter_krypt, aktiv, uppdaterad, senast_testad, senaste_status + from integrationer where organisation_id = $1 order by leverantor`, + [anspr.org], + ); + const integrationer = rader.rows.map((rad) => { + const def = leverantorsDef(rad.leverantor); + let uppgifter = {}; + try { + if (nyckel) uppgifter = JSON.parse(dekryptera(rad.uppgifter_krypt, nyckel)); + } catch { + // Fel nyckel eller manipulerad rad — visa inga värden. + } + const maskerade = {}; + for (const falt of def?.falt ?? []) { + const varde = uppgifter[falt.nyckel]; + maskerade[falt.nyckel] = falt.hemlig ? maskera(varde) : (varde ?? ""); + } + return { + leverantor: rad.leverantor, + namn: def?.namn ?? rad.leverantor, + aktiv: rad.aktiv, + uppdaterad: rad.uppdaterad, + senast_testad: rad.senast_testad, + senaste_status: rad.senaste_status, + uppgifter: maskerade, + }; + }); + return svara(res, 200, { integrationer, krypteringKonfigurerad: !!nyckel }); + } + if (req.method === "POST") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + const nyckel = integrationsNyckel(); + if (!nyckel) { + return svara(res, 503, { + error: "Kryptering är inte konfigurerad (INTEGRATION_NYCKEL saknas) — uppgifter kan inte sparas.", + }); + } + const { leverantor, uppgifter, aktiv } = await lasKropp(req); + const def = leverantorsDef(leverantor); + if (!def) return svara(res, 400, { error: "Okänd leverantör." }); + if (!uppgifter || typeof uppgifter !== "object") { + return svara(res, 400, { error: "Uppgifter saknas." }); + } + // Endast leverantörens definierade fält sparas, och varje fält + // måste ha ett värde — inga tomma nycklar i vila. + const rena = {}; + for (const falt of def.falt) { + const varde = uppgifter[falt.nyckel]; + if (typeof varde !== "string" || !varde.trim()) { + return svara(res, 400, { error: `Fältet "${falt.etikett}" måste fyllas i.` }); + } + if (varde.length > 2000) return svara(res, 400, { error: "Ett värde är för långt." }); + rena[falt.nyckel] = varde.trim(); + } + await pool.query( + `insert into integrationer (organisation_id, leverantor, uppgifter_krypt, aktiv) + values ($1, $2, $3, $4) + on conflict (organisation_id, leverantor) + do update set uppgifter_krypt = excluded.uppgifter_krypt, + aktiv = excluded.aktiv, + uppdaterad = now(), + senast_testad = null, + senaste_status = null`, + [anspr.org, leverantor, kryptera(JSON.stringify(rena), nyckel), aktiv !== false], + ); + return svara(res, 200, { ok: true }); + } + } + + const integrationVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)$/); + if (req.method === "DELETE" && integrationVag) { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + await pool.query(`delete from integrationer where organisation_id = $1 and leverantor = $2`, [ + anspr.org, + integrationVag[1], + ]); + return svara(res, 200, { ok: true }); + } + + // Uppslag mot märkesspecifik koppling. Anropet görs alltid av + // servern — kundens leverantörsnycklar når aldrig webbläsaren. + const uppslagVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)\/uppslag$/); + if (req.method === "POST" && uppslagVag) { + const nyckel = integrationsNyckel(); + const def = leverantorsDef(uppslagVag[1]); + if (!def) return svara(res, 400, { error: "Okänd leverantör." }); + if (!nyckel) return svara(res, 503, { error: "Kryptering är inte konfigurerad." }); + + const { identifierare } = await lasKropp(req); + if (typeof identifierare !== "string" || !/^[A-Za-z0-9-]{4,20}$/.test(identifierare.trim())) { + return svara(res, 400, { error: "Ogiltig identifierare." }); + } + const rad = await pool.query( + `select uppgifter_krypt from integrationer + where organisation_id = $1 and leverantor = $2 and aktiv = true`, + [anspr.org, uppslagVag[1]], + ); + if (rad.rowCount === 0) return svara(res, 404, { error: "Kopplingen är inte konfigurerad." }); + + let uppgifter; + try { + uppgifter = JSON.parse(dekryptera(rad.rows[0].uppgifter_krypt, nyckel)); + } catch { + return svara(res, 500, { error: "Uppgifterna kunde inte läsas — spara om kopplingen." }); + } + + const resultat = await gorUppslag(def, uppgifter, identifierare.trim().toUpperCase()); + await pool.query( + `update integrationer set senast_testad = now(), senaste_status = $3 + where organisation_id = $1 and leverantor = $2`, + [anspr.org, uppslagVag[1], resultat.ok ? "ok" : `fel: ${resultat.fel}`.slice(0, 200)], + ); + if (!resultat.ok) return svara(res, 502, { error: resultat.fel }); + return svara(res, 200, { fordon: resultat.fordon }); + } + // Organisationens inställningar: vad som visas när ett ärende // startas (objekttyper, identifieringsmetoder). Alla inloggade // läser; endast systemadministratören ändrar. diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 7699b02..4aa0c9c 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -82,6 +82,10 @@ describe("AI-orkestern", () => { ["/api/oversikt", "/api/oversikt"], ["/api/delad/{delningskod}", "delad"], ["/api/delad/{delningskod}/beslut", "beslut"], + ["/api/integrationer", "/api/integrationer"], + ["/api/integrationer/leverantorer", "/api/integrationer/leverantorer"], + ["/api/integrationer/{leverantor}", "integrationVag"], + ["/api/integrationer/{leverantor}/uppslag", "uppslagVag"], ]; for (const [iSpec, iServer] of vagar) { expect(spec).toContain(`${iSpec}:`); diff --git a/src/felsokning/__tests__/integrationer.test.ts b/src/felsokning/__tests__/integrationer.test.ts new file mode 100644 index 0000000..559569e --- /dev/null +++ b/src/felsokning/__tests__/integrationer.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +// Servermodulen körs bara i Node — jsdom saknar bland annat +// AbortSignal.timeout, som uppslaget använder. +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { dekryptera, gorUppslag, kryptera, maskera } from "../../../services/plattform/server.mjs"; + +const REGISTER = JSON.parse(readFileSync("services/plattform/integrationer.json", "utf8")); + +describe("integrationsuppgifter i vila", () => { + it("krypterar och dekrypterar med AES-256-GCM", () => { + const nyckel = randomBytes(32); + const hemlighet = JSON.stringify({ api_nyckel: "sk-verkstad-123", bas_url: "https://api.exempel.se/{vin}" }); + const paket = kryptera(hemlighet, nyckel); + // Chiffertexten avslöjar ingenting. + expect(paket).not.toContain("sk-verkstad-123"); + expect(paket.split(".")).toHaveLength(3); + expect(dekryptera(paket, nyckel)).toBe(hemlighet); + }); + + it("manipulerad chiffertext avvisas av autentiseringstaggen", () => { + const nyckel = randomBytes(32); + const paket = kryptera("hemligt", nyckel); + const [iv, tagg, data] = paket.split("."); + const trasig = `${iv}.${tagg}.${Buffer.from("annat innehåll").toString("base64")}`; + expect(() => dekryptera(trasig, nyckel)).toThrow(); + // Fel nyckel går inte heller. + expect(() => dekryptera(paket, randomBytes(32))).toThrow(); + }); + + it("maskerar hemligheter så bara de sista tecknen syns", () => { + expect(maskera("sk-verkstad-9821")).toBe("••••9821"); + expect(maskera("abc")).toBe("••••"); + expect(maskera("")).toBe(""); + expect(maskera(undefined)).toBe(""); + }); +}); + +describe("leverantörsregistret är data, inte kod", () => { + it("varje leverantör har fält och en komplett uppslagsdefinition", () => { + expect(REGISTER.leverantorer.length).toBeGreaterThan(0); + for (const lev of REGISTER.leverantorer) { + expect(lev.id, lev.namn).toMatch(/^[a-z0-9_]+$/); + expect(lev.falt.length, lev.namn).toBeGreaterThan(0); + for (const falt of lev.falt) { + expect(typeof falt.hemlig, `${lev.id}/${falt.nyckel}`).toBe("boolean"); + } + // Varje leverantör måste ha minst ett hemligt fält (annars behövs + // ingen kryptering) och en URL-mall att slå upp mot. + expect(lev.falt.some((f: { hemlig: boolean }) => f.hemlig), lev.id).toBe(true); + expect(Object.keys(lev.uppslag.svarsfalt).length, lev.id).toBeGreaterThan(0); + } + }); +}); + +describe("uppslag mot leverantör", () => { + const def = REGISTER.leverantorer.find((l: { id: string }) => l.id === "generisk_vin"); + + it("bygger URL ur mallen och mappar svaret till våra fält", async () => { + let anropadUrl = ""; + let anropadeHeaders: Record = {}; + const resultat = await gorUppslag( + def, + { bas_url: "https://api.exempel.se/vin/{vin}", api_nyckel: "hemlig-nyckel" }, + "YV1DZ8256F2123456", + async (url: string, init: { headers: Record }) => { + anropadUrl = url; + anropadeHeaders = init.headers; + return { ok: true, json: async () => ({ make: "Volvo", model: "XC60", year: 2019, okant: "x" }) }; + }, + ); + expect(anropadUrl).toBe("https://api.exempel.se/vin/YV1DZ8256F2123456"); + expect(anropadeHeaders.Authorization).toBe("Bearer hemlig-nyckel"); + expect(resultat.ok).toBe(true); + expect(resultat.fordon).toEqual({ marke: "Volvo", modell: "XC60", arsmodell: "2019" }); + }); + + it("avvisar ogiltig bas-URL och rapporterar leverantörens fel ärligt", async () => { + const utanUrl = await gorUppslag(def, { bas_url: "inte-en-url", api_nyckel: "x" }, "ABC123"); + expect(utanUrl.ok).toBe(false); + expect(utanUrl.fel).toContain("Bas-URL"); + + const felsvar = await gorUppslag( + def, + { bas_url: "https://api.exempel.se/{vin}", api_nyckel: "x" }, + "ABC123", + async () => ({ ok: false, status: 401, json: async () => ({}) }), + ); + expect(felsvar.ok).toBe(false); + expect(felsvar.fel).toContain("401"); + + const tomtSvar = await gorUppslag( + def, + { bas_url: "https://api.exempel.se/{vin}", api_nyckel: "x" }, + "ABC123", + async () => ({ ok: true, json: async () => ({ helt: "andra falt" }) }), + ); + expect(tomtSvar.ok).toBe(false); + expect(tomtSvar.fel).toContain("inga kända fält"); + }); + + it("stödjer basic och query-autentisering utan leverantörsspecifik kod", async () => { + const basicDef = REGISTER.leverantorer.find((l: { id: string }) => l.id === "vag_erwin"); + let headers: Record = {}; + await gorUppslag( + basicDef, + { bas_url: "https://erwin.exempel.se/{vin}", anvandarnamn: "verkstad", losenord: "hemligt" }, + "YV1DZ8256F2123456", + async (_url: string, init: { headers: Record }) => { + headers = init.headers; + return { ok: true, json: async () => ({ brand: "VW" }) }; + }, + ); + expect(headers.Authorization).toBe(`Basic ${Buffer.from("verkstad:hemligt").toString("base64")}`); + + const queryDef = REGISTER.leverantorer.find((l: { id: string }) => l.id === "fordonsregister"); + let url = ""; + await gorUppslag( + queryDef, + { bas_url: "https://fordon.exempel.se/{regnr}", api_nyckel: "n1" }, + "ABC123", + async (u: string) => { + url = u; + return { ok: true, json: async () => ({ make: "Volvo" }) }; + }, + ); + expect(url).toBe("https://fordon.exempel.se/ABC123?key=n1"); + }); +}); diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index c178111..65ddc35 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -175,6 +175,75 @@ export async function hamtaFelorsaksstatistik(): Promise<{ orsak: string; antal: return ((await res.json()) as { orsaker: { orsak: string; antal: number }[] }).orsaker; } +// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern +// och returneras alltid maskerade — klienten ser aldrig hemligheterna. +export interface LeverantorsFalt { + nyckel: string; + etikett: string; + hemlig: boolean; +} + +export interface Leverantor { + id: string; + namn: string; + beskrivning?: string; + nyckeltyp?: string; + falt: LeverantorsFalt[]; +} + +export interface Integration { + leverantor: string; + namn: string; + aktiv: boolean; + uppdaterad: string; + senast_testad: string | null; + senaste_status: string | null; + uppgifter: Record; +} + +export async function hamtaLeverantorer(): Promise { + const res = await plattformFetch("/api/integrationer/leverantorer"); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return ((await res.json()) as { leverantorer: Leverantor[] }).leverantorer; +} + +export async function hamtaIntegrationer(): Promise<{ integrationer: Integration[]; krypteringKonfigurerad: boolean }> { + const res = await plattformFetch("/api/integrationer"); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return (await res.json()) as { integrationer: Integration[]; krypteringKonfigurerad: boolean }; +} + +export async function sparaIntegration(leverantor: string, uppgifter: Record): Promise { + const res = await plattformFetch("/api/integrationer", { + method: "POST", + body: JSON.stringify({ leverantor, uppgifter }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Fel ${res.status}`); + } +} + +export async function taBortIntegration(leverantor: string): Promise { + const res = await plattformFetch(`/api/integrationer/${leverantor}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Fel ${res.status}`); +} + +// Uppslag mot leverantören — anropet görs av servern med organisationens +// krypterade uppgifter; klienten skickar bara identifieraren. +export async function gorUppslag( + leverantor: string, + identifierare: string, +): Promise> { + const res = await plattformFetch(`/api/integrationer/${leverantor}/uppslag`, { + method: "POST", + body: JSON.stringify({ identifierare }), + }); + const data = (await res.json().catch(() => ({}))) as { fordon?: Record; error?: string }; + if (!res.ok) throw new Error(data.error ?? `Fel ${res.status}`); + return data.fordon ?? {}; +} + // Autentiserat anrop mot plattformen. En utgången token rensas (401) så // att appen faller tillbaka till lokalt läge tills nästa inloggning. export async function plattformFetch(vag: string, init?: RequestInit): Promise { diff --git a/src/pages/felsokning/ArendeSida.tsx b/src/pages/felsokning/ArendeSida.tsx index 88d610e..370c0a6 100644 --- a/src/pages/felsokning/ArendeSida.tsx +++ b/src/pages/felsokning/ArendeSida.tsx @@ -63,6 +63,7 @@ import { reproduceringsText, sparbarhetspaket, underlagFinns, + UNDANTAGSORSAKER, } from "@/felsokning/ecm"; import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format"; diff --git a/src/pages/felsokning/Installningar.tsx b/src/pages/felsokning/Installningar.tsx index 1c4e37b..281904e 100644 --- a/src/pages/felsokning/Installningar.tsx +++ b/src/pages/felsokning/Installningar.tsx @@ -12,8 +12,17 @@ import { sparaInstallningar, type Installningar as Inst, } from "@/felsokning/installningar"; -import { plattformAktiv, plattformKonto } from "@/felsokning/plattform"; -import { FelsokningSkal, Panel, StorKnapp } from "@/felsokning/ui"; +import { + hamtaIntegrationer, + hamtaLeverantorer, + plattformAktiv, + plattformKonto, + sparaIntegration, + taBortIntegration, + type Integration, + type Leverantor, +} from "@/felsokning/plattform"; +import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; function Vallista({ alla, @@ -47,6 +56,145 @@ function Vallista({ ); } + +// Märkesspecifika kopplingar: systemadministratören lägger in +// organisationens egna leverantörsuppgifter. Värdena skickas till +// servern, lagras krypterade och kommer aldrig tillbaka i klartext — +// hemliga fält visas alltid maskerade. Alla uppslag görs av servern. +function Integrationer() { + const [leverantorer, setLeverantorer] = useState([]); + const [befintliga, setBefintliga] = useState([]); + const [krypteringKlar, setKrypteringKlar] = useState(true); + const [oppen, setOppen] = useState(null); + const [varden, setVarden] = useState>({}); + const [fel, setFel] = useState(""); + const [sparat, setSparat] = useState(""); + + const ladda = () => { + hamtaLeverantorer().then(setLeverantorer).catch(() => setLeverantorer([])); + hamtaIntegrationer() + .then((svar) => { + setBefintliga(svar.integrationer); + setKrypteringKlar(svar.krypteringKonfigurerad); + }) + .catch(() => setBefintliga([])); + }; + + useEffect(ladda, []); + if (leverantorer.length === 0) return null; + + return ( + +

+ Organisationens egna avtal hos tillverkare och dataleverantörer. Uppgifterna lagras krypterat på + plattformen och används bara av servern — de skickas aldrig till teknikernas enheter. +

+ {!krypteringKlar && ( +

+ Kryptering är inte konfigurerad i driften (INTEGRATION_NYCKEL) — uppgifter kan inte sparas förrän + nyckeln finns. +

+ )} + + {leverantorer.map((lev) => { + const finns = befintliga.find((i) => i.leverantor === lev.id); + return ( +
+
+
+

{lev.namn}

+ {lev.beskrivning &&

{lev.beskrivning}

} + {finns && ( +

+ {Object.entries(finns.uppgifter) + .map(([nyckel, varde]) => `${nyckel}: ${varde || "—"}`) + .join(" · ")} + {finns.senaste_status && ` · senaste uppslag: ${finns.senaste_status}`} +

+ )} +
+ + {finns ? "Konfigurerad" : "Ej konfigurerad"} + +
+ + {oppen === lev.id ? ( +
+ {lev.falt.map((falt) => ( + setVarden((f) => ({ ...f, [falt.nyckel]: v }))} + losenord={falt.hemlig} + /> + ))} + {fel &&

{fel}

} +
+ { setOppen(null); setFel(""); }}> + Avbryt + + { + setFel(""); + try { + await sparaIntegration(lev.id, varden); + setVarden({}); + setOppen(null); + setSparat(lev.namn); + ladda(); + } catch (misslyckande) { + setFel(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara."); + } + }} + > + Spara uppgifter + +
+

+ Hemliga fält visas aldrig igen efter sparande — fyll i på nytt för att byta värde. +

+
+ ) : ( +
+ { + setOppen(lev.id); + setFel(""); + // Icke-hemliga värden förifylls så bara hemligheten + // behöver skrivas om. + const start: Record = {}; + for (const falt of lev.falt) { + if (!falt.hemlig && finns?.uppgifter[falt.nyckel]) start[falt.nyckel] = finns.uppgifter[falt.nyckel]; + } + setVarden(start); + }} + > + {finns ? "Uppdatera uppgifter" : "Lägg till uppgifter"} + + {finns && ( + { + await taBortIntegration(lev.id); + ladda(); + }} + > + Ta bort + + )} +
+ )} +
+ ); + })} + {sparat &&

Uppgifter sparade för {sparat}.

} +
+ ); +} + export default function Installningar() { const konto = plattformKonto(); const inloggad = plattformAktiv() && !!konto; @@ -121,6 +269,8 @@ export default function Installningar() { /> + {inloggad && } + {status === "fel" &&

{felText}

} {status === "sparat" &&

✓ Sparat

} diff --git a/src/test/setup.ts b/src/test/setup.ts index fa2d096..a2a6e6a 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,15 +1,20 @@ import "@testing-library/jest-dom"; -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => {}, - }), -}); +// Tester som kör servermoduler väljer node-miljön och har ingen window. +if (typeof window === "undefined") { + // Inget att förbereda — DOM-hjälpmedlen används inte där. +} else { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), + }); +}