-
Notifications
You must be signed in to change notification settings - Fork 15
feat: add Installer for no-CLI platforms like Lovable #551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 12-11-add_migration_installer_api_endpoints_for_no-cli_platforms
Are you sure you want to change the base?
Conversation
|
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
View your CI Pipeline Execution ↗ for commit b7390c4
☁️ Nx Cloud last updated this comment at |
523aa71 to
6ab1501
Compare
4b6d099 to
7caca1d
Compare
6ab1501 to
4e0bf34
Compare
| import { assertEquals, assertMatch } from '@std/assert'; | ||
| import { | ||
| createInstallerHandler, | ||
| type InstallerDeps, | ||
| } from '../../../src/installer/server.ts'; | ||
|
|
||
| // Helper to create mock dependencies | ||
| function createMockDeps(overrides?: Partial<InstallerDeps>): InstallerDeps { | ||
| return { | ||
| fetch: () => Promise.resolve(new Response('{}', { status: 200 })), | ||
| getEnv: (key: string) => | ||
| ({ | ||
| SUPABASE_URL: 'https://test-project.supabase.co', | ||
| SUPABASE_SERVICE_ROLE_KEY: 'test-service-role-key', | ||
| })[key], | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| // Helper to create a request with optional token | ||
| function createRequest(token?: string): Request { | ||
| const url = token | ||
| ? `http://localhost/pgflow-installer?token=${token}` | ||
| : 'http://localhost/pgflow-installer'; | ||
| return new Request(url); | ||
| } | ||
|
|
||
| // ============================================================ | ||
| // Token validation tests | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - returns 401 when token missing', async () => { | ||
| const deps = createMockDeps(); | ||
| const handler = createInstallerHandler('expected-token', deps); | ||
|
|
||
| const request = createRequest(); // no token | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 401); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertMatch(data.message, /Invalid or missing token/); | ||
| }); | ||
|
|
||
| Deno.test('Installer Handler - returns 401 when token incorrect', async () => { | ||
| const deps = createMockDeps(); | ||
| const handler = createInstallerHandler('expected-token', deps); | ||
|
|
||
| const request = createRequest('wrong-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 401); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertMatch(data.message, /Invalid or missing token/); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Environment variable validation tests | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - returns 500 when SUPABASE_URL missing', async () => { | ||
| const deps = createMockDeps({ | ||
| getEnv: (key: string) => | ||
| ({ | ||
| SUPABASE_SERVICE_ROLE_KEY: 'test-key', | ||
| // SUPABASE_URL is undefined | ||
| })[key], | ||
| }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 500); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertMatch(data.message, /Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY/); | ||
| }); | ||
|
|
||
| Deno.test('Installer Handler - returns 500 when SUPABASE_SERVICE_ROLE_KEY missing', async () => { | ||
| const deps = createMockDeps({ | ||
| getEnv: (key: string) => | ||
| ({ | ||
| SUPABASE_URL: 'https://test.supabase.co', | ||
| // SUPABASE_SERVICE_ROLE_KEY is undefined | ||
| })[key], | ||
| }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 500); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertMatch(data.message, /Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY/); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Control Plane call tests - /secrets/configure | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - returns failure when /secrets/configure fails', async () => { | ||
| const mockFetch = (url: string) => { | ||
| if (url.includes('/secrets/configure')) { | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ error: 'Vault error' }), | ||
| { status: 500 } | ||
| ) | ||
| ); | ||
| } | ||
| return Promise.resolve(new Response('{}', { status: 200 })); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 500); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertEquals(data.secrets.success, false); | ||
| assertEquals(data.secrets.status, 500); | ||
| assertMatch(data.message, /Failed to configure vault secrets/); | ||
| }); | ||
|
|
||
| Deno.test('Installer Handler - skips migrations if secrets fail', async () => { | ||
| let migrationsCallMade = false; | ||
|
|
||
| const mockFetch = (url: string) => { | ||
| if (url.includes('/secrets/configure')) { | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ error: 'Vault error' }), | ||
| { status: 500 } | ||
| ) | ||
| ); | ||
| } | ||
| if (url.includes('/migrations/up')) { | ||
| migrationsCallMade = true; | ||
| } | ||
| return Promise.resolve(new Response('{}', { status: 200 })); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| await handler(request); | ||
|
|
||
| assertEquals(migrationsCallMade, false, 'migrations should not be called when secrets fail'); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Control Plane call tests - /migrations/up | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - returns failure when /migrations/up fails', async () => { | ||
| const mockFetch = (url: string) => { | ||
| if (url.includes('/secrets/configure')) { | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ success: true }), | ||
| { status: 200 } | ||
| ) | ||
| ); | ||
| } | ||
| if (url.includes('/migrations/up')) { | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ error: 'Migration error' }), | ||
| { status: 500 } | ||
| ) | ||
| ); | ||
| } | ||
| return Promise.resolve(new Response('{}', { status: 200 })); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 500); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertEquals(data.secrets.success, true); | ||
| assertEquals(data.migrations.success, false); | ||
| assertEquals(data.migrations.status, 500); | ||
| assertMatch(data.message, /Secrets configured but migrations failed/); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Success case | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - returns success when both calls succeed', async () => { | ||
| const mockFetch = (url: string) => { | ||
| if (url.includes('/secrets/configure')) { | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ success: true, message: 'Secrets stored' }), | ||
| { status: 200 } | ||
| ) | ||
| ); | ||
| } | ||
| if (url.includes('/migrations/up')) { | ||
| return Promise.resolve( | ||
| new Response( | ||
| JSON.stringify({ success: true, migrations: ['001_init'] }), | ||
| { status: 200 } | ||
| ) | ||
| ); | ||
| } | ||
| return Promise.resolve(new Response('{}', { status: 200 })); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 200); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, true); | ||
| assertEquals(data.secrets.success, true); | ||
| assertEquals(data.migrations.success, true); | ||
| assertMatch(data.message, /pgflow installed successfully/); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Control Plane endpoint construction tests | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - calls correct Control Plane endpoints', async () => { | ||
| const calledUrls: string[] = []; | ||
|
|
||
| const mockFetch = (url: string) => { | ||
| calledUrls.push(url); | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify({ success: true }), { status: 200 }) | ||
| ); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| await handler(request); | ||
|
|
||
| assertEquals(calledUrls.length, 2); | ||
| assertEquals( | ||
| calledUrls[0], | ||
| 'https://test-project.supabase.co/functions/v1/pgflow/secrets/configure' | ||
| ); | ||
| assertEquals( | ||
| calledUrls[1], | ||
| 'https://test-project.supabase.co/functions/v1/pgflow/migrations/up' | ||
| ); | ||
| }); | ||
|
|
||
| Deno.test('Installer Handler - sends Authorization header to Control Plane', async () => { | ||
| let capturedHeaders: Headers | undefined; | ||
|
|
||
| const mockFetch = (_url: string, init?: RequestInit) => { | ||
| capturedHeaders = new Headers(init?.headers); | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify({ success: true }), { status: 200 }) | ||
| ); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| await handler(request); | ||
|
|
||
| assertEquals( | ||
| capturedHeaders?.get('Authorization'), | ||
| 'Bearer test-service-role-key' | ||
| ); | ||
| assertEquals(capturedHeaders?.get('Content-Type'), 'application/json'); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Network error handling tests | ||
| // ============================================================ | ||
|
|
||
| Deno.test('Installer Handler - handles network error on /secrets/configure', async () => { | ||
| const mockFetch = (url: string) => { | ||
| if (url.includes('/secrets/configure')) { | ||
| return Promise.reject(new Error('Network connection failed')); | ||
| } | ||
| return Promise.resolve(new Response('{}', { status: 200 })); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 500); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertEquals(data.secrets.success, false); | ||
| assertEquals(data.secrets.status, 0); | ||
| assertMatch(data.secrets.error!, /Network connection failed/); | ||
| }); | ||
|
|
||
| Deno.test('Installer Handler - handles network error on /migrations/up', async () => { | ||
| const mockFetch = (url: string) => { | ||
| if (url.includes('/secrets/configure')) { | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify({ success: true }), { status: 200 }) | ||
| ); | ||
| } | ||
| if (url.includes('/migrations/up')) { | ||
| return Promise.reject(new Error('Connection timeout')); | ||
| } | ||
| return Promise.resolve(new Response('{}', { status: 200 })); | ||
| }; | ||
|
|
||
| const deps = createMockDeps({ fetch: mockFetch }); | ||
| const handler = createInstallerHandler('valid-token', deps); | ||
|
|
||
| const request = createRequest('valid-token'); | ||
| const response = await handler(request); | ||
|
|
||
| assertEquals(response.status, 500); | ||
| const data = await response.json(); | ||
| assertEquals(data.success, false); | ||
| assertEquals(data.secrets.success, true); | ||
| assertEquals(data.migrations.success, false); | ||
| assertEquals(data.migrations.status, 0); | ||
| assertMatch(data.migrations.error!, /Connection timeout/); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Tests mock the wrong implementation entirely
The tests mock a fetch function and test HTTP calls to Control Plane endpoints (/secrets/configure, /migrations/up), but the actual implementation in server.ts doesn't use HTTP at all.
The real implementation:
- Directly calls
configureSecrets(sql, supabaseUrl, serviceRoleKey)which executes SQL queries - Directly calls
runMigrations(sql)which usesMigrationRunner - Never makes any HTTP requests
The tests:
- Mock
fetch(not used by real code) - Test URLs like
https://test-project.supabase.co/functions/v1/pgflow/secrets/configure - Validate Authorization headers for HTTP calls that never happen
These tests will pass but provide zero coverage of the actual functionality. The real code could be completely broken and these tests would still pass.
Fix: Rewrite tests to:
- Mock the
postgresclient instead offetch - Test SQL query execution via
configureSecrets()andrunMigrations() - Remove all HTTP endpoint mocking and validation
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
4e0bf34 to
4903940
Compare
4903940 to
b7390c4
Compare
🔍 Preview Deployment: Website✅ Deployment successful! 🔗 Preview URL: https://pr-551.pgflow.pages.dev 📝 Details:
_Last updated: _ |

Add Installer Module for No-CLI Platforms
This PR introduces a new
Installermodule to support pgflow installation on platforms without CLI access, specifically targeting Lovable. The installer provides a secure way to run database migrations and configure vault secrets through a temporary Edge Function.Key features:
The PR also adds a new
InstallerPromptcomponent for the website that generates:This double security layer ensures the temporary installer function cannot be discovered or invoked by unauthorized parties.
The implementation is thoroughly tested with unit tests covering various scenarios including authentication, error handling, and successful installation flows.