From 462ac46d1ecdd31606cda962a27c5af5a3237c07 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 11:36:47 +0200 Subject: [PATCH] fix: Sprachnotizen werden bei ausgeschalteter Aufnahme abgelehnt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /voice-captures und /voice-captures/text antworten mit 403 VOICE_RECORDING_DISABLED („Sprachnotizen sind gerade ausgeschaltet.“), solange VOICE_RECORDING_ENABLED nicht true ist; Liste, Abruf, Wiederholen und Verwerfen bleiben möglich (Ruling 14). Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../captures/voice-captures.service.spec.ts | 25 ++++++++++++ .../voice/captures/voice-captures.service.ts | 16 +++++++- .../src/voice/voice-error-codes.ts | 6 +++ .../test/voice-captures.e2e-spec.ts | 38 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts b/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts index dfb9c6f..5f864dd 100644 --- a/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts +++ b/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts @@ -67,6 +67,7 @@ describe('VoiceCapturesService', () => { jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') }); process.env.JWT_ACCESS_SECRET = 'j'.repeat(40); process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40); + process.env.VOICE_RECORDING_ENABLED = 'true'; counts = { open: 0, today: 0, minute: 0 }; const findFirst = jest.fn(); tx = { @@ -254,6 +255,30 @@ describe('VoiceCapturesService', () => { }); }); + describe('recording switch', () => { + it.each(['upload', 'text'])( + 'refuses %s with 403 VOICE_RECORDING_DISABLED before any work', + async (kind) => { + process.env.VOICE_RECORDING_ENABLED = 'false'; + const call = + kind === 'upload' + ? service.upload('user-1', file(WEBM), uploadDto) + : service.submitText('user-1', { + colonyId: 'colony-1', + inspectionDate: '2026-10-16', + text: SECRET_TEXT, + }); + const error = await failureOf(call); + expect(error.getStatus()).toBe(403); + expect(codeOf(error)).toBe('VOICE_RECORDING_DISABLED'); + expect((error.getResponse() as { message: string }).message).toBe( + 'Sprachnotizen sind gerade ausgeschaltet.', + ); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }, + ); + }); + describe('submitText', () => { const textDto = { colonyId: 'colony-1', diff --git a/ApiCore-Backend/src/voice/captures/voice-captures.service.ts b/ApiCore-Backend/src/voice/captures/voice-captures.service.ts index 7ed0402..d5b9df9 100644 --- a/ApiCore-Backend/src/voice/captures/voice-captures.service.ts +++ b/ApiCore-Backend/src/voice/captures/voice-captures.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, HttpException, HttpStatus, Injectable, @@ -21,7 +22,7 @@ import { openTranscript, sealTranscript } from '../core/transcript'; import { openVoiceText, sealVoiceBytes, voiceAad } from '../core/voice-crypto'; import { normalizeInputText } from '../core/voice-text'; import { voiceConfig } from '../voice-config'; -import { VoiceErrorCode } from '../voice-error-codes'; +import { VOICE_RECORDING_DISABLED, VoiceErrorCode } from '../voice-error-codes'; import { SubmitVoiceTextDto } from './dto/submit-voice-text.dto'; import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto'; import { @@ -107,6 +108,17 @@ function gone(): ConflictException { }); } +/** New notes are refused while the switch is off; reading and cleaning up stay open. */ +function assertRecordingEnabled(): void { + if (!voiceConfig().recordingEnabled) { + throw new ForbiddenException({ + statusCode: HttpStatus.FORBIDDEN, + code: VOICE_RECORDING_DISABLED, + message: 'Sprachnotizen sind gerade ausgeschaltet.', + }); + } +} + function tooManyOpen(): HttpException { const code: VoiceErrorCode = 'TOO_MANY_OPEN'; return new HttpException( @@ -153,6 +165,7 @@ export class VoiceCapturesService { file: Express.Multer.File, dto: UploadVoiceCaptureDto, ): Promise<{ id: string; status: 'QUEUED' }> { + assertRecordingEnabled(); if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) { throw new PayloadTooLargeException({ statusCode: HttpStatus.PAYLOAD_TOO_LARGE, @@ -200,6 +213,7 @@ export class VoiceCapturesService { userId: string, dto: SubmitVoiceTextDto, ): Promise<{ id: string; status: 'TRANSCRIBED' }> { + assertRecordingEnabled(); const text = normalizeInputText(dto.text); const length = Array.from(text).length; if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) { diff --git a/ApiCore-Backend/src/voice/voice-error-codes.ts b/ApiCore-Backend/src/voice/voice-error-codes.ts index 72d01c9..abf9fbb 100644 --- a/ApiCore-Backend/src/voice/voice-error-codes.ts +++ b/ApiCore-Backend/src/voice/voice-error-codes.ts @@ -26,3 +26,9 @@ export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number]; * VOICE_ERROR_CODES: it is never stored in lastErrorCode. */ export const VOICE_CAPTURE_COLONY_MISMATCH = 'VOICE_CAPTURE_COLONY_MISMATCH'; + +/** + * Answered by upload and text when VOICE_RECORDING_ENABLED is off. Not in + * VOICE_ERROR_CODES: it is never stored in lastErrorCode. + */ +export const VOICE_RECORDING_DISABLED = 'VOICE_RECORDING_DISABLED'; diff --git a/ApiCore-Backend/test/voice-captures.e2e-spec.ts b/ApiCore-Backend/test/voice-captures.e2e-spec.ts index 63daeca..614a0ec 100644 --- a/ApiCore-Backend/test/voice-captures.e2e-spec.ts +++ b/ApiCore-Backend/test/voice-captures.e2e-spec.ts @@ -120,7 +120,11 @@ describe('Voice captures (e2e)', () => { tokens[name] = (await login(app, emailOf(name), password)).accessToken; } + const originalRecording = process.env.VOICE_RECORDING_ENABLED; + beforeAll(async () => { + // Notes are only accepted while recording is on; this file turns it on. + process.env.VOICE_RECORDING_ENABLED = 'true'; app = await createTestApp(); prisma = app.get(PrismaService); @@ -208,6 +212,11 @@ describe('Voice captures (e2e)', () => { where: { name: `Voice E2E Stand ${suffix}` }, }); await app.close(); + if (originalRecording === undefined) { + delete process.env.VOICE_RECORDING_ENABLED; + } else { + process.env.VOICE_RECORDING_ENABLED = originalRecording; + } }); const ownRows = (name: string) => @@ -280,6 +289,35 @@ describe('Voice captures (e2e)', () => { expect(rows[0].TimeZone).toBe('UTC'); }); + describe('recording switch', () => { + it('refuses new notes while off but still lists, reads and discards', async () => { + const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; + process.env.VOICE_RECORDING_ENABLED = 'false'; + try { + const up = await upload('alice').expect(403); + expect(up.body).toMatchObject({ + code: 'VOICE_RECORDING_DISABLED', + message: 'Sprachnotizen sind gerade ausgeschaltet.', + }); + await sendText('alice').expect(403); + await request(server()) + .get('/api/v1/voice-captures') + .set(as('alice')) + .expect(200); + await request(server()) + .get(`/api/v1/voice-captures/${id}`) + .set(as('alice')) + .expect(200); + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('alice')) + .expect(204); + } finally { + process.env.VOICE_RECORDING_ENABLED = 'true'; + } + }); + }); + describe('upload', () => { it('stores the audio sealed and answers QUEUED', async () => { const response = await upload('alice').expect(201);