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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 på
|
||||
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 på 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
@@ -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: () => {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user