Märkesspecifika kopplingar — kunden lägger in sina egna credentials

Verkstaden har redan sina avtal: Volvo-verkstaden har VIDA, VAG-verkstaden
har erWin, den fria verkstaden har en fordonsdataleverantör. Kopplingarna
konfigureras därför av kunden själv under Inställningar, med sina egna
uppgifter — vi tillhandahåller ramen, inte kontot.

Uppgifterna når aldrig webbläsaren. De krypteras med AES-256-GCM
(INTEGRATION_NYCKEL) innan de skrivs till tabellen integrationer, och
API:t returnerar hemliga fält maskerade. Alla uppslag görs av servern.
Saknas krypteringsnyckeln sparas ingenting alls — 503 och en förklaring i
gränssnittet i stället för klartext i databasen. Endast
systemadministratören hanterar uppgifterna; kopplingarna är
organisationsknutna som all annan ärendedata.

Leverantörer är data, inte kod: URL-mall, autentiseringstyp
(bearer/header/basic/query) och svarsmappning beskrivs i
services/plattform/integrationer.json, utbytbar via ConfigMap
(INTEGRATIONER_FIL). Nya märken läggs till utan att appen byggs om.

Varje uppslag skriver senast_testad och senaste_status på kopplingen, så
ett utgånget abonnemang syns i inställningarna i stället för att ge tysta
tomma svar.

Två latenta krascher hittade av klicktestet och åtgärdade: TextFalt och
UNDANTAGSORSAKER användes utan import. vite build typkontrollerar inte,
så de passerade bygget — därav nya npm-skriptet typkontroll, nu del av
verifieringen.

Verifierat: 80 vitest-tester, typkontroll, eslint, OpenAPI-validering,
integrationstest mot riktig Postgres (rollstyrning, kryptering i vila,
maskering, organisationsisolering, fail closed, borttagning) och
klickgenomgång mot en körande plattform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 12:50:44 +00:00
parent 64b2124cd8
commit 3a0be7261e
18 changed files with 1112 additions and 21 deletions
+26 -2
View File
@@ -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.
+2 -1
View File
@@ -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.
+101
View File
@@ -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`
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.
+6 -1
View File
@@ -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
+6
View File
@@ -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 }
+15
View File
@@ -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),
+1
View File
@@ -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"
},
+1 -1
View File
@@ -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
+96
View File
@@ -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"
}
}
}
]
}
+82
View File
@@ -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
+184
View File
@@ -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]
+218 -1
View File
@@ -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.
+4
View File
@@ -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}:`);
@@ -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<string, string> = {};
const resultat = await gorUppslag(
def,
{ bas_url: "https://api.exempel.se/vin/{vin}", api_nyckel: "hemlig-nyckel" },
"YV1DZ8256F2123456",
async (url: string, init: { headers: Record<string, string> }) => {
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<string, string> = {};
await gorUppslag(
basicDef,
{ bas_url: "https://erwin.exempel.se/{vin}", anvandarnamn: "verkstad", losenord: "hemligt" },
"YV1DZ8256F2123456",
async (_url: string, init: { headers: Record<string, string> }) => {
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");
});
});
+69
View File
@@ -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<string, string>;
}
export async function hamtaLeverantorer(): Promise<Leverantor[]> {
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<string, string>): Promise<void> {
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<void> {
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<Record<string, string>> {
const res = await plattformFetch(`/api/integrationer/${leverantor}/uppslag`, {
method: "POST",
body: JSON.stringify({ identifierare }),
});
const data = (await res.json().catch(() => ({}))) as { fordon?: Record<string, string>; 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<Response> {
+1
View File
@@ -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";
+152 -2
View File
@@ -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<Leverantor[]>([]);
const [befintliga, setBefintliga] = useState<Integration[]>([]);
const [krypteringKlar, setKrypteringKlar] = useState(true);
const [oppen, setOppen] = useState<string | null>(null);
const [varden, setVarden] = useState<Record<string, string>>({});
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 (
<Panel rubrik="Märkesspecifika kopplingar">
<p className="mb-2 text-[12px] text-[#707070]">
Organisationens egna avtal hos tillverkare och dataleverantörer. Uppgifterna lagras krypterat
plattformen och används bara av servern de skickas aldrig till teknikernas enheter.
</p>
{!krypteringKlar && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
Kryptering är inte konfigurerad i driften (INTEGRATION_NYCKEL) uppgifter kan inte sparas förrän
nyckeln finns.
</p>
)}
{leverantorer.map((lev) => {
const finns = befintliga.find((i) => i.leverantor === lev.id);
return (
<div key={lev.id} className="border-b border-[#EBEBEB] py-2 last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] font-semibold">{lev.namn}</p>
{lev.beskrivning && <p className="text-[12px] text-[#707070]">{lev.beskrivning}</p>}
{finns && (
<p className="mt-1 text-[12px] text-[#4A5560]">
{Object.entries(finns.uppgifter)
.map(([nyckel, varde]) => `${nyckel}: ${varde || "—"}`)
.join(" · ")}
{finns.senaste_status && ` · senaste uppslag: ${finns.senaste_status}`}
</p>
)}
</div>
<span className={`shrink-0 text-[11px] font-semibold ${finns ? "text-[#1E6B34]" : "text-[#707070]"}`}>
{finns ? "Konfigurerad" : "Ej konfigurerad"}
</span>
</div>
{oppen === lev.id ? (
<div className="mt-2 rounded border border-[#C6C6C6] bg-white p-2">
{lev.falt.map((falt) => (
<TextFalt
key={falt.nyckel}
label={`${falt.etikett}${falt.hemlig ? " (lagras krypterat)" : ""}`}
varde={varden[falt.nyckel] ?? ""}
satt={(v) => setVarden((f) => ({ ...f, [falt.nyckel]: v }))}
losenord={falt.hemlig}
/>
))}
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => { setOppen(null); setFel(""); }}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={!krypteringKlar}
onClick={async () => {
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
</StorKnapp>
</div>
<p className="mt-1 text-[11px] text-[#707070]">
Hemliga fält visas aldrig igen efter sparande fyll i nytt för att byta värde.
</p>
</div>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp
variant="sekundar"
onClick={() => {
setOppen(lev.id);
setFel("");
// Icke-hemliga värden förifylls så bara hemligheten
// behöver skrivas om.
const start: Record<string, string> = {};
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"}
</StorKnapp>
{finns && (
<StorKnapp
variant="fara"
onClick={async () => {
await taBortIntegration(lev.id);
ladda();
}}
>
Ta bort
</StorKnapp>
)}
</div>
)}
</div>
);
})}
{sparat && <p className="mt-2 text-[12px] font-semibold text-[#1E6B34]">Uppgifter sparade för {sparat}.</p>}
</Panel>
);
}
export default function Installningar() {
const konto = plattformKonto();
const inloggad = plattformAktiv() && !!konto;
@@ -121,6 +269,8 @@ export default function Installningar() {
/>
</Panel>
{inloggad && <Integrationer />}
{status === "fel" && <p className="mb-3 font-semibold text-[#8B1A1A]">{felText}</p>}
{status === "sparat" && <p className="mb-3 font-semibold text-[#1E6B34]"> Sparat</p>}
<StorKnapp disabled={status === "sparar"} onClick={spara}>
+18 -13
View File
@@ -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: () => {},
}),
});
}