fix(storage): purge raw audio when its history entry or meeting is deleted
This commit is contained in:
parent
4807a5283d
commit
ed790e672b
10 changed files with 1073 additions and 39 deletions
256
server/supabase/tests/audio-retention-on-delete.integration.sql
Normal file
256
server/supabase/tests/audio-retention-on-delete.integration.sql
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
\set ON_ERROR_STOP on
|
||||
|
||||
-- Regression (redteam r2-24): deleting a history entry or meeting (e.g. from the
|
||||
-- phone, which deletes only the parent row) must queue its raw audio for removal
|
||||
-- instead of leaving an unreachable object in the audio bucket forever.
|
||||
-- Requires 20260929000004_audio_retention_on_delete.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF condition IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'assertion_failed: %', message;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO auth.users (
|
||||
id, aud, role, email, encrypted_password, email_confirmed_at,
|
||||
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||
) VALUES
|
||||
(
|
||||
'24000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated',
|
||||
'audio-retention-owner@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
||||
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
||||
),
|
||||
(
|
||||
'24000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated',
|
||||
'audio-retention-leaver@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
||||
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
||||
);
|
||||
|
||||
INSERT INTO public.history (id, user_id, original_text, duration)
|
||||
VALUES
|
||||
('24100000-0000-4000-8000-000000000001', '24000000-0000-4000-8000-000000000001', 'dictation one', 1),
|
||||
('24100000-0000-4000-8000-000000000002', '24000000-0000-4000-8000-000000000001', 'dictation two', 1),
|
||||
('24100000-0000-4000-8000-000000000003', '24000000-0000-4000-8000-000000000002', 'leaver dictation', 1);
|
||||
|
||||
INSERT INTO public.meetings (id, user_id, title, status)
|
||||
VALUES ('24200000-0000-4000-8000-000000000001', '24000000-0000-4000-8000-000000000001', 'Retention meeting', 'completed');
|
||||
|
||||
INSERT INTO public.audio_files (
|
||||
id, user_id, history_id, meeting_id, source, storage_key, mime_type, size_bytes, sha256, upload_status
|
||||
) VALUES
|
||||
('24300000-0000-4000-8000-000000000001', '24000000-0000-4000-8000-000000000001',
|
||||
'24100000-0000-4000-8000-000000000001', NULL, 'recording',
|
||||
'24000000-0000-4000-8000-000000000001/history/one.wav', 'audio/wav', 10, repeat('a', 64), 'uploaded'),
|
||||
('24300000-0000-4000-8000-000000000002', '24000000-0000-4000-8000-000000000001',
|
||||
NULL, '24200000-0000-4000-8000-000000000001', 'recording',
|
||||
'24000000-0000-4000-8000-000000000001/meetings/m.wav', 'audio/wav', 10, repeat('b', 64), 'uploaded'),
|
||||
('24300000-0000-4000-8000-000000000003', '24000000-0000-4000-8000-000000000001',
|
||||
'24100000-0000-4000-8000-000000000002', NULL, 'file-picker',
|
||||
'24000000-0000-4000-8000-000000000001/' || repeat('c', 64) || '/memo.m4a', 'audio/mp4', 10, repeat('c', 64), 'uploaded'),
|
||||
('24300000-0000-4000-8000-000000000004', '24000000-0000-4000-8000-000000000002',
|
||||
'24100000-0000-4000-8000-000000000003', NULL, 'recording',
|
||||
'24000000-0000-4000-8000-000000000002/history/leaver.wav', 'audio/wav', 10, repeat('d', 64), 'uploaded');
|
||||
|
||||
-- 1) Clients cannot see or drive the purge queue. Checked through the catalog:
|
||||
-- calling a function whose EXECUTE is denied segfaults the local
|
||||
-- supabase/postgres 17.6.1.104 image, which would take the shared DB down.
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT has_table_privilege('authenticated', 'public.audio_purge_queue', 'SELECT')
|
||||
AND NOT has_table_privilege('anon', 'public.audio_purge_queue', 'SELECT')
|
||||
AND NOT has_table_privilege('authenticated', 'public.audio_purge_queue', 'INSERT')
|
||||
AND NOT has_function_privilege('authenticated', 'public.claim_audio_purge_batch_v1(integer, integer)', 'EXECUTE')
|
||||
AND NOT has_function_privilege('anon', 'public.claim_audio_purge_batch_v1(integer, integer)', 'EXECUTE')
|
||||
AND NOT has_function_privilege('authenticated', 'public.complete_audio_purge_v1(bigint[], uuid)', 'EXECUTE')
|
||||
AND NOT has_function_privilege('authenticated', 'public.dispatch_audio_purge_v1()', 'EXECUTE')
|
||||
AND has_function_privilege('service_role', 'public.claim_audio_purge_batch_v1(integer, integer)', 'EXECUTE')
|
||||
AND has_function_privilege('service_role', 'public.complete_audio_purge_v1(bigint[], uuid)', 'EXECUTE'),
|
||||
'purge queue and worker RPCs are service-only'
|
||||
);
|
||||
|
||||
SET LOCAL ROLE authenticated;
|
||||
SELECT set_config(
|
||||
'request.jwt.claims',
|
||||
'{"sub":"24000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||
true
|
||||
);
|
||||
|
||||
-- 2) The phone deletes only the parent rows (RLS path, like deleteHistoryRevisionSafe).
|
||||
DELETE FROM public.history WHERE id = '24100000-0000-4000-8000-000000000001';
|
||||
DELETE FROM public.meetings WHERE id = '24200000-0000-4000-8000-000000000001';
|
||||
DELETE FROM public.history WHERE id = '24100000-0000-4000-8000-000000000002';
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT count(*) FROM public.audio_files
|
||||
WHERE id IN ('24300000-0000-4000-8000-000000000001',
|
||||
'24300000-0000-4000-8000-000000000002',
|
||||
'24300000-0000-4000-8000-000000000003')
|
||||
AND upload_status = 'deleted'
|
||||
AND history_id IS NULL AND meeting_id IS NULL) = 3,
|
||||
'orphaned audio rows are marked deleted'
|
||||
);
|
||||
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT array_agg(storage_key ORDER BY storage_key) FROM public.audio_purge_queue
|
||||
WHERE user_id = '24000000-0000-4000-8000-000000000001')
|
||||
= ARRAY[
|
||||
'24000000-0000-4000-8000-000000000001/' || repeat('c', 64) || '/memo.m4a',
|
||||
'24000000-0000-4000-8000-000000000001/history/one.wav',
|
||||
'24000000-0000-4000-8000-000000000001/meetings/m.wav'
|
||||
],
|
||||
'history and meeting deletes queue their audio keys'
|
||||
);
|
||||
|
||||
-- 3) Re-importing the same content-addressed file reclaims the key: the pending
|
||||
-- purge is cancelled and the stale deleted row no longer blocks the UNIQUE key.
|
||||
SET LOCAL ROLE authenticated;
|
||||
INSERT INTO public.audio_files (
|
||||
id, user_id, source, storage_key, mime_type, size_bytes, sha256, upload_status
|
||||
) VALUES (
|
||||
'24300000-0000-4000-8000-000000000005', '24000000-0000-4000-8000-000000000001', 'file-picker',
|
||||
'24000000-0000-4000-8000-000000000001/' || repeat('c', 64) || '/memo.m4a', 'audio/mp4', 10, repeat('c', 64), 'pending'
|
||||
);
|
||||
RESET ROLE;
|
||||
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT EXISTS (SELECT 1 FROM public.audio_purge_queue
|
||||
WHERE storage_key LIKE '%/memo.m4a'),
|
||||
'reclaimed key is no longer queued for purge'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT EXISTS (SELECT 1 FROM public.audio_files WHERE id = '24300000-0000-4000-8000-000000000003'),
|
||||
'stale deleted row for the reclaimed key is dropped'
|
||||
);
|
||||
|
||||
-- 4) A key referenced by a live row again is dropped at claim time, never removed.
|
||||
INSERT INTO public.audio_purge_queue (user_id, storage_key)
|
||||
VALUES ('24000000-0000-4000-8000-000000000001',
|
||||
'24000000-0000-4000-8000-000000000001/' || repeat('c', 64) || '/memo.m4a');
|
||||
|
||||
SET LOCAL ROLE service_role;
|
||||
CREATE TEMP TABLE claimed ON COMMIT DROP AS
|
||||
SELECT * FROM public.claim_audio_purge_batch_v1(100, 600);
|
||||
RESET ROLE;
|
||||
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT count(*) FROM claimed) = 2
|
||||
AND NOT EXISTS (SELECT 1 FROM claimed WHERE storage_key LIKE '%/memo.m4a')
|
||||
AND (SELECT count(DISTINCT lease_token) FROM claimed) = 1
|
||||
AND (SELECT bool_and(leased_until > now() AND attempts = 1) FROM claimed),
|
||||
'claim leases only keys with no live owner'
|
||||
);
|
||||
|
||||
-- 5) While a worker holds the lease, a live row cannot reclaim that key.
|
||||
SET LOCAL ROLE authenticated;
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
INSERT INTO public.audio_files (
|
||||
user_id, meeting_id, source, storage_key, mime_type, size_bytes, sha256, upload_status
|
||||
) VALUES (
|
||||
'24000000-0000-4000-8000-000000000001', NULL, 'recording',
|
||||
'24000000-0000-4000-8000-000000000001/meetings/m.wav', 'audio/wav', 10, repeat('e', 64), 'pending'
|
||||
);
|
||||
RAISE EXCEPTION 'assertion_failed: leased key was reclaimed';
|
||||
EXCEPTION WHEN object_in_use THEN NULL;
|
||||
END;
|
||||
END;
|
||||
$$;
|
||||
RESET ROLE;
|
||||
|
||||
-- 6) A second claim does not hand out leased work.
|
||||
SET LOCAL ROLE service_role;
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT EXISTS (SELECT 1 FROM public.claim_audio_purge_batch_v1(100, 600)),
|
||||
'leased entries are not claimed twice'
|
||||
);
|
||||
|
||||
-- 7) Completing with a wrong lease does nothing; the right lease clears queue and rows.
|
||||
SELECT pg_temp.assert_true(
|
||||
public.complete_audio_purge_v1(
|
||||
(SELECT array_agg(id) FROM claimed), gen_random_uuid()
|
||||
) = 0,
|
||||
'foreign lease cannot complete'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
public.complete_audio_purge_v1(
|
||||
(SELECT array_agg(id) FROM claimed), (SELECT min(lease_token::text)::uuid FROM claimed)
|
||||
) = 2,
|
||||
'lease holder completes the batch'
|
||||
);
|
||||
RESET ROLE;
|
||||
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT EXISTS (SELECT 1 FROM public.audio_purge_queue
|
||||
WHERE user_id = '24000000-0000-4000-8000-000000000001'),
|
||||
'completed entries leave the queue'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT EXISTS (SELECT 1 FROM public.audio_files
|
||||
WHERE id IN ('24300000-0000-4000-8000-000000000001',
|
||||
'24300000-0000-4000-8000-000000000002')),
|
||||
'purged audio metadata is removed'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (SELECT 1 FROM public.audio_files
|
||||
WHERE id = '24300000-0000-4000-8000-000000000005' AND upload_status = 'pending'),
|
||||
'the reclaimed live row survives completion'
|
||||
);
|
||||
|
||||
-- 8) Desktop upsert (ON CONFLICT on the UNIQUE key) over a deleted row reclaims it.
|
||||
INSERT INTO public.history (id, user_id, original_text, duration)
|
||||
VALUES ('24100000-0000-4000-8000-000000000004', '24000000-0000-4000-8000-000000000001', 'desktop', 1);
|
||||
INSERT INTO public.audio_files (
|
||||
id, user_id, history_id, source, storage_key, mime_type, size_bytes, sha256, upload_status
|
||||
) VALUES (
|
||||
'24300000-0000-4000-8000-000000000006', '24000000-0000-4000-8000-000000000001',
|
||||
'24100000-0000-4000-8000-000000000004', 'recording',
|
||||
'24000000-0000-4000-8000-000000000001/history/desk.wav', 'audio/wav', 10, repeat('f', 64), 'uploaded'
|
||||
);
|
||||
DELETE FROM public.history WHERE id = '24100000-0000-4000-8000-000000000004';
|
||||
INSERT INTO public.history (id, user_id, original_text, duration)
|
||||
VALUES ('24100000-0000-4000-8000-000000000004', '24000000-0000-4000-8000-000000000001', 'desktop again', 1);
|
||||
INSERT INTO public.audio_files (
|
||||
user_id, history_id, source, storage_key, mime_type, size_bytes, sha256, upload_status
|
||||
) VALUES (
|
||||
'24000000-0000-4000-8000-000000000001', '24100000-0000-4000-8000-000000000004', 'recording',
|
||||
'24000000-0000-4000-8000-000000000001/history/desk.wav', 'audio/wav', 10, repeat('f', 64), 'uploaded'
|
||||
)
|
||||
ON CONFLICT (user_id, sha256, storage_key) DO UPDATE
|
||||
SET history_id = EXCLUDED.history_id, upload_status = EXCLUDED.upload_status;
|
||||
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT count(*) FROM public.audio_files
|
||||
WHERE storage_key = '24000000-0000-4000-8000-000000000001/history/desk.wav') = 1
|
||||
AND EXISTS (SELECT 1 FROM public.audio_files
|
||||
WHERE storage_key = '24000000-0000-4000-8000-000000000001/history/desk.wav'
|
||||
AND upload_status = 'uploaded'
|
||||
AND history_id = '24100000-0000-4000-8000-000000000004')
|
||||
AND NOT EXISTS (SELECT 1 FROM public.audio_purge_queue WHERE storage_key LIKE '%/desk.wav'),
|
||||
'desktop upsert reclaims a deleted key'
|
||||
);
|
||||
|
||||
-- 9) Account deletion still cascades (the queue has no FK to auth.users).
|
||||
DELETE FROM auth.users WHERE id = '24000000-0000-4000-8000-000000000002';
|
||||
SELECT pg_temp.assert_true(
|
||||
NOT EXISTS (SELECT 1 FROM public.audio_files WHERE user_id = '24000000-0000-4000-8000-000000000002'),
|
||||
'account deletion removes the user audio rows'
|
||||
);
|
||||
|
||||
-- 10) The dispatcher is a no-op when there is no ready work.
|
||||
SELECT pg_temp.assert_true(
|
||||
public.dispatch_audio_purge_v1() IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM public.audio_purge_queue WHERE user_id = '24000000-0000-4000-8000-000000000001'
|
||||
),
|
||||
'dispatcher does not fail'
|
||||
);
|
||||
|
||||
ROLLBACK;
|
||||
Loading…
Add table
Add a link
Reference in a new issue