diff --git a/apps/api/src/tenant/tenant.module.ts b/apps/api/src/tenant/tenant.module.ts index 094a6ee..3f63e12 100644 --- a/apps/api/src/tenant/tenant.module.ts +++ b/apps/api/src/tenant/tenant.module.ts @@ -1,8 +1,16 @@ import { Module } from '@nestjs/common'; +import { GroupsModule } from '../groups/groups.module'; import { TenantController } from './tenant.controller'; import { TenantService } from './tenant.service'; +/** + * Importiert GroupsModule für TenantService.create's Standardgruppen- + * Anlage (D-06/D-13). GroupsModule importiert seinerseits nichts, + * deshalb entsteht keine Zirkularität — UserModule bindet GroupsModule + * bereits nach demselben Muster ein. + */ @Module({ + imports: [GroupsModule], controllers: [TenantController], providers: [TenantService], exports: [TenantService], diff --git a/apps/api/src/tenant/tenant.service.spec.ts b/apps/api/src/tenant/tenant.service.spec.ts new file mode 100644 index 0000000..0c77154 --- /dev/null +++ b/apps/api/src/tenant/tenant.service.spec.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TenantService } from './tenant.service'; + +/** + * TenantService.create — Standardgruppen-Anlage (D-06/D-13, quick-260805-fok). + * + * TenantService.create ist neben AdminSeedService.seedAdmin der zweite + * Mandanten-Entstehungspfad. Diese Tests decken ausschließlich die neue + * ensureDefaultGroup-Anbindung ab, mit gemocktem PrismaService und + * gemocktem GroupsService — im Stil von user.service.spec.ts. + */ +describe('TenantService.create — Standardgruppen-Anlage (D-06/D-13)', () => { + let prisma: any; + let groupsService: any; + let service: TenantService; + + const createdTenant = { id: 't1', name: 'Acme', slug: 'acme' }; + const baseData = { name: 'Acme', slug: 'acme' }; + + beforeEach(() => { + vi.clearAllMocks(); + prisma = { + tenant: { + create: vi.fn().mockResolvedValue(createdTenant), + }, + }; + groupsService = { + ensureDefaultGroup: vi.fn().mockResolvedValue({ id: 'g1' }), + }; + service = new TenantService(prisma, groupsService); + }); + + it('ruft nach der Mandanten-Anlage genau einmal ensureDefaultGroup mit der id des neu angelegten Mandanten auf; der Rueckgabewert bleibt der Mandant', async () => { + const result = await service.create(baseData); + + expect(result).toEqual(createdTenant); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(1); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1'); + }); + + it('gibt den Mandanten trotz werfendem ensureDefaultGroup zurueck — der Fehler wird protokolliert, nicht propagiert', async () => { + groupsService.ensureDefaultGroup.mockRejectedValue(new Error('boom')); + + await expect(service.create(baseData)).resolves.toEqual(createdTenant); + expect(prisma.tenant.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/tenant/tenant.service.ts b/apps/api/src/tenant/tenant.service.ts index a19819f..cb3d821 100644 --- a/apps/api/src/tenant/tenant.service.ts +++ b/apps/api/src/tenant/tenant.service.ts @@ -1,9 +1,15 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { GroupsService } from '../groups/groups.service'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class TenantService { - constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(TenantService.name); + + constructor( + private prisma: PrismaService, + private readonly groupsService: GroupsService, + ) {} async findAll() { return this.prisma.tenant.findMany(); @@ -13,8 +19,30 @@ export class TenantService { return this.prisma.tenant.findUnique({ where: { id } }); } + /** + * Legt einen Mandanten an und stellt unmittelbar danach dessen + * Standardgruppe her (D-06/D-13), damit ein frisch angelegter Mandant + * ab dem ersten Benutzer nutzbar ist. Schlägt ensureDefaultGroup fehl, + * wird der Fehler protokolliert statt propagiert — die Mandanten-Anlage + * selbst war erfolgreich, und die Startup-Reparatur in + * AdminSeedService.ensureDefaultGroupsForAllTenants holt eine + * gescheiterte Gruppenanlage beim nächsten API-Start nach (Muster aus + * UserService.create). + */ async create(data: { name: string; slug: string }) { - return this.prisma.tenant.create({ data }); + const tenant = await this.prisma.tenant.create({ data }); + + try { + await this.groupsService.ensureDefaultGroup(tenant.id); + } catch (err) { + this.logger.error( + `Standardgruppen-Anlage fehlgeschlagen für Mandant '${tenant.id}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + return tenant; } async update(id: string, data: { name?: string; slug?: string; isActive?: boolean }) { diff --git a/apps/api/src/user/admin-seed.service.spec.ts b/apps/api/src/user/admin-seed.service.spec.ts new file mode 100644 index 0000000..ff680e9 --- /dev/null +++ b/apps/api/src/user/admin-seed.service.spec.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminSeedService } from './admin-seed.service'; + +/** + * AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage und + * Startup-Reparatur (quick-260805-fok). + * + * Deckt ab: seedAdmin() ruft ensureDefaultGroup NACH tenant.upsert und VOR + * user.create auf; beide frühen Rückkehrpfade (fehlende ENV, Admin existiert + * bereits) überspringen den Benutzer, lassen aber die Reparatur über ALLE + * Mandanten laufen; die Reparatur ist fehlerisoliert je Mandant; ein zweiter + * Bootstrap-Lauf löst keinen zusätzlichen user.create aus. + * + * Die Reihenfolgeprüfung läuft über ein gemeinsames Aufruf-Log-Array, in das + * jeder Mock beim Aufruf seinen Namen schiebt (nicht über bloße + * Aufrufzähler) — Mock-invocationCallOrder ist über drei unabhängige vi.fn's + * (tenant.upsert / ensureDefaultGroup / user.create) weniger lesbar als ein + * geteiltes Log. + */ +describe('AdminSeedService', () => { + let prisma: any; + let configService: any; + let groupsService: any; + let callLog: string[]; + let service: AdminSeedService; + + const tenant = { id: 't-default', slug: 'default' }; + const envValues: Record = { + TESSERA_ADMIN_USER: 'admin', + TESSERA_ADMIN_EMAIL: 'admin@example.com', + TESSERA_ADMIN_PASSWORD: 'secret', + }; + + beforeEach(() => { + vi.clearAllMocks(); + callLog = []; + + configService = { + get: vi.fn((key: string) => envValues[key]), + }; + + prisma = { + user: { + findUnique: vi.fn(async () => { + callLog.push('user.findUnique'); + return null; + }), + create: vi.fn(async () => { + callLog.push('user.create'); + return { id: 'u1' }; + }), + }, + tenant: { + upsert: vi.fn(async () => { + callLog.push('tenant.upsert'); + return tenant; + }), + findMany: vi.fn(async () => { + callLog.push('tenant.findMany'); + return [tenant]; + }), + }, + }; + + groupsService = { + ensureDefaultGroup: vi.fn(async (tenantId: string) => { + callLog.push(`ensureDefaultGroup:${tenantId}`); + return { id: 'g1' }; + }), + }; + + service = new AdminSeedService(prisma, configService, groupsService); + }); + + it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => { + await service.onApplicationBootstrap(); + + const seedOrder = callLog.filter( + (c) => c === 'tenant.upsert' || c === 'user.create', + ); + expect(seedOrder).toEqual(['tenant.upsert', 'user.create']); + + const upsertIdx = callLog.indexOf('tenant.upsert'); + const ensureIdx = callLog.indexOf(`ensureDefaultGroup:${tenant.id}`); + const createIdx = callLog.indexOf('user.create'); + expect(upsertIdx).toBeLessThan(ensureIdx); + expect(ensureIdx).toBeLessThan(createIdx); + }); + + it('fehlen die ENV-Variablen, wird kein Benutzer angelegt, die Reparatur ueber alle Mandanten laeuft aber trotzdem', async () => { + configService.get = vi.fn(() => undefined); + + await service.onApplicationBootstrap(); + + expect(prisma.tenant.upsert).not.toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); + }); + + it('existiert der Admin-Benutzer bereits, wird kein Benutzer angelegt, die Reparatur ueber alle Mandanten laeuft aber trotzdem', async () => { + prisma.user.findUnique = vi.fn(async () => ({ id: 'existing-admin' })); + + await service.onApplicationBootstrap(); + + expect(prisma.tenant.upsert).not.toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); + }); + + it('die Reparatur ruft ensureDefaultGroup fuer JEDEN von tenant.findMany gelieferten Mandanten auf, auch fuer nicht-default-Mandanten', async () => { + prisma.user.findUnique = vi.fn(async () => ({ id: 'existing-admin' })); + const otherTenant = { id: 't-other', slug: 'other' }; + prisma.tenant.findMany = vi.fn(async () => [tenant, otherTenant]); + + await service.onApplicationBootstrap(); + + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(otherTenant.id); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2); + }); + + it('wirft ensureDefaultGroup fuer einen Mandanten, werden die uebrigen Mandanten trotzdem abgearbeitet und onApplicationBootstrap wirft nicht', async () => { + prisma.user.findUnique = vi.fn(async () => ({ id: 'existing-admin' })); + const failing = { id: 't-fail', slug: 'fail' }; + const ok = { id: 't-ok', slug: 'ok' }; + prisma.tenant.findMany = vi.fn(async () => [failing, ok]); + groupsService.ensureDefaultGroup = vi.fn(async (tenantId: string) => { + if (tenantId === failing.id) { + throw new Error('boom'); + } + return { id: 'g-ok' }; + }); + + await expect(service.onApplicationBootstrap()).resolves.not.toThrow(); + + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(failing.id); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(ok.id); + }); + + it('ein zweiter onApplicationBootstrap-Lauf ruft ensureDefaultGroup erneut auf, loest aber keinen zusaetzlichen user.create aus', async () => { + await service.onApplicationBootstrap(); + expect(prisma.user.create).toHaveBeenCalledTimes(1); + // Erster Lauf: seedAdmin() ruft ensureDefaultGroup einmal vor + // user.create auf, die Reparatur einmal danach fuer denselben Mandanten. + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2); + + // Zweiter Lauf: Admin existiert jetzt bereits (Neustart-Semantik), also + // ruft nur noch die Reparatur ensureDefaultGroup auf — kein weiterer + // user.create. + prisma.user.findUnique = vi.fn(async () => ({ id: 'u1' })); + await service.onApplicationBootstrap(); + + expect(prisma.user.create).toHaveBeenCalledTimes(1); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/api/src/user/admin-seed.service.ts b/apps/api/src/user/admin-seed.service.ts index 0b1242e..caf19dd 100644 --- a/apps/api/src/user/admin-seed.service.ts +++ b/apps/api/src/user/admin-seed.service.ts @@ -1,11 +1,27 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; +import { GroupsService } from '../groups/groups.service'; import { PrismaService } from '../prisma/prisma.service'; /** * Creates the initial Super-Admin account from Docker ENV variables on first boot. * Per D-05, D-07, D-13. + * + * onApplicationBootstrap läuft in genau zwei sequenziellen await-Schritten: + * erst seedAdmin(), danach ensureDefaultGroupsForAllTenants(). Diese + * sequenzielle Reihenfolge — nicht Nests Hook-Reihenfolge über Module + * hinweg — ist der Ordering-Garant. Die Reparatur sitzt bewusst NICHT als + * eigener onApplicationBootstrap-Hook in GroupsModule: GroupsModule ist + * eine Dependency von UserModule, seine eigenen Hooks liefen deshalb VOR + * dem Admin-Seed und würden auf einer frischen Installation den noch gar + * nicht existierenden Default-Mandanten übergehen — dieselbe Falle, die + * tenders/tender-scheduler.service.ts für den DÖE-Poll-Config-Seed + * ausführlich dokumentiert (onModuleInit-Reihenfolge zwischen Modulen ist + * unspezifiziert, onApplicationBootstrap läuft garantiert nach jedem + * onModuleInit). seedAdmin() bleibt bewusst UNgekapselt: schlägt der + * Admin-Seed fehl, soll der Start weiterhin laut scheitern — bestehendes + * Verhalten, hier nicht aufgeweicht. */ @Injectable() export class AdminSeedService implements OnApplicationBootstrap { @@ -14,9 +30,15 @@ export class AdminSeedService implements OnApplicationBootstrap { constructor( private prisma: PrismaService, private configService: ConfigService, + private readonly groupsService: GroupsService, ) {} async onApplicationBootstrap() { + await this.seedAdmin(); + await this.ensureDefaultGroupsForAllTenants(); + } + + private async seedAdmin() { const username = this.configService .get('TESSERA_ADMIN_USER') ?.toLowerCase(); @@ -49,6 +71,12 @@ export class AdminSeedService implements OnApplicationBootstrap { create: { name: 'Default', slug: 'default' }, }); + // Ensure the default group exists BEFORE the Super-Admin user is + // created, so user.create's addUserToDefaultGroup path (D-11/D-12) + // finds a marked default group to join instead of relying on the + // repair below to backfill the membership afterwards. + await this.groupsService.ensureDefaultGroup(tenant.id); + // Create Super-Admin user const passwordHash = await argon2.hash(password); await this.prisma.user.create({ @@ -67,4 +95,50 @@ export class AdminSeedService implements OnApplicationBootstrap { `Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`, ); } + + /** + * One-time startup repair for installations that already exist without a + * default group — e.g. a fresh database where migration + * 20260804130130_add_groups_and_module_grants' backfill ran before any + * tenant existed, or an install whose admin already exists so seedAdmin() + * returned early without ever touching a tenant. Runs per-tenant in its + * own try/catch and is additionally wrapped as a whole, so a failing + * tenant.findMany or a single tenant's ensureDefaultGroup call can never + * block the API from starting. + */ + private async ensureDefaultGroupsForAllTenants() { + try { + const tenants = await this.prisma.tenant.findMany({ + select: { id: true, slug: true }, + }); + + let repaired = 0; + for (const tenant of tenants) { + try { + const group = await this.groupsService.ensureDefaultGroup(tenant.id); + if (group !== null) { + repaired += 1; + } + } catch (err) { + this.logger.error( + `Default-group repair failed for tenant "${tenant.slug}": ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + if (repaired > 0) { + this.logger.warn( + `Default-group repair created a default group for ${repaired} tenant(s) that had none`, + ); + } + } catch (err) { + this.logger.error( + `Default-group repair could not load tenants: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } }