feat: Sprachnotizen-API (T7 L2 Task 7)

Upload (Audio nur per Magic Bytes, versiegelt, 2 MiB), Text-Weg,
Liste, Abruf, Wiederholen, Verwerfen, alle verwerfen, Konfiguration und
Zähler unter /voice-captures. Rechte voice-captures:use plus
inspections:create (eigener Guard, API-Schlüssel ausgeschlossen),
Zähler nur mit system:manage; jede Abfrage auf den Besitzer begrenzt.
Grenzen je Benutzer unter Advisory-Lock (10 offen, 30 je Tag, 10 je
60 s), Wiederholen ebenfalls begrenzt und gedrosselt, Audio für QUEUED
nur unter 24 h. Verwerfen löscht Inhalt und Claim sofort. Keine Inhalte
in Log oder Audit. Wächter gegen Sprachinhalte in Suche, Export und
Berichten erweitert.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:10:54 +02:00
1 parent 19550d1394
commit 0b0bc3c6dc
21 files changed
+2843 -12

No files matched your search

+131 -11
View File
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { sealTranscript } from '../src/voice/core/transcript';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
@@ -67,6 +68,9 @@ describe('Search and filters (e2e)', () => {
});
afterAll(async () => {
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: readonlyId },
});
await prisma.colony.deleteMany({
where: { id: { in: [colonyId, dissolvedColonyId] } },
});
@@ -88,6 +92,48 @@ describe('Search and filters (e2e)', () => {
).toBe(true);
});
it('returns nothing of a voice capture, neither by its text nor by its id', async () => {
const spoken = `Stifte${Date.now()}x`;
const capture = await prisma.voiceCapture.create({
data: {
ownerUserId: readonlyId,
colonyId,
inspectionDate: new Date(),
source: 'TEXT',
status: 'TRANSCRIBED',
contentExpiresAt: new Date(Date.now() + 86_400_000),
},
});
await prisma.voiceCapture.update({
where: { id: capture.id },
data: {
transcriptEnc: sealTranscript(capture.id, {
v: 1,
text: `${spoken} Koenigin gesehen`,
}),
lastErrorCode: 'EXTRACT_DISABLED',
},
});
// The colony code is found (so the colony is in play), the capture never.
for (const term of [spoken, capture.id, 'EXTRACT_DISABLED', uniqueCode]) {
const response = await request(app.getHttpServer())
.get(`/api/v1/search?q=${term}`)
.set('Authorization', `Bearer ${readonlyToken}`)
.expect(200);
const results = response.body as SearchResultBody[];
const text = JSON.stringify(results);
expect(text).not.toContain(capture.id);
expect(text).not.toContain(spoken);
for (const entry of results) {
expect(Object.keys(entry).sort()).toEqual(['id', 'label', 'type']);
expect(['Apiary', 'Colony', 'Queen', 'Task', 'HoneyBatch']).toContain(
entry.type,
);
}
}
});
it('filters colonies by status', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`)
@@ -101,29 +147,103 @@ describe('Search and filters (e2e)', () => {
describe('Voice capture content guard', () => {
// VoiceCapture holds personal data that is deleted after at most 7 days. It
// must never be reachable through search or any export/report builder.
const sourceRoots = ['search', 'export'].map((dir) =>
path.resolve(__dirname, '../src', dir),
);
const srcRoot = path.resolve(__dirname, '../src');
// Every place that builds a document or a list for the user or another
// system, by directory: nothing here may touch the voice tables.
const builderDirs = [
'search',
'export',
'erp-export',
'compliance',
'label-templates',
'statistics',
'dashboard',
'public',
'qr',
'calendar',
'calendar-sync',
'webhooks',
'shop-sync',
'documents',
'import',
];
// Files that build a PDF, CSV or download wherever they live. Found by
// content, so a new report builder in any folder is covered without an
// edit here. src/voice itself and src/backup (which lists the voice models
// as excluded) are the only places allowed to name the model.
const builderMarkers =
/PDFDocument|pdf-lib|pdfkit|application\/pdf|text\/csv|spreadsheetml|Content-Disposition|toCsv|csv-stringify/;
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
return entry.name.endsWith('.ts') ? [full] : [];
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [full]
: [];
});
}
it('VoiceCapture appears in no search provider and no export', () => {
const files = sourceRoots.flatMap(sourceFiles);
function mentionsVoiceCapture(file: string): boolean {
return /voiceCapture|VoiceCapture/.test(fs.readFileSync(file, 'utf8'));
}
function relative(file: string): string {
return path.relative(srcRoot, file).split(path.sep).join('/');
}
it('VoiceCapture appears in no search provider, export or report directory', () => {
const files = builderDirs.flatMap((dir) =>
sourceFiles(path.join(srcRoot, dir)),
);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
expect({
file: path.basename(file),
mentions: /voiceCapture|VoiceCapture/.test(
fs.readFileSync(file, 'utf8'),
),
}).toEqual({ file: path.basename(file), mentions: false });
file: relative(file),
mentions: mentionsVoiceCapture(file),
}).toEqual({
file: relative(file),
mentions: false,
});
}
});
it('VoiceCapture appears in no file that builds a PDF, CSV or download, wherever it lives', () => {
const builders = sourceFiles(srcRoot).filter((file) => {
const name = relative(file);
return (
!name.startsWith('voice/') &&
!name.startsWith('backup/') &&
builderMarkers.test(fs.readFileSync(file, 'utf8'))
);
});
// The scan must find the known builders, or it checks nothing.
expect(builders.map(relative)).toEqual(
expect.arrayContaining([
expect.stringMatching(/^export\//),
expect.stringMatching(/^compliance\/pdf\//),
]),
);
for (const file of builders) {
expect({
file: relative(file),
mentions: mentionsVoiceCapture(file),
}).toEqual({
file: relative(file),
mentions: false,
});
}
});
it('SearchService names no voice type, field or table', () => {
const service = fs.readFileSync(
path.join(srcRoot, 'search/search.service.ts'),
'utf8',
);
expect(service).not.toMatch(/voice|transcript|suggestionEnc/i);
});
});