leistungsbilanz-ts/src/db/repositories/external-initial-import-project-command.repository.ts
Grovy311 a7a1595cbf Add test and security baseline
- make quick/check/mutation from cj/security-baseline (Makefile unchanged,
  settings in baseline.mk), pre-commit, gitleaks, Semgrep rules, ESLint
  security rules, Forgejo workflow
- tests/security: generic baseline tests plus app config and role tests
  against createApp() with an in-memory database
- extract createApp() from src/server/index.ts; LB_DATABASE_PATH for tests
- fix gaps found by the tests: reject unknown fields on project create,
  answer malformed JSON with 400 instead of 500, set nosniff on the API
- npm audit fix (next 16.4.1 and others, no high/critical left)
- clear ESLint errors, safeFileName without control-character regex
- per-project ownership stays an open, documented gap (idorGap -> TODO)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:40:33 +02:00

88 lines
7.6 KiB
TypeScript

import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import {
assertExternalInitialImportProjectCommand,
createExternalInitialImportProjectCommand,
isEmptyExternalModelState,
} from "../../domain/models/external-initial-import-project-command.model.js";
import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
import { insertManyInBatches } from "./batched-insert.persistence.js";
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
assertExternalInitialImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId);
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
const payload = input.command.payload;
if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
}
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
replaceConfiguration(tx, input.projectId, payload.targetConfiguration);
replaceState(tx, input.projectId, payload.targetState);
const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state;
const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
});
}
}
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) {
if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt.");
const batch = state.importBatches[0];
if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig.");
if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig.");
const bytes = decodeCanonicalBase64(batch.originalContentBase64!);
if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig.");
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id));
for (const mapping of state.roomMappings) {
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt.");
if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt.");
}
for (const object of state.objects) {
if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart.");
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler.");
if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät.");
}
}
function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) {
database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run();
if (target) database.insert(externalModelConfigurations).values(target).run();
}
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) {
database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run();
if (!target.source) return;
database.insert(externalModelSources).values(target.source).run();
insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") })));
insertManyInBatches(database, externalRoomMappings, target.roomMappings);
insertManyInBatches(database, externalModelObjects, target.objects);
}
function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }