diff --git a/.env.docker.example b/.env.docker.example index 2bd3f938..08fa0a04 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -15,6 +15,18 @@ NEXT_PUBLIC_SELF_HOSTED=true # Optional dedicated HMAC secret; otherwise SUPABASE_SERVICE_ROLE_KEY is used. # SESSION_TIMEOUT_SECRET= +# Set to true when public signup is turned off in your GoTrue/Supabase auth +# config (GOTRUE_DISABLE_SIGNUP / "Allow new users to sign up" off). GoTrue +# offers no clean server-side read of that setting, so this flag mirrors it. +# When true, inviting a teammate who has no account provisions the account +# server-side via the auth admin invite API (GoTrue must have SMTP configured +# to deliver that mail) instead of relying on public /register, which GoTrue +# would reject with "Signups not allowed". +# The GoTrue redirect URI allow-list (URI Allow List / GOTRUE_URI_ALLOW_LIST) +# must include /invite/* or the invite email's redirect silently falls back +# to SITE_URL. +# AUTH_SIGNUPS_DISABLED=false + # Optional: WebSocket origin allowed for Supabase Realtime in the CSP. # Defaults to NEXT_PUBLIC_SUPABASE_URL with https:// replaced by wss:// # (http:// by ws://). Set only if Realtime is served from another origin. diff --git a/.env.example b/.env.example index 9ec7881e..4b8f03f6 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,19 @@ CRON_SECRET=generate-a-random-secret # NEXT_PUBLIC_SESSION_WARNING_MS=120000 # SESSION_TIMEOUT_SECRET= +# Self-hosted only: set to true when public signup is turned off in your +# GoTrue/Supabase auth config (GOTRUE_DISABLE_SIGNUP / "Allow new users to +# sign up" off). GoTrue offers no clean server-side read of that setting, so +# this flag mirrors it. When true, inviting a teammate who has no account +# provisions the account server-side via the auth admin invite API (GoTrue +# must have SMTP configured to deliver that mail) instead of relying on +# public /register, which GoTrue would reject with "Signups not allowed". +# The GoTrue redirect URI allow-list (URI Allow List / GOTRUE_URI_ALLOW_LIST) +# must include /invite/* or the invite email's redirect silently falls back +# to SITE_URL. +# Hosted keeps this unset: public signup stays open there. +# AUTH_SIGNUPS_DISABLED=false + # ── Optional: extension features (core runs without these) ─ # AI features # ANTHROPIC_API_KEY= diff --git a/app/(auth)/auth/callback/__tests__/route.test.ts b/app/(auth)/auth/callback/__tests__/route.test.ts index a226da60..2324fb52 100644 --- a/app/(auth)/auth/callback/__tests__/route.test.ts +++ b/app/(auth)/auth/callback/__tests__/route.test.ts @@ -85,3 +85,41 @@ describe('GET /auth/callback: recovery flow', () => { ) }) }) + +describe('GET /auth/callback: admin invite flow (type=invite)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes a verified invite to /reset-password and preserves the invite token from next', async () => { + verifyOtp.mockResolvedValue({ error: null }) + + const request = new NextRequest( + 'http://localhost:3000/auth/callback?token_hash=abc&type=invite&next=/invite/gnubok_inv_tok123' + ) + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password') + expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'abc', type: 'invite' }) + // The company invite token is persisted as the pre-auth invite cookie so + // the reset-password handoff can accept the membership after the + // password is set. + expect(response.headers.get('set-cookie') ?? '').toContain( + 'gnubok-invite-token=gnubok_inv_tok123' + ) + }) + + it('routes a verified invite without an invite path in next to /reset-password without the cookie', async () => { + verifyOtp.mockResolvedValue({ error: null }) + + const request = new NextRequest( + 'http://localhost:3000/auth/callback?token_hash=abc&type=invite' + ) + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password') + expect(response.headers.get('set-cookie') ?? '').not.toContain('gnubok-invite-token') + }) +}) diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index d726e1ab..a0771e9f 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -1,6 +1,7 @@ import { createServerClient } from '@supabase/ssr' import { type NextRequest, NextResponse } from 'next/server' import { hashInviteToken } from '@/lib/auth/invite-tokens' +import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie' export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url) @@ -67,6 +68,34 @@ export async function GET(request: NextRequest) { return response } + // Admin-provisioned invite (auth.admin.inviteUserByEmail, used when the + // installation runs with signups disabled): the invited user now has a + // verified session but no password. Reuse the recovery surface so they + // set one before anything else. The company invite token travels in + // `next` (/invite/); persist it as the pre-auth invite cookie so + // the reset-password invite handoff accepts the membership right after + // the password is saved. + if (type === 'invite') { + const response = NextResponse.redirect(new URL('/reset-password', origin)) + for (const { name, value, options } of pendingCookies) { + response.cookies.set({ name, value, ...options }) + } + const inviteTokenMatch = next.match(/^\/invite\/([A-Za-z0-9_-]+)$/) + if (inviteTokenMatch) { + // Mirrors buildInviteCookie in app/invite/[token]/page.tsx: readable + // by the client auth surfaces (not httpOnly), lifetime matching the + // 7-day invite TTL that the server re-checks on every acceptance. + response.cookies.set(INVITE_COOKIE_NAME, inviteTokenMatch[1], { + path: '/', + httpOnly: false, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, + }) + } + return response + } + const { data: { user } } = await supabase.auth.getUser() if (user) { // Check MFA status: redirect to verify if factor is enrolled but session is AAL1 diff --git a/app/api/company/members/invite/__tests__/route.test.ts b/app/api/company/members/invite/__tests__/route.test.ts index f48a7b9d..acc294e0 100644 --- a/app/api/company/members/invite/__tests__/route.test.ts +++ b/app/api/company/members/invite/__tests__/route.test.ts @@ -1,12 +1,19 @@ /** * Tests for POST /api/company/members/invite. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase() +// The queued mock's auth object only carries getUser; the provisioning path +// (AUTH_SIGNUPS_DISABLED=true) also calls auth.admin.inviteUserByEmail. +const inviteUserByEmailMock = vi.fn() +Object.assign(serviceSupabase.auth, { + admin: { inviteUserByEmail: inviteUserByEmailMock }, +}) + const requireAuthMock = vi.fn() vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: (...args: unknown[]) => requireAuthMock(...args), @@ -59,6 +66,7 @@ function post(body: unknown) { beforeEach(() => { vi.clearAllMocks() reset() + delete process.env.AUTH_SIGNUPS_DISABLED requireAuthMock.mockResolvedValue({ user: { id: 'user-1', email: 'owner@example.com' }, supabase: {}, @@ -67,6 +75,11 @@ beforeEach(() => { requireWriteMock.mockResolvedValue({ ok: true }) isConfiguredMock.mockReturnValue(true) sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' }) + inviteUserByEmailMock.mockResolvedValue({ data: { user: { id: 'new-user' } }, error: null }) +}) + +afterEach(() => { + delete process.env.AUTH_SIGNUPS_DISABLED }) describe('POST /api/company/members/invite', () => { @@ -140,3 +153,160 @@ describe('POST /api/company/members/invite', () => { expect(body.data.email_sent).toBe(false) }) }) + +describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning', () => { + it('leaves behavior unchanged when the flag is unset: no existence check, no admin call', async () => { + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: null }) // insert invitation + + const { status, body } = await parseJsonResponse<{ + data: { email_sent: boolean; user_provisioned: boolean } + }>(await post({ email: 'client@example.com' })) + + expect(status).toBe(200) + expect(body.data.email_sent).toBe(true) + expect(body.data.user_provisioned).toBe(false) + expect(serviceSupabase.rpc).not.toHaveBeenCalled() + expect(inviteUserByEmailMock).not.toHaveBeenCalled() + }) + + it('flag on + account exists: skips provisioning, invite proceeds normally', async () => { + process.env.AUTH_SIGNUPS_DISABLED = 'true' + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: true }) // rpc check_email_exists -> account exists + enqueue({ data: null }) // insert invitation + + const { status, body } = await parseJsonResponse<{ + data: { email_sent: boolean; user_provisioned: boolean } + }>(await post({ email: 'client@example.com' })) + + expect(status).toBe(200) + expect(serviceSupabase.rpc).toHaveBeenCalledWith('check_email_exists', { + email_to_check: 'client@example.com', + }) + expect(inviteUserByEmailMock).not.toHaveBeenCalled() + expect(body.data.email_sent).toBe(true) + expect(body.data.user_provisioned).toBe(false) + }) + + it('flag on + no account: provisions via admin invite with the invite redirect', async () => { + process.env.AUTH_SIGNUPS_DISABLED = 'true' + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: false }) // rpc check_email_exists -> no account + enqueue({ data: null }) // insert invitation + + const { status, body } = await parseJsonResponse<{ + data: { email_sent: boolean; user_provisioned: boolean } + }>(await post({ email: 'Client@Example.com' })) + + expect(status).toBe(200) + expect(inviteUserByEmailMock).toHaveBeenCalledTimes(1) + // Provision with the lowercased email (the invitation row and GoTrue + // both lowercase; /api/team/accept enforces exact email match) and a + // redirect that lands back on this invitation. + expect(inviteUserByEmailMock).toHaveBeenCalledWith('client@example.com', { + redirectTo: expect.stringContaining('/invite/tok-plain'), + }) + expect(body.data.user_provisioned).toBe(true) + expect(body.data.email_sent).toBe(true) + }) + + it('flag on + provisioning fails: surfaces a Swedish error, sends nothing, logs a masked address', async () => { + process.env.AUTH_SIGNUPS_DISABLED = 'true' + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: false }) // rpc check_email_exists -> no account + // Mimic a real GoTrue failure (AuthApiError is an Error instance): + // SMTP not configured is the typical self-hosted cause. + const authError = Object.assign(new Error('Error sending invite email'), { + code: 'unexpected_failure', + status: 500, + }) + inviteUserByEmailMock.mockResolvedValue({ data: { user: null }, error: authError }) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { status, body } = await parseJsonResponse<{ error: string }>( + await post({ email: 'client@example.com' }) + ) + + expect(status).toBe(502) + expect(body.error).toContain('SMTP') + expect(sendEmailMock).not.toHaveBeenCalled() + // The failure log carries only a masked invitee address (PII stays out + // of the log record; the mask survives the logger's own redaction). + const logged = consoleErrorSpy.mock.calls + .flat() + .map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))) + .join(' ') + expect(logged).toContain('c***@example.com') + expect(logged).not.toContain('client@example.com') + consoleErrorSpy.mockRestore() + }) + + it('flag on + existence check errors (RPC missing): warns and provisions anyway', async () => { + process.env.AUTH_SIGNUPS_DISABLED = 'true' + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + // The exact failure shape a deployment without migration + // 20260804140000 produces: PostgREST cannot find the function. + enqueue({ + data: null, + error: { + message: 'Could not find the function public.check_email_exists(email_to_check) in the schema cache', + code: 'PGRST202', + }, + }) // rpc check_email_exists -> error + enqueue({ data: null }) // insert invitation + + const { status, body } = await parseJsonResponse<{ + data: { email_sent: boolean; user_provisioned: boolean } + }>(await post({ email: 'client@example.com' })) + + // The route logs a warning and treats GoTrue as the authority: it + // attempts provisioning anyway rather than silently skipping the + // invitee, and a duplicate would surface from GoTrue itself. + expect(status).toBe(200) + expect(serviceSupabase.rpc).toHaveBeenCalledWith('check_email_exists', { + email_to_check: 'client@example.com', + }) + expect(inviteUserByEmailMock).toHaveBeenCalledTimes(1) + expect(body.data.user_provisioned).toBe(true) + expect(body.data.email_sent).toBe(true) + }) + + it('flag on + admin reports the email already registered: treated as existing account', async () => { + process.env.AUTH_SIGNUPS_DISABLED = 'true' + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: false }) // rpc check_email_exists -> stale answer + enqueue({ data: null }) // insert invitation + const authError = Object.assign( + new Error('A user with this email address has already been registered'), + { code: 'email_exists', status: 422 }, + ) + inviteUserByEmailMock.mockResolvedValue({ data: { user: null }, error: authError }) + + const { status, body } = await parseJsonResponse<{ + data: { email_sent: boolean; user_provisioned: boolean } + }>(await post({ email: 'client@example.com' })) + + expect(status).toBe(200) + expect(body.data.user_provisioned).toBe(false) + expect(body.data.email_sent).toBe(true) + }) +}) diff --git a/app/api/company/members/invite/route.ts b/app/api/company/members/invite/route.ts index 0f03b76e..4e390609 100644 --- a/app/api/company/members/invite/route.ts +++ b/app/api/company/members/invite/route.ts @@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { getEmailService } from '@/lib/email/service' import { generateInviteEmailSubject, @@ -23,6 +24,19 @@ const InviteSchema = z.object({ role: z.enum(['admin', 'member', 'viewer']).default('viewer'), }) +/** + * First local-part character + *** + domain, e.g. "j***@example.com". + * Keeps invitee PII out of the log record while leaving enough to tell + * WHICH invite failed. The logger's own redaction would otherwise replace + * a raw address with [REDACTED_EMAIL] (lib/observability/redact.ts); the + * masked form does not match that email pattern, so it survives intact. + */ +function maskEmail(email: string): string { + const at = email.indexOf('@') + if (at <= 0) return '***' + return `${email[0]}***${email.slice(at)}` +} + /** * POST /api/company/members/invite * Invite a user to the current company (e.g., a client as viewer). @@ -97,6 +111,66 @@ export const POST = withRouteContext( const { token, hash } = generateInviteToken() const expiresAt = getInviteExpiry() + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + + // Self-hosted installations that turn public signup off in GoTrue + // (disable_signup) set AUTH_SIGNUPS_DISABLED=true to mirror that config: + // GoTrue offers no clean server-side read of the setting. Without this, + // an invitee with no account is routed to /register, where + // supabase.auth.signUp is rejected with "Signups not allowed for this + // instance" and the invite dead-ends. Provision the account via the auth + // admin invite API instead. Hosted keeps the flag unset: nothing in this + // block runs and behavior is unchanged. + const signupsDisabled = process.env.AUTH_SIGNUPS_DISABLED === 'true' + let userProvisioned = false + if (signupsDisabled) { + const { data: emailExists, error: existsError } = await serviceClient.rpc( + 'check_email_exists', + { email_to_check: email }, + ) + if (existsError) { + // GoTrue is the authority: attempt provisioning anyway and let a + // duplicate surface there instead of silently skipping the invitee. + log.warn('check_email_exists failed; attempting provisioning anyway', { + message: existsError.message, + }) + } + + if (!emailExists) { + // Provision BEFORE the invitation row is written: a failure here + // leaves nothing half-created behind, so the admin can retry cleanly + // after fixing the cause (typically GoTrue SMTP configuration). + // The redirect lands the invitee back on the invite page with a + // session; /auth/callback routes type=invite verifications to the + // set-password surface first. + const { error: provisionError } = await serviceClient.auth.admin.inviteUserByEmail( + email, + { redirectTo: `${appUrl}/invite/${token}` }, + ) + + if (provisionError) { + const alreadyRegistered = + provisionError.code === 'email_exists' || + /already been registered/i.test(provisionError.message) + if (!alreadyRegistered) { + // Never report a silently-successful invite when the invitee + // cannot actually get an account. + log.error('invitee auth provisioning failed', new Error(provisionError.message), { + to: maskEmail(email), + }) + return NextResponse.json( + { error: getErrorMessage(provisionError, { context: 'auth', statusCode: 502 }) }, + { status: 502 }, + ) + } + // The account exists after all (stale check_email_exists answer): + // proceed exactly as for an existing user. + } else { + userProvisioned = true + } + } + } + // Upsert invitation if (existingInvite) { const { error } = await serviceClient @@ -134,7 +208,6 @@ export const POST = withRouteContext( // Send email. email_sent is surfaced in the response so the UI can tell // the user when the invitation exists but the mail never went out: // previously a send failure was invisible (invite looked sent). - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' const emailService = getEmailService() let emailSent = false if (emailService.isConfigured()) { @@ -172,6 +245,7 @@ export const POST = withRouteContext( email, status: 'pending', email_sent: emailSent, + user_provisioned: userProvisioned, ...(isDev && { inviteUrl: devInviteUrl }), }, }) diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index 8af007bd..34bf67bf 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -461,3 +461,34 @@ describe('getErrorMessage: Swedish heuristic covers real route sentences', () => ) }) }) + +describe('getErrorMessage: GoTrue auth error patterns', () => { + it('maps "Signups not allowed for this instance" to the closed-installation message', () => { + // Shape mirrors a real AuthApiError: an Error instance carrying a GoTrue + // error code that the structured registry does not know. + const authError = Object.assign(new Error('Signups not allowed for this instance'), { + code: 'signup_disabled', + status: 422, + }) + const msg = getErrorMessage(authError, { context: 'auth' }) + expect(msg).toBe( + 'Kontoregistrering är avstängd på den här installationen. Kontakta den som bjöd in dig eller din administratör för att få ett konto.', + ) + }) + + it('maps a plain "Signups not allowed" string as well', () => { + const msg = getErrorMessage('Signups not allowed for this instance', { context: 'auth' }) + expect(msg).toContain('avstängd på den här installationen') + }) + + it('maps GoTrue "Error sending invite email" to the SMTP guidance message', () => { + const authError = Object.assign(new Error('Error sending invite email'), { + code: 'unexpected_failure', + status: 500, + }) + const msg = getErrorMessage(authError, { context: 'auth', statusCode: 502 }) + expect(msg).toBe( + 'E-postmeddelandet kunde inte skickas av autentiseringstjänsten. Kontrollera installationens SMTP-inställningar och försök igen.', + ) + }) +}) diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 262ab136..acec9c5f 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -142,6 +142,19 @@ const ERROR_PATTERN_MAP: [RegExp, string | null][] = [ /already has a journal entry/i, 'Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.', ], + [ + // GoTrue rejects supabase.auth.signUp with this when the installation + // runs with disable_signup (closed self-hosted instances). The invitee + // cannot fix it themselves: point them to whoever runs the installation. + /signups? not allowed/i, + 'Kontoregistrering är avstängd på den här installationen. Kontakta den som bjöd in dig eller din administratör för att få ett konto.', + ], + [ + // GoTrue could not send its own mail (admin invite, confirmation, + // recovery): almost always missing SMTP configuration on self-hosted. + /error sending (invite|confirmation|recovery|magic link) email/i, + 'E-postmeddelandet kunde inte skickas av autentiseringstjänsten. Kontrollera installationens SMTP-inställningar och försök igen.', + ], ] /** diff --git a/supabase/migrations/20260804140000_restore_check_email_exists_rpc.sql b/supabase/migrations/20260804140000_restore_check_email_exists_rpc.sql new file mode 100644 index 00000000..a6fe6afb --- /dev/null +++ b/supabase/migrations/20260804140000_restore_check_email_exists_rpc.sql @@ -0,0 +1,37 @@ +-- ============================================================================= +-- Restore public.check_email_exists +-- ============================================================================= +-- +-- This function shipped in PR #229 and was then lost in the #244 migration +-- consolidation before it ever reached prod: it exists in no deployed +-- environment today. The invite flow still calls it via the service client +-- (app/api/team/accept/route.ts and app/api/company/members/invite/route.ts), +-- so on every deployment the RPC error is silently swallowed, the +-- "already has an account" answer resolves to null, and the invite page +-- routes even existing-account invitees toward /register. +-- +-- Restored exactly as originally shipped. SECURITY DEFINER because it reads +-- auth.users; execution is service-role only (the two routes above call it +-- through createServiceClient()) so it cannot be used for email enumeration +-- by anon or authenticated clients. + +CREATE OR REPLACE FUNCTION public.check_email_exists(email_to_check text) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM auth.users WHERE lower(email) = lower(email_to_check) + ); +$$; + +-- Belt and braces: PUBLIC covers the default grant, but revoke the two +-- browser-facing roles explicitly as well in case a future default-privilege +-- change re-grants them. +REVOKE EXECUTE ON FUNCTION public.check_email_exists(text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.check_email_exists(text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.check_email_exists(text) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.check_email_exists(text) TO service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/check-email-exists.pg.test.ts b/tests/pg/check-email-exists.pg.test.ts new file mode 100644 index 00000000..af15cd6d --- /dev/null +++ b/tests/pg/check-email-exists.pg.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { getPool, getClient, runAsServiceRole } from './setup' +import { insertAuthUser } from './fixtures' + +/** + * Migration 20260804140000_restore_check_email_exists_rpc.sql restores + * public.check_email_exists, which shipped in PR #229 and was lost in the + * #244 migration consolidation before it ever reached prod. The invite flow + * (app/api/team/accept and app/api/company/members/invite) calls it via the + * service client to decide whether an invitee already has an account. It + * reads auth.users under SECURITY DEFINER, so execution must stay + * service-role only: exposing it to anon or authenticated would be an email + * enumeration oracle. These tests lock both the semantics and the grants in. + */ +describe('check_email_exists RPC (pg)', () => { + let seededUserId: string + let seededEmail: string + + beforeAll(async () => { + seededUserId = await insertAuthUser() + // insertAuthUser stores the email as pg-real-@test.invalid. + seededEmail = `pg-real-${seededUserId}@test.invalid` + }) + + it('exists in the schema (regression: lost in the #244 consolidation)', async () => { + const res = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = 'check_email_exists'`, + ) + expect(res.rows[0]!.n).toBe(1) + }) + + it('returns true for an existing auth user', async () => { + const res = await getPool().query<{ found: boolean }>( + `SELECT public.check_email_exists($1) AS found`, + [seededEmail], + ) + expect(res.rows[0]!.found).toBe(true) + }) + + it('matches case-insensitively on both sides', async () => { + const res = await getPool().query<{ found: boolean }>( + `SELECT public.check_email_exists($1) AS found`, + [seededEmail.toUpperCase()], + ) + expect(res.rows[0]!.found).toBe(true) + }) + + it('returns false for an unknown email', async () => { + const res = await getPool().query<{ found: boolean }>( + `SELECT public.check_email_exists($1) AS found`, + ['nobody-here@test.invalid'], + ) + expect(res.rows[0]!.found).toBe(false) + }) + + async function expectExecutionDenied(role: 'anon' | 'authenticated') { + const client = await getClient() + try { + await client.query('BEGIN') + await client.query(`SET LOCAL ROLE ${role}`) + await expect( + client.query(`SELECT public.check_email_exists('probe@test.invalid')`), + ).rejects.toThrow(/permission denied/i) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + } + + it('denies execution to the anon role', async () => { + await expectExecutionDenied('anon') + }) + + it('denies execution to the authenticated role', async () => { + await expectExecutionDenied('authenticated') + }) + + it('allows execution to the service_role role', async () => { + const found = await runAsServiceRole(async (client) => { + const res = await client.query<{ found: boolean }>( + `SELECT public.check_email_exists($1) AS found`, + [seededEmail], + ) + return res.rows[0]!.found + }) + expect(found).toBe(true) + }) +})