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:
144
src/app/(admin)/_actions/__tests__/proposals.test.ts
Normal file
144
src/app/(admin)/_actions/__tests__/proposals.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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." };
|
||||||
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
|
try {
|
||||||
revalidatePath("/admin/wehren");
|
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
|
||||||
return { ok: true, tempPassword };
|
revalidatePath("/admin/wehren");
|
||||||
|
return { ok: true, tempPassword };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: "Passwort konnte nicht zurückgesetzt werden.",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 +80,64 @@ export async function mergeMerkmal(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
await tx
|
await db.transaction(async (tx) => {
|
||||||
.update(merkmalValues)
|
// vehicle_template_merkmale hat den zusammengesetzten PK
|
||||||
.set({ merkmalId: ziel.data })
|
// (template_id, merkmal_id). Hat eine Vorlage bereits sowohl das
|
||||||
.where(eq(merkmalValues.merkmalId, proposed.data));
|
// vorgeschlagene als auch das Ziel-Merkmal, würde ein pauschales
|
||||||
await tx
|
// Umhängen den PK verletzen. Solche kollidierenden Proposed-Zeilen
|
||||||
.update(vehicleTemplateMerkmale)
|
// werden daher gelöscht statt umgehängt.
|
||||||
.set({ merkmalId: ziel.data })
|
const proposedVtm = await tx
|
||||||
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
|
.select({ templateId: vehicleTemplateMerkmale.templateId })
|
||||||
await tx.delete(merkmale).where(eq(merkmale.id, proposed.data));
|
.from(vehicleTemplateMerkmale)
|
||||||
await writeAudit(
|
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
|
||||||
s.user.id,
|
const zielVtm = await tx
|
||||||
"merkmal.merge",
|
.select({ templateId: vehicleTemplateMerkmale.templateId })
|
||||||
"merkmal",
|
.from(vehicleTemplateMerkmale)
|
||||||
ziel.data,
|
.where(eq(vehicleTemplateMerkmale.merkmalId, ziel.data));
|
||||||
{ merged: proposed.data },
|
const zielTemplateIds = new Set(zielVtm.map((r) => r.templateId));
|
||||||
tx,
|
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
|
||||||
|
.update(merkmalValues)
|
||||||
|
.set({ merkmalId: ziel.data })
|
||||||
|
.where(eq(merkmalValues.merkmalId, proposed.data));
|
||||||
|
await tx
|
||||||
|
.update(vehicleTemplateMerkmale)
|
||||||
|
.set({ merkmalId: ziel.data })
|
||||||
|
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
|
||||||
|
await tx.delete(merkmale).where(eq(merkmale.id, proposed.data));
|
||||||
|
await writeAudit(
|
||||||
|
s.user.id,
|
||||||
|
"merkmal.merge",
|
||||||
|
"merkmal",
|
||||||
|
ziel.data,
|
||||||
|
{ merged: proposed.data },
|
||||||
|
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 };
|
||||||
|
|||||||
88
src/lib/admin/__tests__/provisioning.test.ts
Normal file
88
src/lib/admin/__tests__/provisioning.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user