Files
accounted/tests/pg/brand-signup-allowlist.pg.test.ts
T
MattssonandClaude Fable 5 4f6ecad549 feat(white-label): invite-only signup for brand domains (#1995)
* feat(white-label): invite-only signup for brand domains

A brand domain belongs to the partner's people (founder decision
2026-08-27): only allowlisted or invited users may create an account on
an invite-only brand domain; everyone else is shown an interstitial that
sends them to the canonical Accounted signup.

- brands.signup_mode ('open' default / 'invite_only') +
  brand_signup_allowlist (lowercase emails, team-scoped RLS, owner/admin
  writes) + create_company_for_brand_signup RPC, with pg-real coverage
- server-side gate (lib/auth/brand-signup-gate.ts) enforced on every
  signup path: email signup moved to POST /api/auth/signup (the browser
  used to call GoTrue directly, so a client-side check would be
  bypassable), BankID gated in /bankid/complete, Google covered by the
  dashboard layout's brand-domain bounce
- company invites bypass the allowlist: the invite is the authorization
- register page interstitial on gated brands (no email in the outbound
  URL), sv+en strings
- dashboard layout bounces non-belonging sessions off gated brand hosts
  to the canonical domain (navigation rule like WL-01, not a security
  boundary)
- allowlisted signups' onboarding-created companies attach to the
  brand's byra team via the new RPC, so WL-01 homes them on the brand
  domain; the allowlist entry recorded by an owner/admin stands in for
  the WL-15 admin gate
- byra cockpit page /clients/access + /api/clients/signup-access to
  manage the mode and the allowlist

All existing brands default to 'open': behavior is byte-identical until
a brand is flipped to invite_only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4

* fix(white-label): rollback brand-signup company with the service client

Skeptic (correctness) found that a brand-signup company created under the
service role rolled back with the cookie-session client: `companies` has
RLS and no FOR DELETE policy, so the delete was a silent 0-row no-op,
stranding a member-less ghost company on the partner's byra team. Pass an
optional rollbackClient to createCompanyCore and hand it the service
client on that path; user_preferences.active_company_id then clears itself
via its ON DELETE SET NULL FK once the company row is actually deleted.

Also map a validateBody 400 (flat envelope, no code) on the register page
to the specific email-invalid field message instead of the generic one,
since the client already pre-gates password strength.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4

* fix(white-label): fail-safe brand lookup, pg-test seed, anonymize fixtures

Second resolve-pr cycle: skeptic + CodeRabbit findings and a green-up.

- Fail safe on a brands-table error (CodeRabbit CWE-285): the gate treated a
  failed resolveBrandByHost as an unbranded host, opening invite-only signup
  during a transient DB blip. resolveBrandResultByHost now distinguishes
  "no brand" from "lookup failed"; the gate returns lookupFailed and the
  email + BankID routes answer 503 (retry), never creating an account.
- pg-real: the RLS delete test seeded its row inside withUserContext, which
  always rolls back, so the owner DELETE saw zero rows. Seed on the superuser
  pool instead.
- Anonymize every test/fixture brand to the repo's existing synthetic
  placeholder (Siffra / app.siffra.se): no real partner names in code.
- SignupAccessManager: functional setData updates so a concurrent mode
  toggle and an add/remove do not clobber each other's snapshot (CodeRabbit).
- Route a transient-error message through i18n instead of the raw envelope
  (raw-user-error guard); new register.error_temporary sv+en.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4

* test(white-label): anonymize new signup-gate fixtures; log oracle residual

Rename the placeholder brand in the four new brand-signup test files to a
clearly-fake, partner-unrelated name (Testbrand / app.testbrand.example);
the previous placeholder echoed a real partner. Scoped to files this PR
creates; the repo-wide legacy placeholder is left for a separate cleanup.

Also record in DECISIONS.md that the feature ships accepting the
low-severity allowlist-enumeration residual (captcha-free 403 vs 200 on
the signup endpoint), with rate-limiting as the follow-up option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:32:52 +02:00

