feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s

- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren
- Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze
- Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix)
- Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt
- Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand)
- Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten
- Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck
- Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
Nick Szaunig committed 2026-08-31 18:56:08 +02:00
1 parent 0a52b29c57
commit 8432313fa9
83 files changed
+2684 -289

No files matched your search

+9
View File
@@ -19,3 +19,12 @@ COOKIE_SECURE=false
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
DOCUMENTS_STORAGE_PATH="./uploads"
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
OIDC_CLIENT_ID="change-me-entra-client-id"
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
+32
View File
@@ -24,6 +24,7 @@
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -8681,6 +8682,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.10",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz",
"integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
@@ -9648,6 +9658,15 @@
"node": ">=8"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz",
"integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9712,6 +9731,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openid-client": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.2",
"oauth4webapi": "^3.8.5"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+2 -1
View File
@@ -38,6 +38,7 @@
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -90,7 +91,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble)/)"
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
@@ -0,0 +1,4 @@
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SWARM_MOOD_HIGH';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_HEALTH_CRITICAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'STOCK_ITEM_LOW';
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "LabelTemplate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT NOT NULL,
"widthMm" DOUBLE PRECISION NOT NULL DEFAULT 90,
"heightMm" DOUBLE PRECISION NOT NULL DEFAULT 35,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LabelTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LabelTemplate_active_idx" ON "LabelTemplate"("active");
+19
View File
@@ -853,6 +853,22 @@ model Product {
@@index([status])
}
// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den
// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf).
model LabelTemplate {
id String @id @default(cuid())
name String
content String
widthMm Float @default(90)
heightMm Float @default(35)
isDefault Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active])
}
model BottlingBatch {
id String @id @default(cuid())
honeyBatchId String
@@ -981,6 +997,9 @@ enum AutomationTriggerType {
QUEENLESS_NO_EGGS
VARROA_THRESHOLD
FEED_STATUS_CRITICAL
SWARM_MOOD_HIGH
COLONY_HEALTH_CRITICAL
STOCK_ITEM_LOW
}
model AutomationRule {
+2
View File
@@ -33,6 +33,7 @@ import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module';
import { LabelTemplatesModule } from './label-templates/label-templates.module';
import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
@@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
WebhooksModule,
DocumentsModule,
AutomationModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
TaskTemplatesModule,
+11 -38
View File
@@ -7,7 +7,6 @@ import {
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
@@ -19,49 +18,23 @@ import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { MfaVerifyDto } from './dto/mfa-verify.dto';
import { AuthService, CurrentUserProfile } from './auth.service';
import { parseDurationToMs } from './util/duration';
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
import {
clearRefreshTokenCookie,
extractRefreshToken,
setRefreshTokenCookie,
} from './util/refresh-token-cookie';
interface MfaRequiredResponse {
mfaRequired: true;
mfaToken: string;
}
const REFRESH_TOKEN_COOKIE = 'refreshToken';
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
httpOnly: true,
// Explicit opt-out for local HTTP dev only; every real deployment must
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
// over plaintext HTTP.
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax',
path: REFRESH_TOKEN_COOKIE_PATH,
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
});
}
private clearRefreshTokenCookie(res: Response): void {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
}
private extractRefreshToken(req: Request): string {
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
REFRESH_TOKEN_COOKIE
];
if (!refreshToken) {
throw new UnauthorizedException('Missing refresh token');
}
return refreshToken;
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('login')
@@ -74,7 +47,7 @@ export class AuthController {
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
this.setRefreshTokenCookie(res, result.refreshToken);
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
@@ -88,7 +61,7 @@ export class AuthController {
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } =
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
this.setRefreshTokenCookie(res, refreshToken);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@@ -101,9 +74,9 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.refresh(
this.extractRefreshToken(req),
extractRefreshToken(req),
);
this.setRefreshTokenCookie(res, refreshToken);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@@ -114,8 +87,8 @@ export class AuthController {
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
await this.authService.logout(extractRefreshToken(req));
clearRefreshTokenCookie(res);
}
@ApiBearerAuth()
+10 -2
View File
@@ -6,10 +6,18 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { MfaModule } from './mfa/mfa.module';
import { SsoModule } from './sso/sso.module';
import { SsoController } from './sso/sso.controller';
@Module({
imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule],
controllers: [AuthController],
imports: [
PassportModule,
JwtModule.register({}),
UsersModule,
MfaModule,
SsoModule,
],
controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
@@ -229,6 +229,33 @@ describe('AuthService', () => {
});
});
describe('issueTokensForSsoUser', () => {
it('issues tokens and records LOGIN for an active user', async () => {
usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.issueTokensForSsoUser(activeUser.id);
expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.create).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
);
});
it('throws when the user is deactivated', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
isActive: false,
});
await expect(
authService.issueTokensForSsoUser(activeUser.id),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
});
});
describe('refresh', () => {
it('throws when the refresh token is unknown', async () => {
prisma.refreshToken.findUnique.mockResolvedValue(null);
+20
View File
@@ -144,6 +144,26 @@ export class AuthService {
return { mfaRequired: false, accessToken, refreshToken };
}
/** Issues our own access/refresh token pair for a user already authenticated via SSO. */
async issueTokensForSsoUser(userId: string): Promise<AuthTokens> {
const user = await this.usersService.findById(userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
const accessToken = this.signAccessToken({
id: user.id,
email: user.email,
});
const refreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { accessToken, refreshToken };
}
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
const user = await this.usersService.findById(userId);
@@ -194,6 +194,18 @@ export const PERMISSION_CATALOG = [
],
1600,
),
...group(
'Etiketten',
'label-templates',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['print', 'Drucken'],
],
1650,
),
...group(
'Inventar',
'equipment',
@@ -0,0 +1,158 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { AuthService } from '../auth.service';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier';
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
// Scoped to /complete only - the completion endpoint is the only place that
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
const SSO_CODE_COOKIE = 'oidcSsoCode';
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
interface PkceCookiePayload {
codeVerifier: string;
state: string;
}
@ApiTags('auth')
@Controller('auth/sso')
export class SsoController {
constructor(
private readonly ssoService: SsoService,
private readonly authService: AuthService,
) {}
private frontendLoginUrl(): string {
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
}
private cookieOptions(path: string, maxAge: number) {
return {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax' as const,
path,
maxAge,
};
}
@Public()
@Get('status')
status(): { enabled: boolean } {
return { enabled: this.ssoService.isConfigured() };
}
@Public()
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { redirectUrl, codeVerifier, state } =
await this.ssoService.buildAuthorizationRequest();
const payload: PkceCookiePayload = { codeVerifier, state };
res.cookie(
PKCE_COOKIE,
JSON.stringify(payload),
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
);
res.redirect(redirectUrl);
}
@Public()
@Get('callback')
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
PKCE_COOKIE
];
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH });
const pkce = this.parsePkceCookie(rawCookie);
if (!pkce) {
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
return;
}
const callbackUrl = new URL(
req.originalUrl,
`${req.protocol}://${req.get('host')}`,
);
const result = await this.ssoService.handleCallback(
callbackUrl,
pkce.codeVerifier,
pkce.state,
);
if (result.status === 'error') {
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
return;
}
// The one-time completion code never touches the URL (history, server
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
// to the /complete endpoint, which the frontend calls right after.
res.cookie(
SSO_CODE_COOKIE,
result.ssoCode,
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
);
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('complete')
@HttpCode(HttpStatus.OK)
async complete(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE
];
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH });
if (!ssoCode) {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
const { userId } = this.ssoService.verifySsoCode(ssoCode);
const { accessToken, refreshToken } =
await this.authService.issueTokensForSsoUser(userId);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
if (
typeof parsed.codeVerifier !== 'string' ||
typeof parsed.state !== 'string'
) {
return null;
}
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
} catch {
return null;
}
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '../../users/users.module';
import { SsoService } from './sso.service';
// SsoController lives in AuthModule (not here) because it also depends on
// AuthService, which only AuthModule provides - keeping the controller here
// would require a circular import between AuthModule and SsoModule.
@Module({
imports: [JwtModule.register({}), UsersModule],
providers: [SsoService],
exports: [SsoService],
})
export class SsoModule {}
@@ -0,0 +1,209 @@
import { JwtService } from '@nestjs/jwt';
import {
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import * as client from 'openid-client';
import { SsoService } from './sso.service';
import { UsersService } from '../../users/users.service';
jest.mock('openid-client', () => ({
discovery: jest.fn(),
randomPKCECodeVerifier: jest.fn(),
calculatePKCECodeChallenge: jest.fn(),
randomState: jest.fn(),
buildAuthorizationUrl: jest.fn(),
authorizationCodeGrant: jest.fn(),
}));
const mockedClient = client as jest.Mocked<typeof client>;
describe('SsoService', () => {
let ssoService: SsoService;
let usersService: { findByEmail: jest.Mock };
let jwtService: { sign: jest.Mock; verify: jest.Mock };
const originalEnv = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.OIDC_ISSUER_URL =
'https://login.microsoftonline.com/tenant/v2.0';
process.env.OIDC_CLIENT_ID = 'client-id';
process.env.OIDC_CLIENT_SECRET = 'client-secret';
process.env.OIDC_REDIRECT_URI =
'https://api.example.com/api/v1/auth/sso/callback';
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
usersService = { findByEmail: jest.fn() };
jwtService = { sign: jest.fn(), verify: jest.fn() };
ssoService = new SsoService(
usersService as unknown as UsersService,
jwtService as unknown as JwtService,
);
});
afterEach(() => {
process.env = { ...originalEnv };
});
describe('isConfigured', () => {
it('returns true when all four OIDC variables are set', () => {
expect(ssoService.isConfigured()).toBe(true);
});
it('returns false when a variable is missing', () => {
delete process.env.OIDC_CLIENT_SECRET;
expect(ssoService.isConfigured()).toBe(false);
});
});
describe('buildAuthorizationRequest', () => {
it('throws ServiceUnavailableException when SSO is not configured', async () => {
delete process.env.OIDC_ISSUER_URL;
await expect(ssoService.buildAuthorizationRequest()).rejects.toThrow(
ServiceUnavailableException,
);
});
it('discovers the issuer once and returns the authorization URL with a PKCE verifier', async () => {
const config = {} as client.Configuration;
mockedClient.discovery.mockResolvedValue(config);
mockedClient.randomPKCECodeVerifier.mockReturnValue('verifier');
mockedClient.calculatePKCECodeChallenge.mockResolvedValue('challenge');
mockedClient.randomState.mockReturnValue('random-state');
mockedClient.buildAuthorizationUrl.mockReturnValue(
new URL(
'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?x=1',
),
);
const result = await ssoService.buildAuthorizationRequest();
await ssoService.buildAuthorizationRequest();
expect(result.codeVerifier).toBe('verifier');
expect(result.state).toBe('random-state');
expect(result.redirectUrl).toContain('authorize');
expect(mockedClient.discovery).toHaveBeenCalledTimes(1);
expect(mockedClient.buildAuthorizationUrl).toHaveBeenCalledWith(
config,
expect.objectContaining({
redirect_uri: process.env.OIDC_REDIRECT_URI,
code_challenge: 'challenge',
code_challenge_method: 'S256',
state: 'random-state',
}),
);
});
});
describe('handleCallback', () => {
beforeEach(() => {
mockedClient.discovery.mockResolvedValue({} as client.Configuration);
});
it('returns exchange_failed when the token exchange throws', async () => {
mockedClient.authorizationCodeGrant.mockRejectedValue(
new Error('bad code'),
);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'exchange_failed' });
});
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ sub: 'abc' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_email' });
});
it('returns no_account when no active local user matches the email claim', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ email: '[email protected]' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
usersService.findByEmail.mockResolvedValue(null);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_account' });
});
it('signs a short-lived sso-complete token for an active matching user', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ preferred_username: '[email protected]' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
isActive: true,
});
jwtService.sign.mockReturnValue('signed-sso-code');
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
expect(mockedClient.authorizationCodeGrant).toHaveBeenCalledWith(
{},
expect.any(URL),
{ pkceCodeVerifier: 'verifier', expectedState: 'random-state' },
);
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
expect.objectContaining({ expiresIn: '60s' }),
);
});
});
describe('verifySsoCode', () => {
it('returns the userId for a valid sso-complete token', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'sso-complete',
});
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
});
it('throws UnauthorizedException when verification fails', () => {
jwtService.verify.mockImplementation(() => {
throw new Error('expired');
});
expect(() => ssoService.verifySsoCode('token')).toThrow(
UnauthorizedException,
);
});
it('throws UnauthorizedException when the token purpose does not match', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'mfa-challenge',
});
expect(() => ssoService.verifySsoCode('token')).toThrow(
UnauthorizedException,
);
});
});
});
+138
View File
@@ -0,0 +1,138 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as client from 'openid-client';
import { UsersService } from '../../users/users.service';
const SSO_COMPLETE_TOKEN_TTL = '60s';
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
interface SsoCompleteTokenPayload {
sub: string;
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
}
export type SsoCallbackResult =
| { status: 'success'; ssoCode: string }
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
@Injectable()
export class SsoService {
private readonly logger = new Logger(SsoService.name);
private config: client.Configuration | undefined;
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
isConfigured(): boolean {
return Boolean(
process.env.OIDC_ISSUER_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_REDIRECT_URI,
);
}
private async getConfig(): Promise<client.Configuration> {
if (!this.isConfigured()) {
throw new ServiceUnavailableException(
'Microsoft-Anmeldung ist nicht konfiguriert',
);
}
if (!this.config) {
this.config = await client.discovery(
new URL(process.env.OIDC_ISSUER_URL as string),
process.env.OIDC_CLIENT_ID as string,
process.env.OIDC_CLIENT_SECRET,
);
}
return this.config;
}
async buildAuthorizationRequest(): Promise<{
redirectUrl: string;
codeVerifier: string;
state: string;
}> {
const config = await this.getConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const redirectUrl = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.OIDC_REDIRECT_URI as string,
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
return { redirectUrl: redirectUrl.href, codeVerifier, state };
}
async handleCallback(
callbackUrl: URL,
codeVerifier: string,
state: string,
): Promise<SsoCallbackResult> {
const config = await this.getConfig();
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
try {
tokens = await client.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: codeVerifier,
expectedState: state,
});
} catch (error) {
this.logger.warn(
`SSO callback token exchange failed: ${(error as Error).message}`,
);
return { status: 'error', reason: 'exchange_failed' };
}
const claims = tokens.claims();
const email =
(claims?.email as string | undefined) ??
(claims?.preferred_username as string | undefined);
if (!email) {
this.logger.warn(
'SSO callback succeeded but ID token had no email claim',
);
return { status: 'error', reason: 'no_email' };
}
const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) {
this.logger.warn(
`SSO login denied: no active local account for ${email}`,
);
return { status: 'error', reason: 'no_account' };
}
const ssoCode = this.jwtService.sign(
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
{
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: SSO_COMPLETE_TOKEN_TTL,
},
);
return { status: 'success', ssoCode };
}
verifySsoCode(ssoCode: string): { userId: string } {
let payload: SsoCompleteTokenPayload;
try {
payload = this.jwtService.verify<SsoCompleteTokenPayload>(ssoCode, {
secret: process.env.JWT_ACCESS_SECRET,
});
} catch {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return { userId: payload.sub };
}
}
@@ -0,0 +1,36 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request, Response } from 'express';
import { parseDurationToMs } from './duration';
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
export function setRefreshTokenCookie(
res: Response,
refreshToken: string,
): void {
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
httpOnly: true,
// Explicit opt-out for local HTTP dev only; every real deployment must
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
// over plaintext HTTP.
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax',
path: REFRESH_TOKEN_COOKIE_PATH,
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
});
}
export function clearRefreshTokenCookie(res: Response): void {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
}
export function extractRefreshToken(req: Request): string {
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
REFRESH_TOKEN_COOKIE
];
if (!refreshToken) {
throw new UnauthorizedException('Missing refresh token');
}
return refreshToken;
}
@@ -70,7 +70,7 @@ describe('AutomationService', () => {
);
});
it('findAll seeds the three default rules before returning them', async () => {
it('findAll seeds all default rules before returning them', async () => {
prisma.automationRule.findMany.mockResolvedValue([
queenlessRule,
varroaRule,
@@ -79,7 +79,7 @@ describe('AutomationService', () => {
const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3);
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6);
expect(result).toHaveLength(3);
});
@@ -188,6 +188,106 @@ describe('AutomationService', () => {
});
});
describe('evaluateSwarmControlCheck', () => {
const swarmRule = {
id: 'rule-4',
triggerType: 'SWARM_MOOD_HIGH',
active: true,
config: {},
actionTemplate: 'Schwarmkontrolle dringend',
};
it('creates a task when swarm mood is HIGH', async () => {
prisma.automationRule.findUnique.mockResolvedValue(swarmRule);
await service.evaluateSwarmControlCheck({
id: 'check-1',
colonyId: 'colony-1',
swarmMood: 'HIGH',
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not trigger for a low swarm mood', async () => {
prisma.automationRule.findUnique.mockResolvedValue(swarmRule);
await service.evaluateSwarmControlCheck({
id: 'check-2',
colonyId: 'colony-1',
swarmMood: 'LOW',
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('evaluateColonyHealth', () => {
const healthRule = {
id: 'rule-5',
triggerType: 'COLONY_HEALTH_CRITICAL',
active: true,
config: {},
actionTemplate: 'Kontrolle erforderlich',
};
it('creates a task on the transition into CRITICAL', async () => {
prisma.automationRule.findUnique.mockResolvedValue(healthRule);
await service.evaluateColonyHealth(
{ id: 'colony-1', healthStatus: 'AVERAGE' } as never,
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not re-trigger when already CRITICAL before the update', async () => {
prisma.automationRule.findUnique.mockResolvedValue(healthRule);
await service.evaluateColonyHealth(
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('evaluateStockItem', () => {
const stockRule = {
id: 'rule-6',
triggerType: 'STOCK_ITEM_LOW',
active: true,
config: {},
actionTemplate: 'Nachbestellung prüfen',
};
it('creates a task when quantity drops below minThreshold', async () => {
prisma.automationRule.findUnique.mockResolvedValue(stockRule);
await service.evaluateStockItem({
id: 'item-1',
quantity: 2,
minThreshold: 5,
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not trigger when minThreshold is not set', async () => {
prisma.automationRule.findUnique.mockResolvedValue(stockRule);
await service.evaluateStockItem({
id: 'item-2',
quantity: 2,
minThreshold: null,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
@@ -6,8 +6,12 @@ import { WebhooksService } from '../webhooks/webhooks.service';
import {
AutomationRule,
AutomationTriggerType,
Colony,
Inspection,
Prisma,
StockItem,
SwarmControlCheck,
TaskRelatedType,
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
@@ -31,6 +35,19 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
};
@Injectable()
@@ -118,6 +135,7 @@ export class AutomationService {
) {
await this.triggerAction(
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
@@ -125,7 +143,12 @@ export class AutomationService {
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerAction(feedRule, inspection.colonyId, inspection.id);
await this.triggerAction(
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
}
@@ -143,13 +166,59 @@ export class AutomationService {
const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue ?? measurement.miteCount;
if (value !== null && value >= threshold) {
await this.triggerAction(rule, measurement.colonyId);
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
}
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerAction(rule, 'COLONY', check.colonyId);
}
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
await this.ensureDefaults();
if (
after.healthStatus !== 'CRITICAL' ||
before.healthStatus === 'CRITICAL'
) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.ensureDefaults();
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
}
private async triggerAction(
rule: AutomationRule,
colonyId: string,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
): Promise<void> {
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
@@ -157,8 +226,8 @@ export class AutomationService {
data: {
title,
priority: 'HIGH',
relatedType: 'COLONY',
relatedId: colonyId,
relatedType,
relatedId,
sourceInspectionId,
},
});
@@ -166,14 +235,19 @@ export class AutomationService {
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType: 'COLONY',
relatedId: colonyId,
relatedType,
relatedId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: { taskId: task.id, colonyId, triggerType: rule.triggerType },
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
},
});
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
@Module({
imports: [AutomationModule],
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
@@ -3,6 +3,7 @@ import { ColoniesService } from './colonies.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('ColoniesService', () => {
let service: ColoniesService;
@@ -55,10 +56,14 @@ describe('ColoniesService', () => {
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
};
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
);
});
@@ -6,6 +6,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
@@ -24,6 +25,7 @@ export class ColoniesService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -80,6 +82,7 @@ export class ColoniesService {
beforeData: before,
afterData: colony,
});
await this.automation.evaluateColonyHealth(before, colony);
return colony;
}
@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateLabelTemplateDto {
@ApiProperty()
@IsString()
name!: string;
@ApiProperty({
description:
'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}',
})
@IsString()
content!: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(10)
widthMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(10)
heightMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateLabelTemplateDto } from './create-label-template.dto';
export class UpdateLabelTemplateDto extends PartialType(
CreateLabelTemplateDto,
) {}
@@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import { LabelTemplatesService } from './label-templates.service';
@ApiTags('label-templates')
@ApiBearerAuth()
@Controller('label-templates')
export class LabelTemplatesController {
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
@RequirePermission('label-templates:read')
@Get()
findAll() {
return this.labelTemplatesService.findAll();
}
@RequirePermission('label-templates:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.labelTemplatesService.findById(id);
}
@RequirePermission('label-templates:create')
@Post()
create(
@Body() dto: CreateLabelTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.labelTemplatesService.create(dto, actor.id);
}
@RequirePermission('label-templates:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateLabelTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.labelTemplatesService.update(id, dto, actor.id);
}
@RequirePermission('label-templates:deactivate')
@Delete(':id')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.labelTemplatesService.deactivate(id, actor.id);
}
@RequirePermission('label-templates:print')
@Get(':id/print/:bottlingBatchId')
async print(
@Param('id') id: string,
@Param('bottlingBatchId') bottlingBatchId: string,
@Res() res: Response,
): Promise<void> {
const pdf = await this.labelTemplatesService.renderLabelsPdf(
id,
bottlingBatchId,
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
'attachment; filename="etiketten.pdf"',
);
res.send(pdf);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabelTemplatesController } from './label-templates.controller';
import { LabelTemplatesService } from './label-templates.service';
@Module({
controllers: [LabelTemplatesController],
providers: [LabelTemplatesService],
exports: [LabelTemplatesService],
})
export class LabelTemplatesModule {}
@@ -0,0 +1,175 @@
import { NotFoundException } from '@nestjs/common';
import { LabelTemplatesService } from './label-templates.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('LabelTemplatesService', () => {
let service: LabelTemplatesService;
let tx: {
labelTemplate: {
updateMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
labelTemplate: {
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
bottlingBatch: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
const template = {
id: 'template-1',
name: 'Standard',
content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}',
widthMm: 90,
heightMm: 35,
isDefault: false,
active: true,
};
beforeEach(() => {
tx = {
labelTemplate: {
updateMany: jest.fn().mockResolvedValue({}),
create: jest.fn(),
update: jest.fn(),
},
};
prisma = {
labelTemplate: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
bottlingBatch: { findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new LabelTemplatesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
describe('create', () => {
it('clears other defaults first when isDefault is set', async () => {
tx.labelTemplate.create.mockResolvedValue({
...template,
isDefault: true,
});
await service.create(
{ name: 'Standard', content: 'x', isDefault: true },
'user-1',
);
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
where: {},
data: { isDefault: false },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'CREATE' }),
);
});
it('does not touch other defaults when isDefault is not set', async () => {
tx.labelTemplate.create.mockResolvedValue(template);
await service.create({ name: 'Standard', content: 'x' }, 'user-1');
expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled();
});
});
describe('deactivate', () => {
it('sets active and isDefault to false, records DEACTIVATE', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.labelTemplate.update.mockResolvedValue({
...template,
active: false,
isDefault: false,
});
const result = await service.deactivate('template-1', 'user-1');
expect(result.active).toBe(false);
expect(prisma.labelTemplate.update).toHaveBeenCalledWith({
where: { id: 'template-1' },
data: { active: false, isDefault: false },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEACTIVATE' }),
);
});
});
describe('renderLabelsPdf', () => {
it('fills placeholders and tiles one label per jar', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 2,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
expect(pdf).toBeInstanceOf(Buffer);
expect(pdf.length).toBeGreaterThan(0);
// %PDF header confirms a real PDF document was produced.
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
});
it('throws NotFoundException when the bottling batch does not exist', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
await expect(
service.renderLabelsPdf('template-1', 'missing'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
content: `${template.content} {{qrCode}}`,
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 1,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
// A PDF with an embedded raster image contains an XObject /Image entry.
expect(pdf.toString('latin1')).toContain('/Image');
});
});
});
@@ -0,0 +1,236 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { LabelTemplate } from '../../generated/prisma';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
const MM_TO_PT = 72 / 25.4;
const PAGE_MARGIN_MM = 5;
const LABEL_GAP_MM = 2;
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const QR_CODE_PLACEHOLDER = '{{qrCode}}';
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE');
}
function fillPlaceholders(
content: string,
values: Record<string, string>,
): string {
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
key in values ? values[key] : match,
);
}
@Injectable()
export class LabelTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(): Promise<LabelTemplate[]> {
return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<LabelTemplate> {
const template = await this.prisma.labelTemplate.findUnique({
where: { id },
});
if (!template) {
throw new NotFoundException('Label template not found');
}
return template;
}
async create(
dto: CreateLabelTemplateDto,
actingUserId: string,
): Promise<LabelTemplate> {
const template = await this.prisma.$transaction(async (tx) => {
if (dto.isDefault) {
await tx.labelTemplate.updateMany({
where: {},
data: { isDefault: false },
});
}
return tx.labelTemplate.create({ data: { ...dto } });
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: template.id,
action: 'CREATE',
userId: actingUserId,
afterData: template,
});
return template;
}
async update(
id: string,
dto: UpdateLabelTemplateDto,
actingUserId: string,
): Promise<LabelTemplate> {
const before = await this.findById(id);
const template = await this.prisma.$transaction(async (tx) => {
if (dto.isDefault) {
await tx.labelTemplate.updateMany({
where: { id: { not: id } },
data: { isDefault: false },
});
}
return tx.labelTemplate.update({ where: { id }, data: { ...dto } });
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
const before = await this.findById(id);
const template = await this.prisma.labelTemplate.update({
where: { id },
data: { active: false, isDefault: false },
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async renderLabelsPdf(
templateId: string,
bottlingBatchId: string,
): Promise<Buffer> {
const template = await this.findById(templateId);
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id: bottlingBatchId },
include: { honeyBatch: true, product: true },
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
const values: Record<string, string> = {
batchNumber: bottlingBatch.honeyBatch.batchNumber,
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
lotNumber: bottlingBatch.lotNumber,
bottlingDate: formatDate(bottlingBatch.date),
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
jarSize: `${bottlingBatch.jarSize} ml`,
productName: bottlingBatch.product?.name ?? '',
};
const labelText = fillPlaceholders(template.content, values);
// The QR code is generated once (not per copy) - it always points at the
// public Herkunft/traceability page for this batch, which already shows
// honey type, dates, and origin, so the label itself only needs to
// reference it rather than duplicate that information.
let qrCodeBuffer: Buffer | null = null;
if (labelText.includes(QR_CODE_PLACEHOLDER)) {
const frontendOrigin =
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`;
qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, {
margin: 1,
width: 300,
});
}
return this.renderPdf(
labelText,
template.widthMm,
template.heightMm,
bottlingBatch.jarCount,
qrCodeBuffer,
);
}
private renderPdf(
labelText: string,
widthMm: number,
heightMm: number,
copies: number,
qrCodeBuffer: Buffer | null,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
const usableWidthMm = A4_WIDTH_MM - 2 * PAGE_MARGIN_MM;
const usableHeightMm = A4_HEIGHT_MM - 2 * PAGE_MARGIN_MM;
const columns = Math.max(
1,
Math.floor((usableWidthMm + LABEL_GAP_MM) / (widthMm + LABEL_GAP_MM)),
);
const rows = Math.max(
1,
Math.floor((usableHeightMm + LABEL_GAP_MM) / (heightMm + LABEL_GAP_MM)),
);
const perPage = columns * rows;
for (let index = 0; index < copies; index += 1) {
const onPage = index % perPage;
if (index > 0 && onPage === 0) {
doc.addPage({ size: 'A4', margin: 0 });
}
const column = onPage % columns;
const row = Math.floor(onPage / columns);
const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM);
const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM);
const x = xMm * MM_TO_PT;
const y = yMm * MM_TO_PT;
const w = widthMm * MM_TO_PT;
const h = heightMm * MM_TO_PT;
doc.rect(x, y, w, h).dash(2, { space: 2 }).stroke('#999999').undash();
if (qrCodeBuffer) {
const qrSide = Math.min(w, h) - 8;
const qrX = x + w - qrSide - 4;
const qrY = y + (h - qrSide) / 2;
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSide, height: qrSide });
const textWidth = qrX - 4 - (x + 4);
doc
.fontSize(9)
.fillColor('#000000')
.text(
labelText.replace(QR_CODE_PLACEHOLDER, '').trim(),
x + 4,
y + 4,
{
width: Math.max(textWidth, 0),
height: h - 8,
},
);
} else {
doc
.fontSize(9)
.fillColor('#000000')
.text(labelText, x + 4, y + 4, { width: w - 8, height: h - 8 });
}
}
doc.end();
});
}
}
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class DeactivateAccountDto {
@ApiProperty()
@IsString()
currentPassword!: string;
}
@@ -20,6 +20,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
import { isAllowedAvatarMimeType } from './avatar-mime-types';
import { ChangePasswordDto } from './dto/change-password.dto';
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
import { DisableMfaDto } from './dto/disable-mfa.dto';
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
@@ -64,6 +65,16 @@ export class ProfileController {
);
}
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Delete()
@HttpCode(204)
deactivateAccount(
@Body() dto: DeactivateAccountDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.profileService.deactivateAccount(actor.id, dto.currentPassword);
}
@Post('avatar')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { MfaModule } from '../auth/mfa/mfa.module';
import { UsersModule } from '../users/users.module';
import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';
@Module({
imports: [MfaModule],
imports: [MfaModule, UsersModule],
controllers: [ProfileController],
providers: [ProfileService],
})
@@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from '../auth/mfa/mfa.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service';
jest.mock('node:fs/promises');
@@ -29,6 +30,7 @@ describe('ProfileService', () => {
confirmEnrollment: jest.Mock;
};
let permissionResolver: { invalidateUser: jest.Mock };
let usersService: { deactivate: jest.Mock };
const baseUser = {
id: 'user-1',
@@ -52,11 +54,13 @@ describe('ProfileService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
permissionResolver = { invalidateUser: jest.fn() };
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
service = new ProfileService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
mfaService as unknown as MfaService,
permissionResolver as unknown as PermissionResolverService,
usersService as unknown as UsersService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
@@ -155,6 +159,24 @@ describe('ProfileService', () => {
});
});
describe('deactivateAccount', () => {
it('throws UnauthorizedException when the current password is wrong', async () => {
prisma.user.findUnique.mockResolvedValue(baseUser);
await expect(
service.deactivateAccount('user-1', 'wrong-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(usersService.deactivate).not.toHaveBeenCalled();
});
it('deactivates the user via UsersService when the password matches', async () => {
prisma.user.findUnique.mockResolvedValue(baseUser);
await service.deactivateAccount('user-1', 'correct-password');
expect(usersService.deactivate).toHaveBeenCalledWith('user-1', 'user-1');
});
});
describe('uploadAvatar', () => {
const file = {
buffer: Buffer.from('binary'),
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service';
import {
ALLOWED_AVATAR_MIME_TYPES,
mimeTypeForExtension,
@@ -41,6 +42,7 @@ export class ProfileService {
private readonly auditLog: AuditLogService,
private readonly mfaService: MfaService,
private readonly permissionResolver: PermissionResolverService,
private readonly usersService: UsersService,
) {}
private async requireUser(userId: string) {
@@ -124,6 +126,21 @@ export class ProfileService {
});
}
async deactivateAccount(
userId: string,
currentPassword: string,
): Promise<void> {
const user = await this.requireUser(userId);
const currentMatches = await bcrypt.compare(
currentPassword,
user.passwordHash,
);
if (!currentMatches) {
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
}
await this.usersService.deactivate(userId, userId);
}
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { StorageController } from './storage.controller';
import { StorageService } from './storage.service';
@Module({
imports: [AutomationModule],
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
@@ -3,6 +3,7 @@ import { StorageService } from './storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
describe('StorageService', () => {
let service: StorageService;
@@ -47,10 +48,14 @@ describe('StorageService', () => {
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateStockItem: jest.fn().mockResolvedValue(undefined),
};
service = new StorageService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
);
});
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { StockItem, StorageContainer } from '../../generated/prisma';
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
import { UpdateStorageContainerDto } from './dto/update-storage-container.dto';
@@ -15,6 +16,7 @@ export class StorageService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAllContainers(): Promise<StorageContainer[]> {
@@ -149,6 +151,7 @@ export class StorageService {
});
const stockItem = await this.findStockItemById(id);
await this.webhooks.dispatch('product.stock_changed', stockItem);
await this.automation.evaluateStockItem(stockItem);
return stockItem;
}
}
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { SwarmControlController } from './swarm-control.controller';
import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller';
import { SwarmControlService } from './swarm-control.service';
@Module({
imports: [AutomationModule],
controllers: [SwarmControlController, ColonySwarmControlChecksController],
providers: [SwarmControlService],
exports: [SwarmControlService],
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
import { SwarmControlService } from './swarm-control.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
describe('SwarmControlService', () => {
let service: SwarmControlService;
@@ -31,9 +32,13 @@ describe('SwarmControlService', () => {
},
};
auditLog = { record: jest.fn() };
const automation = {
evaluateSwarmControlCheck: jest.fn().mockResolvedValue(undefined),
};
service = new SwarmControlService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
automation as unknown as AutomationService,
);
});
@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { SwarmControlCheck } from '../../generated/prisma';
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
@@ -10,6 +11,7 @@ export class SwarmControlService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly automation: AutomationService,
) {}
async findAll(): Promise<SwarmControlCheck[]> {
@@ -56,6 +58,7 @@ export class SwarmControlService {
userId: actingUserId,
afterData: check,
});
await this.automation.evaluateSwarmControlCheck(check);
return check;
}
@@ -83,6 +86,7 @@ export class SwarmControlService {
beforeData: before,
afterData: check,
});
await this.automation.evaluateSwarmControlCheck(check);
return check;
}
}
@@ -20,6 +20,7 @@ const ALLOWLIST = new Set([
'AuthController.me',
'ProfileController.updateProfile',
'ProfileController.changePassword',
'ProfileController.deactivateAccount',
'ProfileController.uploadAvatar',
'ProfileController.deleteAvatar',
'ProfileController.getAvatar',
+3 -1
View File
@@ -8,5 +8,7 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": ["node_modules/(?!(otplib|@otplib|@scure|@noble)/)"]
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
]
}