From 6115b29627754b1e681a676e0d833f4af2a5780d Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 30 Apr 2026 16:45:20 +0200 Subject: [PATCH 1/4] fix(api): hide private update channels --- supabase/functions/_backend/utils/pg.ts | 5 + tests/updates.test.ts | 117 ++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 57ec7fe65c..241c60058f 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -494,6 +494,11 @@ export function requestInfosChannelPostgres( : and( eq(channelAlias.app_id, app_id), eq(channelAlias.name, defaultChannel), + eq(platformQuery, true), + or( + eq(channelAlias.public, true), + eq(channelAlias.allow_device_self_set, true), + ), ), ) .groupBy(channelAlias.id, versionAlias.id) diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 23cfaf4e3b..6e25e53912 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -14,6 +14,8 @@ interface UpdateRes { version?: string message?: string manifest?: { file_name: string | null, file_hash?: string | null, download_url?: string | null }[] + old?: string + major?: boolean } const updateNewScheme = z.object({ @@ -524,8 +526,8 @@ describe('[POST] /updates parallel tests', () => { const uuid = randomUUID().toLowerCase() const baseData = getBaseData(APP_NAME_UPDATE) - baseData.device_id = uuid; - (baseData as any).defaultChannel = 'beta' + baseData.device_id = uuid + baseData.defaultChannel = 'beta' const response = await postUpdate(baseData) expect(response.status).toBe(200) @@ -534,6 +536,103 @@ describe('[POST] /updates parallel tests', () => { expect(() => updateNewScheme.parse(json)).not.toThrow() expect(json.version).toBe('1.361.0') }) + + it('hides private defaultChannel before major upgrade checks', async () => { + const supabase = getSupabaseClient() + const versionName = `9.9.${Math.floor(Math.random() * 100000) + 1000}` + const channelName = `private-noself-${randomUUID().slice(0, 8)}` + + const version = await createAppVersions(versionName, APP_NAME_UPDATE) + await supabase + .from('app_versions') + .update({ external_url: `https://example.com/${channelName}.zip` }) + .eq('id', version.id) + .throwOnError() + + await supabase + .from('channels') + .insert({ + name: channelName, + app_id: APP_NAME_UPDATE, + version: version.id, + owner_org: ORG_ID, + created_by: USER_ID, + public: false, + disable_auto_update_under_native: false, + disable_auto_update: 'major', + allow_device_self_set: false, + allow_emulator: true, + allow_device: true, + allow_dev: true, + allow_prod: true, + ios: true, + android: true, + electron: true, + }) + .throwOnError() + + const baseData = getBaseData(APP_NAME_UPDATE) + baseData.defaultChannel = channelName + baseData.version_build = '0.0.1' + baseData.version_name = '0.0.1' + + const response = await postUpdate(baseData) + expect(response.status).toBe(200) + + const json = await response.json() + expect(json.error).toBe('no_channel') + expect(json.version).toBeUndefined() + expect(json.old).toBeUndefined() + expect(json.major).toBeUndefined() + }) + + it('hides platform-incompatible private defaultChannel before platform checks', async () => { + const supabase = getSupabaseClient() + const versionName = `9.8.${Math.floor(Math.random() * 100000) + 1000}` + const channelName = `private-iosonly-${randomUUID().slice(0, 8)}` + + const version = await createAppVersions(versionName, APP_NAME_UPDATE) + await supabase + .from('app_versions') + .update({ external_url: `https://example.com/${channelName}.zip` }) + .eq('id', version.id) + .throwOnError() + + await supabase + .from('channels') + .insert({ + name: channelName, + app_id: APP_NAME_UPDATE, + version: version.id, + owner_org: ORG_ID, + created_by: USER_ID, + public: false, + disable_auto_update_under_native: false, + disable_auto_update: 'none', + allow_device_self_set: true, + allow_emulator: true, + allow_device: true, + allow_dev: true, + allow_prod: true, + ios: true, + android: false, + electron: false, + }) + .throwOnError() + + const baseData = getBaseData(APP_NAME_UPDATE) + baseData.defaultChannel = channelName + baseData.version_build = '0.0.1' + baseData.version_name = '0.0.1' + + const response = await postUpdate(baseData) + expect(response.status).toBe(200) + + const json = await response.json() + expect(json.error).toBe('no_channel') + expect(json.version).toBeUndefined() + expect(json.old).toBeUndefined() + }) }) describe('[POST] /updates invalid data', () => { @@ -864,14 +963,14 @@ describe('update scenarios', () => { } }) - it('cannot update via private channel', async () => { + it('hides private channel that does not allow self-assignment', async () => { // First reset the channel to ensure it's working properly await updateChannel('production', { public: true, allow_device_self_set: true, }) - // Now set both conditions for the error + // Now set both conditions that make the channel private. await updateChannel('production', { public: false, allow_device_self_set: false, @@ -879,15 +978,15 @@ describe('update scenarios', () => { const baseData = getBaseData(APP_NAME_UPDATE) baseData.version_name = '1.1.0' - // Need to specify defaultChannel so the non-public channel can be found - ;(baseData as any).defaultChannel = 'production' + // A caller-supplied defaultChannel must not reveal that this private channel exists. + baseData.defaultChannel = 'production' try { const response = await postUpdate(baseData) expect(response.status).toBe(200) const json = await response.json() - expect(json.error).toBe('cannot_update_via_private_channel') - expect(json.message).toContain('Cannot update via a private channel') + expect(json.error).toBe('no_channel') + expect(json.version).toBeUndefined() } finally { await updateChannel('production', { @@ -937,7 +1036,7 @@ describe('update scenarios', () => { const baseData = getBaseData(APP_NAME_UPDATE) baseData.device_id = uuid baseData.version_name = '1.1.0' - ;(baseData as any).defaultChannel = 'production' + baseData.defaultChannel = 'production' const response = await postUpdate(baseData) expect(response.status).toBe(200) From d1746c63eb662483ed8779aeee51a06e81af5605 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 30 Apr 2026 17:03:36 +0200 Subject: [PATCH 2/4] test(api): cover self-set update channels --- tests/updates.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 6e25e53912..308e7c3020 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -572,6 +572,7 @@ describe('[POST] /updates parallel tests', () => { .throwOnError() const baseData = getBaseData(APP_NAME_UPDATE) + baseData.platform = 'android' baseData.defaultChannel = channelName baseData.version_build = '0.0.1' baseData.version_name = '0.0.1' @@ -586,6 +587,55 @@ describe('[POST] /updates parallel tests', () => { expect(json.major).toBeUndefined() }) + it('allows private self-settable platform-compatible defaultChannel', async () => { + const supabase = getSupabaseClient() + const versionName = `9.7.${Math.floor(Math.random() * 100000) + 1000}` + const channelName = `private-selfset-${randomUUID().slice(0, 8)}` + + const version = await createAppVersions(versionName, APP_NAME_UPDATE) + await supabase + .from('app_versions') + .update({ external_url: `https://example.com/${channelName}.zip` }) + .eq('id', version.id) + .throwOnError() + + await supabase + .from('channels') + .insert({ + name: channelName, + app_id: APP_NAME_UPDATE, + version: version.id, + owner_org: ORG_ID, + created_by: USER_ID, + public: false, + disable_auto_update_under_native: false, + disable_auto_update: 'none', + allow_device_self_set: true, + allow_emulator: true, + allow_device: true, + allow_dev: true, + allow_prod: true, + ios: false, + android: true, + electron: false, + }) + .throwOnError() + + const baseData = getBaseData(APP_NAME_UPDATE) + baseData.platform = 'android' + baseData.defaultChannel = channelName + baseData.version_build = '0.0.1' + baseData.version_name = '0.0.1' + + const response = await postUpdate(baseData) + expect(response.status).toBe(200) + + const json = await response.json() + expect(() => updateNewScheme.parse(json)).not.toThrow() + expect(json.version).toBe(versionName) + expect(json.error).toBeUndefined() + }) + it('hides platform-incompatible private defaultChannel before platform checks', async () => { const supabase = getSupabaseClient() const versionName = `9.8.${Math.floor(Math.random() * 100000) + 1000}` @@ -621,6 +671,7 @@ describe('[POST] /updates parallel tests', () => { .throwOnError() const baseData = getBaseData(APP_NAME_UPDATE) + baseData.platform = 'android' baseData.defaultChannel = channelName baseData.version_build = '0.0.1' baseData.version_name = '0.0.1' From 2a9b065344df46af90fdd1d27d8e38696aa7b99e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 30 Apr 2026 17:18:45 +0200 Subject: [PATCH 3/4] test(api): assert hidden update metadata --- tests/updates.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 308e7c3020..6c1ddecfc5 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -683,6 +683,7 @@ describe('[POST] /updates parallel tests', () => { expect(json.error).toBe('no_channel') expect(json.version).toBeUndefined() expect(json.old).toBeUndefined() + expect(json.major).toBeUndefined() }) }) From 091bb95d9ab1f160c52ade4ed92ff8c21f61bf8d Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 30 Apr 2026 17:34:37 +0200 Subject: [PATCH 4/4] test(api): assert hidden channel metadata --- tests/updates.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 6c1ddecfc5..0e3050f9ba 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -1039,6 +1039,8 @@ describe('update scenarios', () => { const json = await response.json() expect(json.error).toBe('no_channel') expect(json.version).toBeUndefined() + expect(json.old).toBeUndefined() + expect(json.major).toBeUndefined() } finally { await updateChannel('production', {