337 lines
12 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser } from '@/tests/pg/fixtures'
// Tests for 20260827120000_brand_invite_only_signup.sql: brands.signup_mode,
// the brand_signup_allowlist table (lowercase/format/unique CHECKs, cascade,
// team-scoped RLS with owner/admin-only writes) and the
// create_company_for_brand_signup RPC (allowlist-gated byrå team attachment).
async function insertTeam(params: {
createdBy: string
kind?: 'personal' | 'byra'
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.teams (id, name, created_by, kind)
VALUES ($1, 'Byra Team', $2, $3)`,
[id, params.createdBy, params.kind ?? 'byra'],
)
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role)
VALUES ($1, $2, 'owner')`,
[id, params.createdBy],
)
return id
}
async function addTeamMember(teamId: string, userId: string, role: string): Promise<void> {
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, $3)`,
[teamId, userId, role],
)
}
async function insertBrand(teamId: string, signupMode?: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.brands (id, team_id, domain, app_name, brand_color, support_email, signup_mode)
VALUES ($1, $2, $3, 'Testbrand', '#2563eb', 'support@testbrand.example', COALESCE($4, 'open'))`,
[id, teamId, `${randomUUID().slice(0, 8)}.accounted.se`, signupMode ?? null],
)
return id
}
async function insertAllowlistEntry(brandId: string, email: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.brand_signup_allowlist (id, brand_id, email) VALUES ($1, $2, $3)`,
[id, brandId, email],
)
return id
}
/** The deterministic email insertAuthUser gives an auth user. */
function authEmail(userId: string): string {
return `pg-real-${userId}@test.invalid`
}
async function expectSqlstate(fn: () => Promise<unknown>, expected: string): Promise<void> {
let sqlstate: string | undefined
try {
await fn()
} catch (err) {
sqlstate = (err as { code?: string }).code
}
expect(sqlstate).toBe(expected)
}
describe('brands.signup_mode', () => {
it('defaults to open and accepts invite_only', async () => {
const owner = await insertAuthUser()
const teamId = await insertTeam({ createdBy: owner })
const brandId = await insertBrand(teamId)
const { rows } = await getPool().query<{ signup_mode: string }>(
`SELECT signup_mode FROM public.brands WHERE id = $1`,
[brandId],
)
expect(rows[0].signup_mode).toBe('open')
await getPool().query(
`UPDATE public.brands SET signup_mode = 'invite_only' WHERE id = $1`,
[brandId],
)
})
it('rejects unknown modes (23514)', async () => {
const owner = await insertAuthUser()
const teamId = await insertTeam({ createdBy: owner })
const brandId = await insertBrand(teamId)
await expectSqlstate(
() =>
getPool().query(`UPDATE public.brands SET signup_mode = 'closed' WHERE id = $1`, [
brandId,
]),
'23514',
)
})
})
describe('brand_signup_allowlist: shape', () => {
it('rejects mixed-case and malformed emails (23514)', async () => {
const owner = await insertAuthUser()
const teamId = await insertTeam({ createdBy: owner })
const brandId = await insertBrand(teamId)
await expectSqlstate(() => insertAllowlistEntry(brandId, 'Kund@Example.com'), '23514')
await expectSqlstate(() => insertAllowlistEntry(brandId, 'not-an-email'), '23514')
await expectSqlstate(() => insertAllowlistEntry(brandId, 'a b@example.com'), '23514')
})
it('enforces one entry per brand and email (23505), same email ok on another brand', async () => {
const ownerA = await insertAuthUser()
const ownerB = await insertAuthUser()
const brandA = await insertBrand(await insertTeam({ createdBy: ownerA }))
const brandB = await insertBrand(await insertTeam({ createdBy: ownerB }))
await insertAllowlistEntry(brandA, 'kund@example.com')
await expectSqlstate(() => insertAllowlistEntry(brandA, 'kund@example.com'), '23505')
await insertAllowlistEntry(brandB, 'kund@example.com')
})
it('cascades on brand delete', async () => {
const owner = await insertAuthUser()
const brandId = await insertBrand(await insertTeam({ createdBy: owner }))
const entryId = await insertAllowlistEntry(brandId, 'kund@example.com')
await getPool().query(`DELETE FROM public.brands WHERE id = $1`, [brandId])
const { rows } = await getPool().query(
`SELECT 1 FROM public.brand_signup_allowlist WHERE id = $1`,
[entryId],
)
expect(rows).toHaveLength(0)
})
})
describe('brand_signup_allowlist: RLS', () => {
it('team members read their own list; outsiders see nothing', async () => {
const owner = await insertAuthUser()
const member = await insertAuthUser()
const stranger = await insertAuthUser()
const teamId = await insertTeam({ createdBy: owner })
await addTeamMember(teamId, member, 'member')
const brandId = await insertBrand(teamId)
await insertAllowlistEntry(brandId, 'kund@example.com')
for (const insider of [owner, member]) {
await withUserContext(insider, async (client) => {
const { rows } = await client.query(
`SELECT id FROM public.brand_signup_allowlist WHERE brand_id = $1`,
[brandId],
)
expect(rows).toHaveLength(1)
})
}
await withUserContext(stranger, async (client) => {
const { rows } = await client.query(
`SELECT id FROM public.brand_signup_allowlist WHERE brand_id = $1`,
[brandId],
)
expect(rows).toHaveLength(0)
})
})
it('owner/admin can insert and delete; plain members cannot', async () => {
const owner = await insertAuthUser()
const admin = await insertAuthUser()
const member = await insertAuthUser()
const teamId = await insertTeam({ createdBy: owner })
await addTeamMember(teamId, admin, 'admin')
await addTeamMember(teamId, member, 'member')
const brandId = await insertBrand(teamId)
// Admin INSERT passes the WITH CHECK (no 42501). withUserContext always
// rolls back, so this asserts the policy allows the write; it does not
// persist. The persistent row for the DELETE assertions is seeded on the
// superuser pool below.
await withUserContext(admin, async (client) => {
await client.query(
`INSERT INTO public.brand_signup_allowlist (brand_id, email, created_by)
VALUES ($1, 'ny@example.com', $2)`,
[brandId, admin],
)
})
// Plain member INSERT violates the with-check (42501).
let sqlstate: string | undefined
try {
await withUserContext(member, async (client) => {
await client.query(
`INSERT INTO public.brand_signup_allowlist (brand_id, email) VALUES ($1, 'rogue@example.com')`,
[brandId],
)
})
} catch (err) {
sqlstate = (err as { code?: string }).code
}
expect(sqlstate).toBe('42501')
// Seed a row that persists (superuser pool, no rollback) so the DELETE
// assertions below act on a real row: member DELETE must be RLS-filtered
// to zero, owner DELETE must remove the one row.
await insertAllowlistEntry(brandId, 'target@example.com')
// Plain member DELETE is silently filtered to zero rows.
await withUserContext(member, async (client) => {
const deleted = await client.query(
`DELETE FROM public.brand_signup_allowlist WHERE brand_id = $1`,
[brandId],
)
expect(deleted.rowCount).toBe(0)
})
// The member's rolled-back DELETE left the row intact; owner removes it.
await withUserContext(owner, async (client) => {
const deleted = await client.query(
`DELETE FROM public.brand_signup_allowlist WHERE brand_id = $1`,
[brandId],
)
expect(deleted.rowCount).toBe(1)
})
})
})
describe('create_company_for_brand_signup', () => {
it('creates a company on the brand team for an allowlisted user', async () => {
const byraOwner = await insertAuthUser()
const client = await insertAuthUser()
const teamId = await insertTeam({ createdBy: byraOwner })
const brandId = await insertBrand(teamId, 'invite_only')
await insertAllowlistEntry(brandId, authEmail(client))
const { rows } = await getPool().query<{ id: string }>(
`SELECT public.create_company_for_brand_signup($1, 'Kundbolaget AB', 'aktiebolag', $2) AS id`,
[client, brandId],
)
const companyId = rows[0].id
const { rows: companyRows } = await getPool().query<{
team_id: string
created_by: string
}>(`SELECT team_id, created_by FROM public.companies WHERE id = $1`, [companyId])
expect(companyRows[0]).toEqual({ team_id: teamId, created_by: client })
// The signup user owns the company; team sync gave the byrå access too.
const { rows: memberRows } = await getPool().query<{ user_id: string; role: string }>(
`SELECT user_id, role FROM public.company_members WHERE company_id = $1 ORDER BY role`,
[companyId],
)
expect(memberRows).toContainEqual({ user_id: client, role: 'owner' })
expect(memberRows.some((m) => m.user_id === byraOwner)).toBe(true)
})
it('matches the allowlist case-insensitively against the auth email', async () => {
const byraOwner = await insertAuthUser()
const client = await insertAuthUser()
const teamId = await insertTeam({ createdBy: byraOwner })
const brandId = await insertBrand(teamId, 'invite_only')
// Auth emails from the fixture are already lowercase; the allowlist
// stores lowercase by CHECK, so this is the canonical match.
await insertAllowlistEntry(brandId, authEmail(client))
const { rows } = await getPool().query<{ id: string }>(
`SELECT public.create_company_for_brand_signup($1, 'EF Kund', 'enskild_firma', $2) AS id`,
[client, brandId],
)
expect(rows[0].id).toBeTruthy()
})
it('refuses a user who is not on the allowlist (42501)', async () => {
const byraOwner = await insertAuthUser()
const stranger = await insertAuthUser()
const teamId = await insertTeam({ createdBy: byraOwner })
const brandId = await insertBrand(teamId, 'invite_only')
await expectSqlstate(
() =>
getPool().query(
`SELECT public.create_company_for_brand_signup($1, 'Rogue AB', 'aktiebolag', $2)`,
[stranger, brandId],
),
'42501',
)
})
it('refuses unknown brands (23503) and unknown users (23503)', async () => {
const byraOwner = await insertAuthUser()
const client = await insertAuthUser()
const teamId = await insertTeam({ createdBy: byraOwner })
const brandId = await insertBrand(teamId, 'invite_only')
await insertAllowlistEntry(brandId, authEmail(client))
await expectSqlstate(
() =>
getPool().query(
`SELECT public.create_company_for_brand_signup($1, 'X AB', 'aktiebolag', $2)`,
[client, randomUUID()],
),
'23503',
)
await expectSqlstate(
() =>
getPool().query(
`SELECT public.create_company_for_brand_signup($1, 'X AB', 'aktiebolag', $2)`,
[randomUUID(), brandId],
),
'23503',
)
})
it('is not executable by authenticated sessions (42501)', async () => {
const byraOwner = await insertAuthUser()
const client = await insertAuthUser()
const teamId = await insertTeam({ createdBy: byraOwner })
const brandId = await insertBrand(teamId, 'invite_only')
await insertAllowlistEntry(brandId, authEmail(client))
let sqlstate: string | undefined
try {
await withUserContext(client, async (session) => {
await session.query(
`SELECT public.create_company_for_brand_signup($1, 'Self AB', 'aktiebolag', $2)`,
[client, brandId],
)
})
} catch (err) {
sqlstate = (err as { code?: string }).code
}
expect(sqlstate).toBe('42501')
})
})