Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions app/entry.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'

// boot.server probes the database when it is evaluated; handleRequest only needs it to have run.
vi.mock('~/shared/infra/boot.server', () => ({}))

vi.mock('~/shared/infra/logger.server', () => ({
default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}))

vi.mock('~/i18n/paraglide/runtime', () => ({
baseLocale: 'fr',
isLocale: (value: unknown) => value === 'fr' || value === 'en',
overwriteGetLocale: vi.fn(),
}))

vi.mock('~/shared/utils/locale.server', () => ({
resolveDocumentLocale: vi.fn(),
}))

// Rendering is React's part: report the shell ready at once, so handleRequest answers.
vi.mock('react-dom/server', () => ({
renderToPipeableStream: (_element: unknown, options: { onShellReady?: () => void; onAllReady?: () => void }) => {
queueMicrotask(() => (options.onShellReady ?? options.onAllReady)?.())
return { pipe: (body: { end: () => void }) => body.end(), abort: () => {} }
},
}))

const { default: handleRequest } = await import('./entry.server')
const { resolveDocumentLocale } = await import('~/shared/utils/locale.server')

describe('handleRequest', () => {
// Regression: a subdomain no congregation owns was a 500 on every unknown path, because the
// redirect was thrown from here. It has to come back from resolveDocumentLocale and be the answer.
it('answers with the redirect resolveDocumentLocale hands back instead of rendering', async () => {
const redirect = new Response(null, { status: 302, headers: { Location: '/congregation-not-found' } })
vi.mocked(resolveDocumentLocale).mockResolvedValue({ redirect })
const responseHeaders = new Headers()

const response = await handleRequest(
new Request('https://inconnu.unitae.app/nope'),
404,
responseHeaders,
{} as never,
{} as never,
)

expect(response).toBe(redirect)
expect(responseHeaders.has('Set-Cookie')).toBe(false)
})

it('remembers a resolved locale in the locale cookie', async () => {
vi.mocked(resolveDocumentLocale).mockResolvedValue({ locale: 'en', persist: true })

const response = await handleRequest(
new Request('https://lyon.unitae.app/login'),
200,
new Headers(),
{} as never,
{} as never,
)

expect(response.headers.get('Set-Cookie')).toContain('PARAGLIDE_LOCALE=en;')
})

// A failed lookup renders in a fallback locale. Writing that one down replaced the visitor's own.
it('leaves the locale cookie alone when the locale is a fallback', async () => {
vi.mocked(resolveDocumentLocale).mockResolvedValue({ locale: 'fr', persist: false })

const response = await handleRequest(
new Request('https://lyon.unitae.app/login'),
200,
new Headers(),
{} as never,
{} as never,
)

expect(response.headers.get('Set-Cookie')).toBeNull()
})
})
15 changes: 10 additions & 5 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { renderToPipeableStream } from 'react-dom/server'
import type { EntryContext, RouterContextProvider } from 'react-router'
import { ServerRouter } from 'react-router'

import { baseLocale, isLocale, type Locale, overwriteGetLocale } from '~/i18n/paraglide/runtime'
import { baseLocale, type Locale, overwriteGetLocale } from '~/i18n/paraglide/runtime'
import logger from '~/shared/infra/logger.server'
import { resolveDocumentLocale } from '~/shared/utils/locale.server'

Expand All @@ -28,13 +28,18 @@ export default async function handleRequest(
_loadContext: RouterContextProvider,
) {
const resolved = await resolveDocumentLocale(request)
// A subdomain no congregation owns: send the visitor to the page that says so, as the loaders do.
// A subdomain no congregation owns. The sign-in loaders and the session guard send it to
// /congregation-not-found themselves; this covers the documents that get here without either
// (public shell pages, unmatched paths).
if ('redirect' in resolved) return resolved.redirect
const locale = isLocale(resolved.locale) ? resolved.locale : baseLocale
const { locale } = resolved

// Set the PARAGLIDE_LOCALE cookie via HTTP header so the client-side
// cookie strategy can read it during hydration (before any JS executes)
responseHeaders.append('Set-Cookie', `PARAGLIDE_LOCALE=${locale}; Path=/; Max-Age=34560000; SameSite=Lax`)
// cookie strategy can read it during hydration (before any JS executes).
// A fallback locale is not written: it would replace the one the visitor already has.
if (resolved.persist) {
responseHeaders.append('Set-Cookie', `PARAGLIDE_LOCALE=${locale}; Path=/; Max-Age=34560000; SameSite=Lax`)
}

return localeStore.run(locale, () => {
return isbot(request.headers.get('user-agent') || '')
Expand Down
6 changes: 3 additions & 3 deletions app/features/authentication/server/session.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@ describe('verifySession', () => {
expect(response.headers.get('Location')).toBe('/login?redirectTo=%2Fterritories%2F1%3Fx%3D2')
})

// Regression: a session outliving its congregation used to throw a plain Error out of the
// guard, which every authenticated page answered with a 500.
it("redirects to /login and drops the session when the account's congregation no longer exists", async () => {
// The account's congregation row is missing: a deletion racing the request. The guard used to let
// resolveCongregation's plain Error through, a 500 instead of the sign-in page.
it('sends an account whose congregation is gone to /login, without a return URL or its session', async () => {
setSession({ userId: '42', sessionEpoch: '0' })
vi.mocked(db.userAccount.findUnique).mockResolvedValue(fakeUser as never)
vi.mocked(resolveCongregationFromRequest).mockResolvedValue(null as never)
Expand Down
4 changes: 3 additions & 1 deletion app/features/authentication/server/session.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ export async function verifySession(request: Request) {

const congregation = await findCongregation(user.congregationId)
if (congregation == null) {
// The cookie outlived its congregation: drop it and send the visitor to sign in again.
// No congregation behind this account. The foreign key forbids that as a lasting state, so a
// deletion is racing this request: drop the cookie and send the visitor to sign in, without a
// return URL.
logger.warn(`verifySession: congregation ${user.congregationId} of userId ${user.id} no longer exists`)
return redirectToLogin(session)
}
Expand Down
89 changes: 69 additions & 20 deletions app/shared/domain/congregation.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ beforeEach(() => {
vi.resetAllMocks()
})

// Answers the slug lookup the way the table would: the row for that slug, or nothing. A test that
// reads the wrong slug from the host then gets no congregation instead of whatever was mocked.
function mockSlugLookup(rows: Record<string, object>) {
vi.mocked(db.congregation.findUnique).mockImplementation(((args: { where: { slug?: string } }) =>
Promise.resolve(rows[args.where.slug ?? ''] ?? null)) as never)
}

const baseCongregation = {
id: 1,
name: 'Congrégation Test',
Expand Down Expand Up @@ -180,11 +187,20 @@ describe('findCongregation', () => {
await expect(findCongregation(999)).resolves.toBeNull()
})

it('returns the same congregation as resolveCongregation when it exists', async () => {
vi.mocked(db.congregation.findUnique).mockResolvedValue(baseCongregation as never)
it('returns the mapped congregation when it exists', async () => {
vi.mocked(db.congregation.findUnique).mockResolvedValue({
...baseCongregation,
locale: 'en',
timezone: 'Europe/London',
} as never)

const found = await findCongregation(1)
expect(found).toEqual(await resolveCongregation(1))
await expect(findCongregation(1)).resolves.toMatchObject({
id: 1,
slug: 'test',
displayName: 'Congrégation Test',
locale: 'en',
timezone: 'Europe/London',
})
})
})

Expand Down Expand Up @@ -242,26 +258,55 @@ describe('resolveCongregationFromRequest', () => {
})
})

// Regression: the base domain itself was read as the slug "unitae.app" and redirected to
// /congregation-not-found (it serves /setup in production).
it('looks up no slug for the base domain itself', async () => {
// The self-hosting guide writes UNITAE_BASE_URL with its scheme: a subdomain has to resolve either way.
it.each([
'unitae.app',
'https://unitae.app',
'http://unitae.app',
'https://unitae.app/',
'https://unitae.app:8443',
'https://Unitae.App',
])('resolves a subdomain when UNITAE_BASE_URL is %s', async baseUrl => {
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = baseUrl
mockSlugLookup({ 'tenant-alpha': { id: 1, slug: 'tenant-alpha' } })

const result = await resolveCongregationFromRequest(makeRequest('https://tenant-alpha.unitae.app/'))
expect(result).toEqual({ id: 1, slug: 'tenant-alpha' })
})

// Set but left empty, the variable matched no host at all: every subdomain was served as the
// platform root, with nothing to say why.
it('reads an empty UNITAE_BASE_URL as unset', async () => {
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = ''
mockSlugLookup({ 'tenant-alpha': { id: 1, slug: 'tenant-alpha' } })

const result = await resolveCongregationFromRequest(makeRequest('https://tenant-alpha.unitae.app/'))
expect(result).toEqual({ id: 1, slug: 'tenant-alpha' })
})

// Regression: the base domain itself was read as the slug "unitae.app", so every lookup on it
// threw the /congregation-not-found redirect.
it('resolves no congregation for the base domain itself', async () => {
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = 'https://unitae.app'
// Whatever slug is read from the host would find this row: none may be read.
vi.mocked(db.congregation.findUnique).mockResolvedValue({ id: 9, slug: 'any-slug' } as never)
vi.mocked(db.congregation.findFirst).mockResolvedValue(null as never)

const result = await resolveCongregationFromRequest(makeRequest('https://unitae.app/setup'))
const result = await resolveCongregationFromRequest(makeRequest('https://unitae.app/'))
expect(result).toBeNull()
expect(db.congregation.findUnique).not.toHaveBeenCalled()
})

it('treats a host that only ends with the base domain as a custom domain, not a subdomain', async () => {
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = 'unitae.app'
vi.mocked(db.congregation.findUnique).mockResolvedValue({ id: 9, slug: 'any-slug' } as never)
vi.mocked(db.congregation.findFirst).mockResolvedValue({ id: 3, slug: 'lyon' } as never)

const result = await resolveCongregationFromRequest(makeRequest('https://notunitae.app/'))
expect(result).toEqual({ id: 3, slug: 'lyon' })
expect(db.congregation.findUnique).not.toHaveBeenCalled()
})

it('retourne null pour le domaine racine sans slug ni domaine personnalisé', async () => {
Expand Down Expand Up @@ -298,24 +343,28 @@ describe('getBrandingName — deterministic single-tenant pick', () => {
})

describe('getBrandingName — multi-tenant host', () => {
it('looks up no slug for the base domain itself', async () => {
it('brands the base domain as the platform, not as a congregation', async () => {
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = 'unitae.app'
// Whatever slug is read from the host would find this row: none may be read.
vi.mocked(db.congregation.findUnique).mockResolvedValue({ name: 'Slug Match', displayName: null } as never)
vi.mocked(db.congregation.findFirst).mockResolvedValue(null as never)

await expect(getBrandingName(new Request('https://unitae.app/setup'))).resolves.toBe('Unitae')
expect(db.congregation.findUnique).not.toHaveBeenCalled()
await expect(getBrandingName(new Request('https://unitae.app/'))).resolves.toBe('Unitae')
})

it("brands a subdomain with its congregation's name", async () => {
it.each([
'unitae.app',
'https://unitae.app',
'http://unitae.app',
'https://unitae.app/',
'https://unitae.app:8443',
'https://Unitae.App',
])("brands a subdomain with its congregation's name when UNITAE_BASE_URL is %s", async baseUrl => {
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = 'unitae.app'
vi.mocked(db.congregation.findUnique).mockResolvedValue({ name: 'Lyon Centre', displayName: null } as never)
process.env.UNITAE_BASE_URL = baseUrl
mockSlugLookup({ lyon: { name: 'Lyon Centre', displayName: null } })

await expect(getBrandingName(new Request('https://lyon.unitae.app/login'))).resolves.toBe('Lyon Centre')
expect(db.congregation.findUnique).toHaveBeenCalledWith({
where: { slug: 'lyon' },
select: { name: true, displayName: true },
})
})
})
21 changes: 12 additions & 9 deletions app/shared/domain/congregation.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { redirect } from 'react-router'

import { unscopedDb } from '~/shared/infra/db.server'
import { getBaseDomain } from '~/shared/utils/env.server'

const DEFAULT_PLATFORM_NAME = 'Unitae'
const DEFAULT_EMAIL_FROM = process.env.UNITAE_EMAIL_FROM ?? 'Unitae <noreply@unitae.app>'
Expand Down Expand Up @@ -41,8 +42,10 @@ export async function resolveCongregation(congregationId: number): Promise<Congr
}

/**
* Like {@link resolveCongregation}, for callers that can outlive the congregation they hold an id
* for — a session cookie, mostly: `null` lets them handle a deleted congregation instead of throwing.
* The congregation with this id, or `null` when no row has it. For the request path — the session
* guard, the document locale — which has to answer a missing row with a redirect or a fallback
* rather than an exception. {@link resolveCongregation} is the throwing variant, for callers where
* a missing congregation is a bug.
*/
export async function findCongregation(congregationId: number): Promise<CongregationInfo | null> {
const congregation = await unscopedDb.congregation.findUnique({
Expand Down Expand Up @@ -91,20 +94,20 @@ export function getPlatformName(): string {
/**
* The congregation slug in a `<slug>.<base domain>` hostname. `null` for the base domain itself — it
* is the platform, not a congregation — and for any other host, including one that merely ends with
* the same letters (`notunitae.app`).
* the same letters (`notunitae.app`) or has nothing before the dot.
*/
function slugFromHostname(hostname: string): string | null {
const appBaseUrl = (process.env.UNITAE_BASE_URL ?? 'unitae.app').replace('https://', '').replace('http://', '')
const suffix = `.${appBaseUrl}`
return hostname.endsWith(suffix) ? hostname.slice(0, -suffix.length) : null
const suffix = `.${getBaseDomain()}`
return hostname.endsWith(suffix) ? hostname.slice(0, -suffix.length) || null : null
}

/**
* Resolves the congregation matching the subdomain or custom domain from the request.
*
* - Returns `null` in single-tenant mode, or for a host that is neither a subdomain of the base domain
* nor a congregation's custom domain (the base domain itself among them).
* - Redirects to `/congregation-not-found` if a slug is present but doesn't match any congregation.
* - Returns `null` in single-tenant mode, and for any host that is neither `<slug>.<base domain>`
* nor a congregation's custom domain. That includes the base domain itself.
* - Throws a redirect to `/congregation-not-found` for `<slug>.<base domain>` when no congregation
* has that slug. The custom-domain lookup is not tried in that case.
*/
export async function resolveCongregationFromRequest(request: Request): Promise<{ id: number; slug: string } | null> {
if (process.env.UNITAE_MULTI_TENANT !== 'true') return null
Expand Down
49 changes: 48 additions & 1 deletion app/shared/utils/env.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ vi.mock('~/shared/infra/logger.server', () => ({
default: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}))

const ENV_KEYS = ['DB_URL', 'UNITAE_SESSION_SECRET', 'DB_RUNTIME_URL', 'UNITAE_CRON_SECRET'] as const
const ENV_KEYS = [
'DB_URL',
'UNITAE_SESSION_SECRET',
'DB_RUNTIME_URL',
'UNITAE_CRON_SECRET',
'UNITAE_BASE_URL',
'UNITAE_MULTI_TENANT',
] as const

describe('env.server', () => {
const original: Record<string, string | undefined> = {}
Expand Down Expand Up @@ -166,4 +173,44 @@ describe('env.server', () => {
expect(getSessionSecrets()).toEqual([])
})
})

describe('getBaseDomain', () => {
it.each([
['unitae.example.com', 'unitae.example.com'],
['https://unitae.example.com', 'unitae.example.com'],
['http://unitae.example.com', 'unitae.example.com'],
['https://unitae.example.com/', 'unitae.example.com'],
['https://unitae.example.com:8443', 'unitae.example.com'],
['https://Unitae.Example.com', 'unitae.example.com'],
])('reads the hostname out of %s', async (value, hostname) => {
const { getBaseDomain } = await import('./env.server')
process.env.UNITAE_BASE_URL = value
expect(getBaseDomain()).toBe(hostname)
})

it('falls back to unitae.app when the var is unset', async () => {
const { getBaseDomain } = await import('./env.server')
expect(getBaseDomain()).toBe('unitae.app')
})

it.each(['', 'not a host'])('falls back to unitae.app when the var is %j', async value => {
const { getBaseDomain } = await import('./env.server')
process.env.UNITAE_BASE_URL = value
expect(getBaseDomain()).toBe('unitae.app')
})
})

// A value that names no host leaves subdomains resolved against the default domain. Boot reports
// it and carries on: an instance that only serves custom domains runs fine without one.
it.each([
'',
'not a host',
])('validateEnv does not refuse a multi-tenant boot when UNITAE_BASE_URL is %j', async value => {
const { validateEnv } = await import('./env.server')
process.env.DB_URL = 'postgresql://localhost/db'
process.env.UNITAE_SESSION_SECRET = 'a-sufficiently-long-test-session-secret-value'
process.env.UNITAE_MULTI_TENANT = 'true'
process.env.UNITAE_BASE_URL = value
expect(() => validateEnv()).not.toThrow()
})
})
Loading
Loading