fix(admin): serverseitige authTyp-Pruefung beim Reset + Merge-PK-Kollision

resetUserPassword: laedt das Konto in der Transaktion und bricht bei
authTyp !== "local" ab (kein Hash, kein user.reset-Audit). Damit wird die
dokumentierte Invariante "nur lokale Wehr-Benutzer zuruecksetzen" auch
serverseitig erzwungen, nicht nur im UI. resetBrigadeUserPassword faengt
den Fehler als { ok: false, error } ab.

mergeMerkmal: loest PK-Kollisionen in vehicle_template_merkmale auf, indem
proposed-Zeilen geloescht werden, wenn das Ziel-Merkmal in derselben Vorlage
bereits existiert (zusammengesetzter PK template_id, merkmal_id). Das gesamte
Umhaengen ist zudem in try/catch gekapselt und liefert bei Fehlern eine klare
{ ok: false }-Meldung - analog zu promoteMerkmal.

Neue Unit-Tests (db/tx gemockt, kein Postgres noetig) decken beide Pfade ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 10:42:37 +02:00
parent e97e16d254
commit 628d35bfcd
5 changed files with 311 additions and 23 deletions

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks ---------------------------------------------------------------
const PROPOSED = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const ZIEL = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
// Geteilter, veraenderbarer Zustand. vi.hoisted laeuft vor den (gehoisteten)
// vi.mock-Factories, sodass diese den State sicher referenzieren koennen.
const state = vi.hoisted(() => ({
// merkmale-Zeilen, die der Top-Level db.select() liefert.
merkmaleRows: [] as Array<{ id: string; typ: string; status: string }>,
// Reihenfolge der tx.select()-Ergebnisse fuer vehicle_template_merkmale:
// [0] = proposed-Templates, [1] = ziel-Templates.
vtmSelectQueue: [] as Array<Array<{ templateId: string }>>,
ops: [] as { type: string; table: string; vals?: unknown }[],
}));
function tableName(arg: unknown): string {
return (arg as { __name?: string })?.__name ?? "unknown";
}
vi.mock("@/db/schema", () => ({
merkmale: { __name: "merkmale" },
merkmalValues: { __name: "merkmal_values" },
vehicleTemplateMerkmale: { __name: "vehicle_template_merkmale" },
}));
function makeTx() {
return {
select: () => ({
from: () => ({
where: () => Promise.resolve(state.vtmSelectQueue.shift() ?? []),
}),
}),
update: (table: unknown) => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
state.ops.push({ type: "update", table: tableName(table), vals });
return Promise.resolve(undefined);
},
}),
}),
delete: (table: unknown) => ({
where: () => {
state.ops.push({ type: "delete", table: tableName(table) });
return Promise.resolve(undefined);
},
}),
};
}
vi.mock("@/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(state.merkmaleRows),
}),
transaction: (cb: (tx: ReturnType<typeof makeTx>) => Promise<unknown>) =>
cb(makeTx()),
},
}));
vi.mock("@/lib/auth/guards", () => ({
requirePlatformAdmin: () =>
Promise.resolve({ user: { id: "actor-1" } }),
}));
vi.mock("@/lib/audit", () => ({
writeAudit: () => Promise.resolve(),
}));
vi.mock("next/cache", () => ({
revalidatePath: () => undefined,
}));
// drizzle-orm Helfer (eq/and/inArray) muessen echte Aufrufe ueberstehen.
vi.mock("drizzle-orm", () => ({
eq: (...a: unknown[]) => ({ op: "eq", a }),
and: (...a: unknown[]) => ({ op: "and", a }),
inArray: (...a: unknown[]) => ({ op: "inArray", a }),
}));
// -------------------------------------------------------------------------
import { mergeMerkmal } from "@/app/(admin)/_actions/proposals";
describe("mergeMerkmal", () => {
beforeEach(() => {
state.ops.length = 0;
state.vtmSelectQueue = [];
state.merkmaleRows = [
{ id: PROPOSED, typ: "number", status: "proposed" },
{ id: ZIEL, typ: "number", status: "active" },
];
});
it("lehnt unterschiedliche Typen ab", async () => {
state.merkmaleRows = [
{ id: PROPOSED, typ: "boolean", status: "proposed" },
{ id: ZIEL, typ: "number", status: "active" },
];
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
expect(res.ok).toBe(false);
});
it("haengt ohne Kollision alle vtm-Zeilen um (kein Delete der proposed-vtm)", async () => {
// proposed in Template T1, Ziel in keinem -> keine Kollision.
state.vtmSelectQueue = [[{ templateId: "T1" }], []];
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
expect(res.ok).toBe(true);
// Es darf kein Kollisions-Delete auf vtm geben, nur das finale
// merkmale-Delete.
const vtmDeletes = state.ops.filter(
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
);
expect(vtmDeletes).toHaveLength(0);
const vtmUpdates = state.ops.filter(
(o) => o.type === "update" && o.table === "vehicle_template_merkmale",
);
expect(vtmUpdates).toHaveLength(1);
expect(
state.ops.some((o) => o.type === "delete" && o.table === "merkmale"),
).toBe(true);
});
it("loescht kollidierende proposed-vtm-Zeilen vor dem Umhaengen", async () => {
// proposed und Ziel teilen sich Template T1 -> Kollision auf PK.
state.vtmSelectQueue = [[{ templateId: "T1" }], [{ templateId: "T1" }]];
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
expect(res.ok).toBe(true);
const vtmDeletes = state.ops.filter(
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
);
expect(vtmDeletes).toHaveLength(1);
// Delete des Kollisions-Eintrags vor dem Umhaengen.
const deleteIdx = state.ops.findIndex(
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
);
const updateIdx = state.ops.findIndex(
(o) => o.type === "update" && o.table === "vehicle_template_merkmale",
);
expect(deleteIdx).toBeLessThan(updateIdx);
});
});

View File

@@ -50,7 +50,17 @@ export async function resetBrigadeUserPassword(
const s = await requirePlatformAdmin(); const s = await requirePlatformAdmin();
const p = userResetSchema.safeParse(input); const p = userResetSchema.safeParse(input);
if (!p.success) return { ok: false, error: "Ungültige ID." }; if (!p.success) return { ok: false, error: "Ungültige ID." };
try {
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id); const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
revalidatePath("/admin/wehren"); revalidatePath("/admin/wehren");
return { ok: true, tempPassword }; return { ok: true, tempPassword };
} catch (e) {
return {
ok: false,
error:
e instanceof Error
? e.message
: "Passwort konnte nicht zurückgesetzt werden.",
};
}
} }

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { db } from "@/db"; import { db } from "@/db";
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema"; import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
@@ -80,7 +80,40 @@ export async function mergeMerkmal(input: {
}; };
} }
try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
// vehicle_template_merkmale hat den zusammengesetzten PK
// (template_id, merkmal_id). Hat eine Vorlage bereits sowohl das
// vorgeschlagene als auch das Ziel-Merkmal, würde ein pauschales
// Umhängen den PK verletzen. Solche kollidierenden Proposed-Zeilen
// werden daher gelöscht statt umgehängt.
const proposedVtm = await tx
.select({ templateId: vehicleTemplateMerkmale.templateId })
.from(vehicleTemplateMerkmale)
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
const zielVtm = await tx
.select({ templateId: vehicleTemplateMerkmale.templateId })
.from(vehicleTemplateMerkmale)
.where(eq(vehicleTemplateMerkmale.merkmalId, ziel.data));
const zielTemplateIds = new Set(zielVtm.map((r) => r.templateId));
const collidingTemplateIds = proposedVtm
.map((r) => r.templateId)
.filter((id) => zielTemplateIds.has(id));
if (collidingTemplateIds.length > 0) {
await tx
.delete(vehicleTemplateMerkmale)
.where(
and(
eq(vehicleTemplateMerkmale.merkmalId, proposed.data),
inArray(
vehicleTemplateMerkmale.templateId,
collidingTemplateIds,
),
),
);
}
await tx await tx
.update(merkmalValues) .update(merkmalValues)
.set({ merkmalId: ziel.data }) .set({ merkmalId: ziel.data })
@@ -99,6 +132,12 @@ export async function mergeMerkmal(input: {
tx, tx,
); );
}); });
} catch {
return {
ok: false,
error: "Zusammenführen fehlgeschlagen. Bitte erneut versuchen.",
};
}
revalidatePath("/admin/merkmale/proposals"); revalidatePath("/admin/merkmale/proposals");
return { ok: true }; return { ok: true };

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks ---------------------------------------------------------------
const auditCalls: unknown[][] = [];
const updateSetCalls: Record<string, unknown>[] = [];
// Steuert, welchen authTyp das geladene Konto hat (oder kein Treffer).
let selectResult: Array<{ authTyp: "local" | "authentik" }> = [];
function makeTx() {
return {
select: () => ({
from: () => ({
where: () => Promise.resolve(selectResult),
}),
}),
update: () => ({
set: (vals: Record<string, unknown>) => {
updateSetCalls.push(vals);
return { where: () => Promise.resolve(undefined) };
},
}),
};
}
vi.mock("@/db", () => ({
db: {
transaction: (cb: (tx: ReturnType<typeof makeTx>) => Promise<unknown>) =>
cb(makeTx()),
},
}));
vi.mock("@/lib/auth/password", () => ({
hashPassword: (_pw: string) => Promise.resolve("HASHED"),
}));
vi.mock("@/lib/audit", () => ({
writeAudit: (...args: unknown[]) => {
auditCalls.push(args);
return Promise.resolve();
},
}));
vi.mock("@/lib/geo/nominatim", () => ({
geocodeAddress: () => Promise.resolve({ status: "fail" }),
}));
// -------------------------------------------------------------------------
import { resetUserPassword } from "@/lib/admin/provisioning";
const USER = "11111111-1111-1111-1111-111111111111";
const ACTOR = "22222222-2222-2222-2222-222222222222";
describe("resetUserPassword", () => {
beforeEach(() => {
auditCalls.length = 0;
updateSetCalls.length = 0;
selectResult = [];
});
it("setzt das Passwort fuer ein lokales Konto zurueck und schreibt Audit", async () => {
selectResult = [{ authTyp: "local" }];
const res = await resetUserPassword(USER, ACTOR);
expect(typeof res.tempPassword).toBe("string");
expect(res.tempPassword.length).toBeGreaterThan(0);
expect(updateSetCalls).toEqual([{ passwortHash: "HASHED" }]);
expect(auditCalls).toHaveLength(1);
expect(auditCalls[0]?.[1]).toBe("user.reset");
});
it("bricht fuer Authentik-Konten ab: kein Hash, kein Audit", async () => {
selectResult = [{ authTyp: "authentik" }];
await expect(resetUserPassword(USER, ACTOR)).rejects.toThrow(
/lokale Konten/i,
);
expect(updateSetCalls).toHaveLength(0);
expect(auditCalls).toHaveLength(0);
});
it("bricht ab, wenn das Konto nicht existiert", async () => {
selectResult = [];
await expect(resetUserPassword(USER, ACTOR)).rejects.toThrow();
expect(updateSetCalls).toHaveLength(0);
expect(auditCalls).toHaveLength(0);
});
});

View File

@@ -116,6 +116,13 @@ export async function resetUserPassword(
const temp = generateTempPassword(); const temp = generateTempPassword();
const hash = await hashPassword(temp); const hash = await hashPassword(temp);
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [u] = await tx
.select({ authTyp: users.authTyp })
.from(users)
.where(eq(users.id, userId));
if (!u || u.authTyp !== "local") {
throw new Error("Nur lokale Konten können zurückgesetzt werden.");
}
await tx await tx
.update(users) .update(users)
.set({ passwortHash: hash }) .set({ passwortHash: hash })