diff --git a/bun.lockb b/bun.lockb index 507decce6c..1c5d001dbe 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 0d38b73e19..d848e58b42 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "husky": "^9.1.7", "miniflare": "^3.20250124.0", "sass": "1.83.4", + "supabase": "1.127.3", "tailwindcss": "^4.0.0", "typescript": "5.7.3", "unplugin-icons": "22.0.0", diff --git a/supabase/functions/_backend/plugins/channel_self.ts b/supabase/functions/_backend/plugins/channel_self.ts index ff8c2ccde9..5ded52c8e2 100644 --- a/supabase/functions/_backend/plugins/channel_self.ts +++ b/supabase/functions/_backend/plugins/channel_self.ts @@ -3,8 +3,8 @@ import type { Context } from '@hono/hono' import type { DeviceWithoutCreatedAt } from '../utils/stats.ts' import type { Database } from '../utils/supabase.types.ts' import type { AppInfos } from '../utils/types.ts' +import { Hono } from '@hono/hono' import { format, tryParse } from '@std/semver' -import { Hono } from 'hono/tiny' import { z } from 'zod' import { BRES, getBody } from '../utils/hono.ts' import { sendStatsAndDevice } from '../utils/stats.ts' @@ -49,7 +49,7 @@ export const jsonRequestSchema = z.object({ return val }) -async function post(c: Context, body: DeviceLink): Promise { +async function post(c: Context, body: DeviceLink): Promise { console.log({ requestId: c.get('requestId'), context: 'post channel self body', body }) const parseResult = jsonRequestSchema.safeParse(body) if (!parseResult.success) { @@ -265,7 +265,7 @@ async function post(c: Context, body: DeviceLink): Promise { return c.json(BRES) } -async function put(c: Context, body: DeviceLink): Promise { +async function put(c: Context, body: DeviceLink): Promise { console.log({ requestId: c.get('requestId'), context: 'put channel self body', body }) let { version_name, @@ -408,7 +408,7 @@ async function put(c: Context, body: DeviceLink): Promise { }, 400) } -async function deleteOverride(c: Context, body: DeviceLink): Promise { +async function deleteOverride(c: Context, body: DeviceLink): Promise { console.log({ requestId: c.get('requestId'), context: 'delete channel self body', body }) let { version_build, @@ -473,7 +473,7 @@ export const app = new Hono() app.post('/', async (c: Context) => { try { - const body = await c.req.json() + const body = await c.req.json() as DeviceLink console.log({ requestId: c.get('requestId'), context: 'post body', body }) return post(c, body) } @@ -485,7 +485,7 @@ app.post('/', async (c: Context) => { app.put('/', async (c: Context) => { // Used as get, should be refactor with query param instead try { - const body = await c.req.json() + const body = await c.req.json() as DeviceLink console.log({ requestId: c.get('requestId'), context: 'put body', body }) return put(c, body) } @@ -496,7 +496,7 @@ app.put('/', async (c: Context) => { app.delete('/', async (c: Context) => { try { - const body = await getBody(c) + const body = await getBody(c) as DeviceLink // const body = await c.req.json() console.log({ requestId: c.get('requestId'), context: 'delete body', body }) return deleteOverride(c, body) diff --git a/supabase/migrations/20240513055310_base.sql b/supabase/migrations/20240101000001_base.sql similarity index 100% rename from supabase/migrations/20240513055310_base.sql rename to supabase/migrations/20240101000001_base.sql diff --git a/supabase/migrations/20240101000002_fix_channel_deletion.sql b/supabase/migrations/20240101000002_fix_channel_deletion.sql new file mode 100644 index 0000000000..5287a58b4f --- /dev/null +++ b/supabase/migrations/20240101000002_fix_channel_deletion.sql @@ -0,0 +1,37 @@ +-- Drop existing constraint +ALTER TABLE "public"."channel_devices" + DROP CONSTRAINT IF EXISTS "channel_devices_channel_id_fkey"; + +-- Re-add constraint with ON DELETE SET NULL +ALTER TABLE "public"."channel_devices" + ADD CONSTRAINT "channel_devices_channel_id_fkey" + FOREIGN KEY ("channel_id") + REFERENCES "public"."channels"("id") + ON DELETE SET NULL; + +-- Add NOT NULL constraint to prevent accidental deletions +ALTER TABLE "public"."channels" + ALTER COLUMN "name" SET NOT NULL, + ALTER COLUMN "app_id" SET NOT NULL; + +-- Add explicit deletion protection +CREATE OR REPLACE FUNCTION prevent_channel_deletion() +RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.channel_devices + WHERE channel_id = OLD.id + LIMIT 1 + ) THEN + RAISE EXCEPTION 'Cannot delete channel while devices are associated with it'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS prevent_channel_deletion_trigger ON public.channels; +CREATE TRIGGER prevent_channel_deletion_trigger + BEFORE DELETE ON public.channels + FOR EACH ROW + EXECUTE FUNCTION prevent_channel_deletion(); diff --git a/supabase/migrations/20240101000003_add_seed_function.sql b/supabase/migrations/20240101000003_add_seed_function.sql new file mode 100644 index 0000000000..dd3619ec4c --- /dev/null +++ b/supabase/migrations/20240101000003_add_seed_function.sql @@ -0,0 +1,72 @@ +CREATE OR REPLACE FUNCTION public.reset_and_seed_app_data(p_app_id character varying) RETURNS void + LANGUAGE plpgsql SECURITY DEFINER + AS $$ +DECLARE + org_id uuid := '046a36ac-e03c-4590-9257-bd6c9dba9ee8'; + user_id uuid := '6aa76066-55ef-4238-ade6-0b32334a4097'; + max_version_id bigint; + max_channel_id bigint; +BEGIN + -- Lock the tables to prevent concurrent inserts + LOCK TABLE app_versions, channels IN EXCLUSIVE MODE; + + -- Delete existing data for the specified app_id + DELETE FROM channels WHERE app_id = p_app_id; + DELETE FROM app_versions WHERE app_id = p_app_id; + DELETE FROM apps WHERE app_id = p_app_id; + + -- Get the current max ids and reset the sequences + SELECT COALESCE(MAX(id), 0) + 1 INTO max_version_id FROM app_versions; + SELECT COALESCE(MAX(id), 0) + 1 INTO max_channel_id FROM channels; + + -- Reset both sequences + PERFORM setval('app_versions_id_seq', max_version_id, false); + PERFORM setval('channel_id_seq', max_channel_id, false); + + -- Insert new app data + INSERT INTO apps (created_at, app_id, icon_url, name, last_version, updated_at, owner_org, user_id) + VALUES (now(), p_app_id, '', 'Seeded App', '1.0.0', now(), org_id, user_id); + + -- Insert app versions in a single statement + WITH inserted_versions AS ( + INSERT INTO app_versions (created_at, app_id, name, r2_path, updated_at, deleted, external_url, checksum, storage_provider, owner_org) + VALUES + (now(), p_app_id, 'builtin', NULL, now(), 't', NULL, NULL, 'supabase', org_id), + (now(), p_app_id, 'unknown', NULL, now(), 't', NULL, NULL, 'supabase', org_id), + (now(), p_app_id, '1.0.1', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.0.1.zip', now(), 'f', NULL, '', 'r2-direct', org_id), + (now(), p_app_id, '1.0.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.0.0.zip', now(), 'f', NULL, '3885ee49', 'r2', org_id), + (now(), p_app_id, '1.361.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.361.0.zip', now(), 'f', NULL, '9d4f798a', 'r2', org_id), + (now(), p_app_id, '1.360.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.360.0.zip', now(), 'f', NULL, '44913a9f', 'r2', org_id), + (now(), p_app_id, '1.359.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.359.0.zip', now(), 'f', NULL, '9f74e70a', 'r2', org_id) + RETURNING id, name + ) + -- Insert channels using the version IDs from the CTE + INSERT INTO channels (created_at, name, app_id, version, updated_at, public, disable_auto_update_under_native, disable_auto_update, ios, android, allow_device_self_set, allow_emulator, allow_dev, owner_org) + SELECT + now(), + c.name, + p_app_id, + v.id, + now(), + c.is_public, + 't', + 'major', + c.ios, + c.android, + 't', + 't', + 't', + org_id + FROM ( + VALUES + ('production', '1.0.0', true, false, true), + ('no_access', '1.361.0', false, true, true), + ('two_default', '1.0.0', true, true, false) + ) as c(name, version_name, is_public, ios, android) + JOIN inserted_versions v ON v.name = c.version_name; + +END; +$$; + +REVOKE ALL ON FUNCTION public.reset_and_seed_app_data(p_app_id character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION public.reset_and_seed_app_data(p_app_id character varying) TO service_role; diff --git a/tests/channel_deletion.test.ts b/tests/channel_deletion.test.ts new file mode 100644 index 0000000000..304f79c527 --- /dev/null +++ b/tests/channel_deletion.test.ts @@ -0,0 +1,230 @@ +import { randomUUID } from 'node:crypto' +import { beforeAll, describe, expect, it } from 'vitest' +import { getBaseData, getSupabaseClient, resetAndSeedAppData } from './test-utils.ts' + +const APPNAME = 'com.demo.app.channel_deletion' +const FUNCTIONS_URL = process.env.FUNCTIONS_URL ?? 'http://127.0.0.1:54321/functions/v1' + +async function setupChannel(channelName: string, allowSelfSet: boolean) { + const { error } = await getSupabaseClient() + .from('channels') + .update({ allow_device_self_set: allowSelfSet }) + .eq('name', channelName) + .eq('app_id', APPNAME) + .eq('owner_org', '046a36ac-e03c-4590-9257-bd6c9dba9ee8') + + if (error) { + throw new Error(`Failed to setup channel: ${error.message}`) + } +} + +interface ChannelResponse { + channel?: string; + status?: string; + error?: string; + message?: string; +} + +async function fetchEndpoint(method: string, bodyIn: object) { + const url = new URL(`${FUNCTIONS_URL}/channel_self`) + if (method === 'DELETE') { + for (const [key, value] of Object.entries(bodyIn)) + url.searchParams.append(key, value.toString()) + } + + const body = method !== 'DELETE' ? JSON.stringify(bodyIn) : undefined + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0`, + }, + body, + }) + + if (!response.ok) { + const errorData = await response.json() as ChannelResponse + console.error('Request failed:', { + status: response.status, + error: errorData.error, + message: errorData.message + }) + } + + return response +} + +let productionChannelId: number + +beforeAll(async () => { + // Set up environment variables for local testing + process.env.SUPABASE_URL = 'http://127.0.0.1:54321' + process.env.SUPABASE_SERVICE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' + + await resetAndSeedAppData(APPNAME) + + const { data: channels, error: findError } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('name', 'production') + .eq('app_id', APPNAME) + .eq('owner_org', '046a36ac-e03c-4590-9257-bd6c9dba9ee8') + .limit(1) + + if (findError || !channels || channels.length === 0) { + throw new Error(`Failed to find production channel: ${findError?.message || 'Channel not found'}`) + } + + productionChannelId = channels[0].id + + const { error: updateError } = await getSupabaseClient() + .from('channels') + .update({ allow_device_self_set: true }) + .eq('id', productionChannelId) + + if (updateError) { + throw new Error(`Failed to update channel: ${updateError.message}`) + } +}) + +describe('channel deletion tests', () => { + it('should not delete channel when setting and unsetting device channel', async () => { + await resetAndSeedAppData(APPNAME) + const deviceId = randomUUID().toLowerCase() + const data = { + ...getBaseData(APPNAME), + device_id: deviceId, + platform: 'ios', + channel: 'production' + } + + await setupChannel('production', true) + try { + // Initial channel verification + const { data: initialChannel, error: initialError } = await getSupabaseClient() + .from('channels') + .select('id, name, allow_device_self_set') + .eq('id', productionChannelId) + .eq('owner_org', '046a36ac-e03c-4590-9257-bd6c9dba9ee8') + .single() + + expect(initialError).toBeNull() + expect(initialChannel).toBeTruthy() + expect(initialChannel!.allow_device_self_set).toBe(true) + + // Set channel + const setResponse = await fetchEndpoint('POST', data) + expect(setResponse.ok).toBe(true) + expect(await setResponse.json()).toEqual({ status: 'ok' }) + + // Verify channel assignment + const { data: channelDevice, error: channelDeviceError } = await getSupabaseClient() + .from('channel_devices') + .select('channel_id, device_id') + .eq('device_id', deviceId) + .eq('app_id', APPNAME) + .single() + + expect(channelDeviceError).toBeNull() + expect(channelDevice).toBeTruthy() + expect(channelDevice!.channel_id).toBe(productionChannelId) + + // Unset channel + const unsetResponse = await fetchEndpoint('DELETE', data) + expect(unsetResponse.ok).toBe(true) + expect(await unsetResponse.json()).toEqual({ status: 'ok' }) + + // Verify channel still exists after unset + const { data: channelAfterUnset, error: channelAfterUnsetError } = await getSupabaseClient() + .from('channels') + .select('id, name, allow_device_self_set') + .eq('id', productionChannelId) + .single() + + expect(channelAfterUnsetError).toBeNull() + expect(channelAfterUnset).toBeTruthy() + expect(channelAfterUnset!.name).toBe('production') + expect(channelAfterUnset!.id).toBe(initialChannel!.id) + expect(channelAfterUnset!.allow_device_self_set).toBe(true) + + // Verify device assignment is removed + const { data: deviceAssignments, error: deviceAssignmentsError } = await getSupabaseClient() + .from('channel_devices') + .select('channel_id, device_id') + .eq('device_id', deviceId) + .eq('app_id', APPNAME) + + expect(deviceAssignmentsError).toBeNull() + expect(deviceAssignments).toBeDefined() + expect(deviceAssignments!.length).toBe(0) + } finally { + await setupChannel('production', false) + } + }) + + it('should not delete channel when multiple devices set and unset channel simultaneously', async () => { + await resetAndSeedAppData(APPNAME) + const deviceCount = 3 + const devices = Array.from({ length: deviceCount }, () => ({ + ...getBaseData(APPNAME), + device_id: randomUUID().toLowerCase(), + platform: 'ios', + channel: 'production' + })) + + await setupChannel('production', true) + try { + const { data: initialChannel, error: initialError } = await getSupabaseClient() + .from('channels') + .select('id, name, allow_device_self_set') + .eq('id', productionChannelId) + .eq('owner_org', '046a36ac-e03c-4590-9257-bd6c9dba9ee8') + .single() + + expect(initialError).toBeNull() + expect(initialChannel).toBeTruthy() + expect(initialChannel!.allow_device_self_set).toBe(true) + + const setResponses = await Promise.all(devices.map(device => + fetchEndpoint('POST', device) + )) + + setResponses.forEach(response => { + expect(response.ok).toBe(true) + }) + + const unsetResponses = await Promise.all(devices.map(device => + fetchEndpoint('DELETE', device) + )) + + unsetResponses.forEach(response => { + expect(response.ok).toBe(true) + }) + + const { data: channelAfterUnset, error: channelAfterUnsetError } = await getSupabaseClient() + .from('channels') + .select('id, name, allow_device_self_set') + .eq('id', productionChannelId) + .eq('owner_org', '046a36ac-e03c-4590-9257-bd6c9dba9ee8') + .single() + + expect(channelAfterUnsetError).toBeNull() + expect(channelAfterUnset).toBeTruthy() + expect(channelAfterUnset!.name).toBe('production') + expect(channelAfterUnset!.id).toBe(initialChannel!.id) + expect(channelAfterUnset!.allow_device_self_set).toBe(true) + + const { data: deviceAssignments, error: deviceAssignmentsError } = await getSupabaseClient() + .from('channel_devices') + .select('channel_id, device_id') + .eq('app_id', APPNAME) + .in('device_id', devices.map(d => d.device_id)) + + expect(deviceAssignmentsError).toBeNull() + expect(deviceAssignments).toBeDefined() + expect(deviceAssignments!.length).toBe(0) + } finally { + await setupChannel('production', false) + } + }) +}) diff --git a/tests/channel_self.test.ts b/tests/channel_self.test.ts index e1a7efb940..640d426f88 100644 --- a/tests/channel_self.test.ts +++ b/tests/channel_self.test.ts @@ -110,7 +110,7 @@ describe('invalids /channel_self tests', () => { it('[POST] with a channel that does not allow self assign', async () => { const data = getBaseData(APPNAME) - const { error } = await getSupabaseClient().from('channels').update({ allow_device_self_set: false }).eq('name', data.channel).eq('app_id', APPNAME).select('id').single() + const { error } = await getSupabaseClient().from('channels').update({ allow_device_self_set: false }).eq('name', data.channel || '').eq('app_id', APPNAME).select('id').single() expect(error).toBeNull() @@ -122,7 +122,7 @@ describe('invalids /channel_self tests', () => { expect(responseError).toBe('channel_set_from_plugin_not_allowed') } finally { - const { error } = await getSupabaseClient().from('channels').update({ allow_device_self_set: true }).eq('name', data.channel).eq('app_id', APPNAME).select('id').single() + const { error } = await getSupabaseClient().from('channels').update({ allow_device_self_set: true }).eq('name', data.channel || '').eq('app_id', APPNAME).select('id').single() expect(error).toBeNull() } diff --git a/tests/test-utils.ts b/tests/test-utils.ts index b698850317..820bd9ae3b 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -54,13 +54,17 @@ export async function resetAndSeedAppDataStats(appId: string) { } export function getSupabaseClient(): SupabaseClient { - const supabaseUrl = env.SUPABASE_URL ?? '' - const supabaseServiceKey = env.SUPABASE_SERVICE_KEY ?? '' - return createClient(supabaseUrl, supabaseServiceKey) + const supabaseUrl = env.SUPABASE_URL ?? 'http://127.0.0.1:54321' + const supabaseServiceKey = env.SUPABASE_SERVICE_KEY ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' + return createClient(supabaseUrl, supabaseServiceKey, { + db: { + schema: 'public' + } + }) } export async function seedTestData(supabase: SupabaseClient, appId: string) { - const { error } = await supabase.rpc('seed_test_data', { p_app_id: appId }) + const { error } = await supabase.rpc('reset_and_seed_app_data', { p_app_id: appId }) if (error) throw error }