d3ro-voice/apps/mobile-rn/__tests__/meeting-rerecord-failure-redteam-r3-15.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

142 lines
6.2 KiB
TypeScript

jest.mock('../src/lib/supabase', () => ({
supabase: {
rpc: jest.fn(),
},
}));
import type { Meeting, MeetingStatus } from '@d3ro/api-client';
import { supabase } from '../src/lib/supabase';
import {
beginMeetingRecording,
cancelMeetingRecording,
completeMeetingProcessing,
failMeetingRecording,
queueMeetingRecording,
} from '../src/features/meetings/meetings-service';
import {
confirmsMeetingTransition,
expectedMeetingStates,
} from '../src/features/meetings/meeting-recording-state-policy';
const USER_ID = '11111111-1111-4111-8111-111111111111';
const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555';
const MEETING_ID = '22222222-2222-4222-8222-222222222222';
const AUDIO_ID = '33333333-3333-4333-8333-333333333333';
const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`;
const mockRpc = supabase.rpc as jest.Mock;
function meeting(status: MeetingStatus, userId = USER_ID): Meeting {
return {
id: MEETING_ID,
user_id: userId,
team_id: null,
title: 'Re-recorded meeting',
status,
started_at: '2026-08-21T00:00:00.000Z',
ended_at: '2026-08-21T00:01:00.000Z',
duration_ms: 60_000,
raw_transcript: 'earlier transcript',
edited_transcript: null,
minutes_markdown: 'earlier minutes',
minutes_json: null,
stt_model: 'whisper',
llm_model: null,
stt_latency_ms: 100,
llm_latency_ms: null,
error_message: null,
audio_storage_key: `${USER_ID}/imports/earlier.wav`,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:01:00.000Z',
};
}
describe('re-record failure paths (server restores a meeting with content to completed)', () => {
beforeEach(() => mockRpc.mockReset());
test('cancelMeetingRecording accepts the restored completed meeting', async () => {
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID))
.resolves.toMatchObject({ status: 'completed', raw_transcript: 'earlier transcript' });
expect(mockRpc).toHaveBeenCalledWith('mobile_cancel_meeting_recording', { p_meeting_id: MEETING_ID });
});
test('failMeetingRecording accepts the restored completed meeting', async () => {
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'Queued audio processing was cancelled'))
.resolves.toMatchObject({ status: 'completed' });
expect(mockRpc).toHaveBeenCalledWith('mobile_fail_meeting_recording', {
p_meeting_id: MEETING_ID,
p_error_message: 'Queued audio processing was cancelled',
});
});
test('a first recording still confirms the error outcome', async () => {
mockRpc
.mockResolvedValueOnce({ data: meeting('error'), error: null })
.mockResolvedValueOnce({ data: meeting('error'), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'error' });
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).resolves.toMatchObject({ status: 'error' });
});
test.each<MeetingStatus>(['recording', 'processing'])(
'cancel/fail still reject a meeting left in %s',
async (status) => {
mockRpc
.mockResolvedValueOnce({ data: meeting(status), error: null })
.mockResolvedValueOnce({ data: meeting(status), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
code: 'server',
message: 'Meeting cancellation was not confirmed',
});
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({
code: 'server',
message: 'Meeting error state was not confirmed',
});
},
);
test('cancel/fail still reject a row owned by another user', async () => {
mockRpc
.mockResolvedValueOnce({ data: meeting('completed', OTHER_USER_ID), error: null })
.mockResolvedValueOnce({ data: meeting('error', OTHER_USER_ID), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server' });
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server' });
});
test('the other transitions keep their single confirmed state', async () => {
mockRpc
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
.mockResolvedValueOnce({ data: meeting('error'), error: null });
await expect(beginMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
message: 'Meeting recording state was not confirmed',
});
await expect(queueMeetingRecording(USER_ID, MEETING_ID, 1_000)).rejects.toMatchObject({
message: 'Queued meeting state was not confirmed',
});
await expect(completeMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, {
transcript: 't',
language: 'ko',
provider: 'whisper',
durationMs: 1_000,
sttLatencyMs: 10,
})).rejects.toMatchObject({ message: 'Meeting completion was not confirmed' });
});
});
describe('meeting recording state policy', () => {
test('lists the states each transition may produce', () => {
expect(expectedMeetingStates('recording-started')).toEqual(['recording']);
expect(expectedMeetingStates('recording-queued')).toEqual(['processing']);
expect(expectedMeetingStates('processing-completed')).toEqual(['completed']);
expect(expectedMeetingStates('capture-abandoned')).toEqual(['error', 'completed']);
});
test('confirms only the caller-owned meeting in an expected state', () => {
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'completed' }, USER_ID, 'capture-abandoned')).toBe(true);
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(true);
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'processing' }, USER_ID, 'capture-abandoned')).toBe(false);
expect(confirmsMeetingTransition({ user_id: OTHER_USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(false);
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'processing-completed')).toBe(false);
});
});