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
+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: () => {},
}),
});
}