d3ro-voice/apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.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

224 lines
8.5 KiB
TypeScript

jest.mock('../src/lib/supabase', () => ({
supabase: { functions: { invoke: jest.fn() } },
}))
import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js'
import { supabase } from '../src/lib/supabase'
import {
edgeFailureField,
invokeEdgeFunction,
readEdgeFunctionHttpFailure,
type EdgeFunctionFailure,
} from '../src/lib/edge-functions'
import {
accountDeletionFailureMessageKey,
edgeAccountDeletionService,
runAccountDeletion,
type AccountDeletionService,
} from '../src/features/account/account-deletion-service'
const mockInvoke = (supabase as unknown as { functions: { invoke: jest.Mock } }).functions.invoke
function jsonResponse(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** Mirrors functions-js 2.103: non-2xx → { data: null, error: FunctionsHttpError(response) }. */
function httpFailure(body: unknown, status: number): { data: null; error: FunctionsHttpError } {
return { data: null, error: new FunctionsHttpError(jsonResponse(body, status)) }
}
beforeEach(() => {
mockInvoke.mockReset()
})
describe('invokeEdgeFunction adapter', () => {
test('decodes the JSON body of a 403 FunctionsHttpError instead of relying on data', async () => {
mockInvoke.mockResolvedValue(httpFailure({
error: 'Recent authentication is required',
code: 'REAUTHENTICATION_REQUIRED',
}, 403))
const result = await invokeEdgeFunction('account-delete', { confirmation: 'x' })
expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'x' } })
expect(result.ok).toBe(false)
const failure = result as EdgeFunctionFailure
expect(failure.status).toBe(403)
expect(failure.body).toEqual({
readable: true,
payload: { error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' },
})
expect(edgeFailureField(failure, 'code')).toBe('REAUTHENTICATION_REQUIRED')
expect(edgeFailureField(failure, 'error')).toBe('Recent authentication is required')
expect(failure.message).toBe('Edge Function returned a non-2xx status code')
})
test('reports an unreadable body without inventing a code', async () => {
mockInvoke.mockResolvedValue({
data: null,
error: new FunctionsHttpError(new Response('<html>bad gateway</html>', { status: 502 })),
})
const result = await invokeEdgeFunction('team-invite', {})
expect(result).toMatchObject({ ok: false, status: 502, body: { readable: false } })
expect(edgeFailureField(result as EdgeFunctionFailure, 'code')).toBeNull()
})
test('keeps fetch errors and thrown values as failures without an HTTP status', async () => {
mockInvoke.mockResolvedValueOnce({ data: null, error: new FunctionsFetchError('offline') })
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
ok: false,
status: null,
body: null,
})
const thrown = new TypeError('Network request failed')
mockInvoke.mockRejectedValueOnce(thrown)
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
ok: false,
status: null,
body: null,
message: 'Network request failed',
cause: thrown,
})
})
test('returns data on success', async () => {
mockInvoke.mockResolvedValue({ data: { success: true }, error: null })
await expect(invokeEdgeFunction('account-delete', {})).resolves.toEqual({
ok: true,
data: { success: true },
})
})
test('readEdgeFunctionHttpFailure ignores errors without a Response context', async () => {
await expect(readEdgeFunctionHttpFailure(new Error('x'))).resolves.toBeNull()
await expect(readEdgeFunctionHttpFailure({ context: 'nope' })).resolves.toBeNull()
await expect(readEdgeFunctionHttpFailure(null)).resolves.toBeNull()
})
})
describe('edgeAccountDeletionService', () => {
test('recognises REAUTHENTICATION_REQUIRED from a 403 response body', async () => {
mockInvoke.mockResolvedValue(httpFailure({
error: 'Recent authentication is required',
code: 'REAUTHENTICATION_REQUIRED',
}, 403))
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
ok: false,
code: 'REAUTHENTICATION_REQUIRED',
message: 'Recent authentication is required',
})
expect(mockInvoke).toHaveBeenCalledWith('account-delete', {
body: { confirmation: 'DELETE_MY_ACCOUNT' },
})
})
test('recognises ACTIVE_SUBSCRIPTION from a 409 response body', async () => {
mockInvoke.mockResolvedValue(httpFailure({
error: 'Cancel the active subscription before deleting the account',
code: 'ACTIVE_SUBSCRIPTION',
}, 409))
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
ok: false,
code: 'ACTIVE_SUBSCRIPTION',
})
})
test('maps a missing function (404) to SERVER_ENDPOINT_UNAVAILABLE', async () => {
mockInvoke.mockResolvedValue(httpFailure({ code: 'NOT_FOUND', message: 'Requested function was not found' }, 404))
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
ok: false,
code: 'SERVER_ENDPOINT_UNAVAILABLE',
})
})
test('treats a 2xx without success=true as REQUEST_FAILED and succeeds only on success=true', async () => {
mockInvoke.mockResolvedValueOnce({ data: { success: false, error: 'nope' }, error: null })
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
ok: false,
code: 'REQUEST_FAILED',
message: 'nope',
})
mockInvoke.mockResolvedValueOnce({ data: { success: true }, error: null })
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: true })
})
})
describe('accountDeletionFailureMessageKey', () => {
test('always maps to translated copy, never raw server/SDK text', () => {
expect(accountDeletionFailureMessageKey('REAUTHENTICATION_REQUIRED'))
.toBe('mobile.account.reauthenticationRequired')
expect(accountDeletionFailureMessageKey('SERVER_ENDPOINT_UNAVAILABLE'))
.toBe('mobile.account.deleteUnavailable')
expect(accountDeletionFailureMessageKey('ACTIVE_SUBSCRIPTION')).toBe('mobile.account.deleteFailed')
expect(accountDeletionFailureMessageKey('REQUEST_FAILED')).toBe('mobile.account.deleteFailed')
})
})
describe('runAccountDeletion ordering', () => {
function service(result: Awaited<ReturnType<AccountDeletionService['deleteCurrentAccount']>>): AccountDeletionService {
return { deleteCurrentAccount: jest.fn(async () => result) }
}
test('a refused deletion leaves push registration and the local session untouched', async () => {
const detachPushRegistration = jest.fn(async () => undefined)
const purgeLocalSession = jest.fn(async () => undefined)
await expect(runAccountDeletion({
deletionService: service({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }),
detachPushRegistration,
purgeLocalSession,
})).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED' })
expect(detachPushRegistration).not.toHaveBeenCalled()
expect(purgeLocalSession).not.toHaveBeenCalled()
})
test('detaches push after server confirmation and before the local purge', async () => {
const calls: string[] = []
const deletionService: AccountDeletionService = {
deleteCurrentAccount: jest.fn(async () => {
calls.push('delete')
return { ok: true } as const
}),
}
await expect(runAccountDeletion({
deletionService,
detachPushRegistration: async () => { calls.push('detach') },
purgeLocalSession: async () => { calls.push('purge') },
})).resolves.toEqual({ ok: true })
expect(calls).toEqual(['delete', 'detach', 'purge'])
})
test('a push detach failure after deletion does not block the local purge', async () => {
const purgeLocalSession = jest.fn(async () => undefined)
await expect(runAccountDeletion({
deletionService: service({ ok: true }),
detachPushRegistration: async () => { throw new Error('both push boundaries failed') },
purgeLocalSession,
})).resolves.toEqual({ ok: true })
expect(purgeLocalSession).toHaveBeenCalledTimes(1)
})
test('a local purge failure propagates to the caller', async () => {
await expect(runAccountDeletion({
deletionService: service({ ok: true }),
detachPushRegistration: async () => undefined,
purgeLocalSession: async () => { throw new Error('purge failed') },
})).rejects.toThrow('purge failed')
})
})