fix: Sprachnotizen werden bei ausgeschalteter Aufnahme abgelehnt
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) <[email protected]>
This commit is contained in:
1 parent
484d799a36
commit
462ac46d1e
4 files changed
+84
-1
No files matched your search
@@ -67,6 +67,7 @@ describe('VoiceCapturesService', () => {
|
|||||||
jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') });
|
jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') });
|
||||||
process.env.JWT_ACCESS_SECRET = 'j'.repeat(40);
|
process.env.JWT_ACCESS_SECRET = 'j'.repeat(40);
|
||||||
process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40);
|
process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40);
|
||||||
|
process.env.VOICE_RECORDING_ENABLED = 'true';
|
||||||
counts = { open: 0, today: 0, minute: 0 };
|
counts = { open: 0, today: 0, minute: 0 };
|
||||||
const findFirst = jest.fn();
|
const findFirst = jest.fn();
|
||||||
tx = {
|
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', () => {
|
describe('submitText', () => {
|
||||||
const textDto = {
|
const textDto = {
|
||||||
colonyId: 'colony-1',
|
colonyId: 'colony-1',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -21,7 +22,7 @@ import { openTranscript, sealTranscript } from '../core/transcript';
|
|||||||
import { openVoiceText, sealVoiceBytes, voiceAad } from '../core/voice-crypto';
|
import { openVoiceText, sealVoiceBytes, voiceAad } from '../core/voice-crypto';
|
||||||
import { normalizeInputText } from '../core/voice-text';
|
import { normalizeInputText } from '../core/voice-text';
|
||||||
import { voiceConfig } from '../voice-config';
|
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 { SubmitVoiceTextDto } from './dto/submit-voice-text.dto';
|
||||||
import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto';
|
import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto';
|
||||||
import {
|
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 {
|
function tooManyOpen(): HttpException {
|
||||||
const code: VoiceErrorCode = 'TOO_MANY_OPEN';
|
const code: VoiceErrorCode = 'TOO_MANY_OPEN';
|
||||||
return new HttpException(
|
return new HttpException(
|
||||||
@@ -153,6 +165,7 @@ export class VoiceCapturesService {
|
|||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
dto: UploadVoiceCaptureDto,
|
dto: UploadVoiceCaptureDto,
|
||||||
): Promise<{ id: string; status: 'QUEUED' }> {
|
): Promise<{ id: string; status: 'QUEUED' }> {
|
||||||
|
assertRecordingEnabled();
|
||||||
if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) {
|
if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) {
|
||||||
throw new PayloadTooLargeException({
|
throw new PayloadTooLargeException({
|
||||||
statusCode: HttpStatus.PAYLOAD_TOO_LARGE,
|
statusCode: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
@@ -200,6 +213,7 @@ export class VoiceCapturesService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
dto: SubmitVoiceTextDto,
|
dto: SubmitVoiceTextDto,
|
||||||
): Promise<{ id: string; status: 'TRANSCRIBED' }> {
|
): Promise<{ id: string; status: 'TRANSCRIBED' }> {
|
||||||
|
assertRecordingEnabled();
|
||||||
const text = normalizeInputText(dto.text);
|
const text = normalizeInputText(dto.text);
|
||||||
const length = Array.from(text).length;
|
const length = Array.from(text).length;
|
||||||
if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) {
|
if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) {
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number];
|
|||||||
* VOICE_ERROR_CODES: it is never stored in lastErrorCode.
|
* VOICE_ERROR_CODES: it is never stored in lastErrorCode.
|
||||||
*/
|
*/
|
||||||
export const VOICE_CAPTURE_COLONY_MISMATCH = 'VOICE_CAPTURE_COLONY_MISMATCH';
|
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';
|
||||||
@@ -120,7 +120,11 @@ describe('Voice captures (e2e)', () => {
|
|||||||
tokens[name] = (await login(app, emailOf(name), password)).accessToken;
|
tokens[name] = (await login(app, emailOf(name), password)).accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const originalRecording = process.env.VOICE_RECORDING_ENABLED;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
// Notes are only accepted while recording is on; this file turns it on.
|
||||||
|
process.env.VOICE_RECORDING_ENABLED = 'true';
|
||||||
app = await createTestApp();
|
app = await createTestApp();
|
||||||
prisma = app.get(PrismaService);
|
prisma = app.get(PrismaService);
|
||||||
|
|
||||||
@@ -208,6 +212,11 @@ describe('Voice captures (e2e)', () => {
|
|||||||
where: { name: `Voice E2E Stand ${suffix}` },
|
where: { name: `Voice E2E Stand ${suffix}` },
|
||||||
});
|
});
|
||||||
await app.close();
|
await app.close();
|
||||||
|
if (originalRecording === undefined) {
|
||||||
|
delete process.env.VOICE_RECORDING_ENABLED;
|
||||||
|
} else {
|
||||||
|
process.env.VOICE_RECORDING_ENABLED = originalRecording;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownRows = (name: string) =>
|
const ownRows = (name: string) =>
|
||||||
@@ -280,6 +289,35 @@ describe('Voice captures (e2e)', () => {
|
|||||||
expect(rows[0].TimeZone).toBe('UTC');
|
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', () => {
|
describe('upload', () => {
|
||||||
it('stores the audio sealed and answers QUEUED', async () => {
|
it('stores the audio sealed and answers QUEUED', async () => {
|
||||||
const response = await upload('alice').expect(201);
|
const response = await upload('alice').expect(201);
|
||||||
|
|||||||
Reference in new issue
Block a user