import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { BASE_URL, createDirectApiKeyWithBindings, fetchTestRequest, getAuthHeaders, getSupabaseClient, headers, NON_ACCESS_APP_NAME, resetAndSeedAppData, resetAppData, USER_EMAIL, USER_ID, USER_ID_2, warmEdgeEndpoint } from './test-utils.ts' const id = randomUUID().replace(/-/g, '').slice(0, 12) const APPNAME = `com.app.error.${id}` const DELETE_APP_ID = `${APPNAME}.delete` const NOT_FOUND_APP_ID = `${APPNAME}.notfound` const PUT_APP_ID = `${APPNAME}.put` const CREATOR_APP_ID = `${APPNAME}.creator` const testOrgId = randomUUID() const testStripeCustomerId = `cus_app_error_${id}` let testApiKeyId: number | null = null let testHeaders: Record let authHeaders: Record beforeAll(async () => { authHeaders = await getAuthHeaders() await resetAndSeedAppData(APPNAME, { orgId: testOrgId, userId: USER_ID, stripeCustomerId: testStripeCustomerId, }) const createKey = await createDirectApiKeyWithBindings({ userId: USER_ID, key: `app-error-cases-${id}-${randomUUID()}`, name: `app-error-cases-${id}`, orgId: testOrgId, roleName: 'org_super_admin', }) if (!createKey?.id || !createKey.key) { throw new Error('Failed to create isolated API key for app error case tests') } testApiKeyId = createKey.id testHeaders = { 'Content-Type': 'application/json', 'Authorization': createKey.key, } await warmEdgeEndpoint(`${BASE_URL}/app`, { method: 'POST', headers: testHeaders, body: JSON.stringify({}), }) }, 60000) afterAll(async () => { await resetAppData(APPNAME) // Clean up any test apps created during tests await getSupabaseClient().from('apps').delete().eq('app_id', DELETE_APP_ID) await getSupabaseClient().from('apps').delete().eq('app_id', PUT_APP_ID) await getSupabaseClient().from('apps').delete().eq('app_id', CREATOR_APP_ID) await getSupabaseClient().from('apps').delete().eq('app_id', NOT_FOUND_APP_ID) if (testApiKeyId !== null) { await fetchTestRequest(`${BASE_URL}/apikey/${testApiKeyId}`, { method: 'DELETE', headers: authHeaders, }) } await getSupabaseClient().from('org_users').delete().eq('org_id', testOrgId) await getSupabaseClient().from('orgs').delete().eq('id', testOrgId) await getSupabaseClient().from('stripe_info').delete().eq('customer_id', testStripeCustomerId) }, 60000) describe('[POST] /app - Error Cases', () => { it('records the authenticated app creator', async () => { const response = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers: testHeaders, body: JSON.stringify({ owner_org: testOrgId, app_id: CREATOR_APP_ID, name: 'Creator Test App', }), }) expect(response.status).toBe(200) const data = await response.json() as { onboarding: { created_by_email?: string, created_by_user_id?: string } } expect(data.onboarding.created_by_email).toBe(USER_EMAIL) expect(data.onboarding.created_by_user_id).toBe(USER_ID) }) it('should return 400 when name is missing', async () => { const response = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers: testHeaders, body: JSON.stringify({ owner_org: testOrgId, app_id: `${APPNAME}.missingname`, // Missing name }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('missing_name') }) it('should return 403 when organization access is denied', async () => { const nonExistentOrgId = randomUUID() const response = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers: testHeaders, body: JSON.stringify({ app_id: `${APPNAME}.accessdenied`, name: 'Test App', owner_org: nonExistentOrgId, }), }) expect(response.status).toBe(403) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_access_organization') }) it('should return 403 when the user is not a member of the organization', async () => { const { data: org, error } = await getSupabaseClient().from('orgs').insert({ name: `Unauthorized Org ${id}`, created_by: USER_ID_2, management_email: `test-${id}@example.com`, }).select().single() expect(error).toBeNull() try { const response = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, body: JSON.stringify({ app_id: `${APPNAME}.nonmember`, owner_org: org!.id, name: 'Non-member App' }), }) expect(response.status).toBe(403) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_access_organization') } finally { await getSupabaseClient().from('apps').delete().eq('app_id', `${APPNAME}.nonmember`) await getSupabaseClient().from('orgs').delete().eq('id', org!.id) } }) it('should return 400 when owner_org is missing', async () => { const response = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, body: JSON.stringify({ app_id: `${APPNAME}.noorg`, name: 'No Org App' }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('missing_owner_org') }) it('should return 409 when app creation fails due to duplicate app id', async () => { // Try to create another app with the same app_id const response2 = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers: testHeaders, body: JSON.stringify({ app_id: APPNAME, // Same app_id as the one created in beforeAll name: APPNAME, owner_org: testOrgId, }), }) expect(response2.status).toBe(409) const data = await response2.json() as { error: string } expect(data.error).toBe('app_id_already_exists') }) it('should return 400 with invalid JSON body', async () => { const response = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers: testHeaders, body: 'invalid json', }) expect(response.status).toBeGreaterThanOrEqual(400) }) }) describe('[GET] /app - Error Cases', () => { it('should return 401 when user cannot access the app', async () => { const response = await fetchTestRequest(`${BASE_URL}/app/nonexistent.app`, { method: 'GET', headers: testHeaders, }) expect(response.status).toBe(401) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_access_app') }) it('should return 401 cannot_access_app when the app was deleted', async () => { // Create an app first to get access, then delete it to test 404 await resetAndSeedAppData(NOT_FOUND_APP_ID, { orgId: testOrgId, userId: USER_ID, stripeCustomerId: testStripeCustomerId, }) // Delete the app from database directly await getSupabaseClient().from('apps').delete().eq('app_id', NOT_FOUND_APP_ID) // Try to get the deleted app const response = await fetchTestRequest(`${BASE_URL}/app/${NOT_FOUND_APP_ID}`, { method: 'GET', headers: testHeaders, }) expect(response.status).toBe(401) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_access_app') }) it('should deny an app owned by an organization the user cannot access', async () => { // This test would need a more complex setup with different users // For now, we test with a response structure check const response = await fetchTestRequest(`${BASE_URL}/app/${NON_ACCESS_APP_NAME}`, { method: 'GET', headers: testHeaders, }) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_access_app') }) }) describe('[PUT] /app - Error Cases', () => { beforeAll(async () => { // Ensure the test app exists for PUT tests await resetAndSeedAppData(PUT_APP_ID, { orgId: testOrgId, userId: USER_ID, stripeCustomerId: testStripeCustomerId, }) }) it('should return 401 when user cannot access the app', async () => { const response = await fetchTestRequest(`${BASE_URL}/app/nonexistent.app`, { method: 'PUT', headers: testHeaders, body: JSON.stringify({ name: 'Updated Name', }), }) expect(response.status).toBe(401) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_access_app') }) it('should return cannot_update_app when the update fails', async () => { // Try to update with invalid data that would cause a database error const response = await fetchTestRequest(`${BASE_URL}/app/${PUT_APP_ID}`, { method: 'PUT', headers: testHeaders, body: JSON.stringify({ // Try to set owner_org to non-existent org (this might cause an error) owner_org: 'non-existent-org-id', }), }) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_update_app') }) it('should handle invalid JSON body', async () => { const response = await fetchTestRequest(`${BASE_URL}/app/${PUT_APP_ID}`, { method: 'PUT', headers: testHeaders, body: 'invalid json', }) expect(response.status).toBeGreaterThanOrEqual(400) }) }) describe('[DELETE] /app - Error Cases', () => { it('should return 400 when user cannot access the app', async () => { const response = await fetchTestRequest(`${BASE_URL}/app/nonexistent.app`, { method: 'DELETE', headers: testHeaders, }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_delete_app') }) it('should return 400 when app deletion fails', async () => { // Create an app to test deletion. Reset first so Vitest retries after a // previous successful delete do not reuse the seed cache for a missing row. await resetAppData(DELETE_APP_ID) await resetAndSeedAppData(DELETE_APP_ID, { orgId: testOrgId, userId: USER_ID, stripeCustomerId: testStripeCustomerId, }) // Try to delete the app (this should work) const response = await fetchTestRequest(`${BASE_URL}/app/${DELETE_APP_ID}`, { method: 'DELETE', headers: testHeaders, }) // The first delete should succeed expect(response.status).toBe(200) // Try to delete the same app again. Once the row is gone, the endpoint must // deny/fail before doing any destructive work. const response2 = await fetchTestRequest(`${BASE_URL}/app/${DELETE_APP_ID}`, { method: 'DELETE', headers: testHeaders, }) expect([400, 401]).toContain(response2.status) const data = await response2.json() as { error: string } expect(['cannot_delete_app', 'cannot_access_app']).toContain(data.error) }) })