Files
accounted/tests/schema/no-phantom-columns.test.ts
T
MattssonandClaude Fable 5 fdb5f6f891 feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation

- brands table: one white-label identity per byra team (unique mutable
  domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
  ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
  demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
  chrome tone and WCAG contrast gate; no brand row = default appearance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-request brand theming, wordmark slot and source footer

- root layout resolves the brand from the Host header and injects a
  server-rendered style block (light + dark), font pair classes and a
  BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
  images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
  for non-default entries)
- AGPL source-code footer link on login and public pages, both brands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra team invites, member management and team billing

- team invites unfrozen behind a kind gate (byra teams only, owner/admin
  invite); members route handles multi-team membership; members/[id]
  unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
  read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding

- every outbound mail is sent in the brand of the company it concerns:
  getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
  "via Accounted" fallback, canonical default) wired into invites,
  payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
  dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra cockpit, home-domain rule and tab guard

- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
  next deadline via the status engine, last booked) for byra team members,
  who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
  guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
  is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
  signpost page for companies homed elsewhere

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware app name across UI strings

- 24 message keys per locale converted to the {appName} ICU parameter,
  27 call sites pass the active brand name (useBranding client-side,
  getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
  surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
  guard, signpost and source footer (sv/en parity verified)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(white-label): domain glossary and decision log entries

- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
  home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): lean byra cockpit sidebar with company-mode back link

Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings

In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): keep company picker in cockpit with nothing selected

The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): renumber white-label migrations past main and add byra settings scope

Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.

Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): cross-client nyckeltal view in the cockpit

Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra self-service brand logo and app name

New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): route root layout through the shared brand resolver

app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): automations roadmap teaser and cockpit i18n strings

The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company

After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.

The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(white-label): drop brand color theming, keep monochrome everywhere

White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook

ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(byra): drop the 'what's coming' tail from the automations intro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins with zero companies land in the empty cockpit

Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): auth wordmark shows the brand logo alone

Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-brand favicon via brands.favicon_url

Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): wire the villkor and integritetspolicy footer links

Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popup for the team role dropdowns

The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded sender shows the brand name alone, no via-platform

Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): byra landing applies to every team member, not only owners/admins

An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded team invite names the byra, not "ett team pa <platform>"

Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar keeps cockpit mode after refresh on settings

The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): hide the active-company chip in byra-scoped settings

The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): tab guard no longer fires in the tab that initiated the switch

BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popups for every settings dropdown

SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): home-domain affinity redirect in middleware

Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): render hero brand logo at 64px on auth pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): shareable invite link and re-send for byra team invites

A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:

- persistent share-link line after invite create/re-send: ochre attn line
  with a copy action when the mail did not go out, quiet muted line with
  the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
  token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
  by create and re-send so the two paths cannot drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label

Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): close the four skeptic refutations before merge

- trial seed: migration 130300 now carries the seven-key PAID body from
  20260818170000 plus the byra guard, instead of silently reverting it;
  pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
  create_company_for_user (v1 API + MCP path), and both surfaces resolve
  the default team personal-only, so a consultant's private company can
  never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
  longer redirected off the platform host; the signpost handles per-company
  homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
  (ROT/RUT work-type picker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect

The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:56:39 +02:00

537 lines
22 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
MASTER_DATA_DUMP_TABLES,
ARCHIVE_COVERED_ELSEWHERE_TABLES,
ARCHIVE_EXCLUDED_TABLES,
} from '@/lib/reports/full-archive-export'
import {
buildSchemaFromMigrations,
closedValueSets,
listSourceFiles,
scanColumnRefs,
scanSourceText,
type ScanResult,
type SchemaModel,
type TableModel,
} from './schema-guard'
/**
* The phantom-column net.
*
* `createQueuedMockSupabase()` is chainable and permissive: `.eq('nope', 1)` and
* `.select('does_not_exist')` resolve happily, so a green unit test says nothing
* about whether a column exists. That blind spot kept article delete broken for
* ten days, hid sixteen more phantom-column sites, two phantom CHECK values
* (`source_type = 'transaction'` against a real `bank_transaction`: 0 rows where
* prod has 3 917) and one `onConflict` naming a dropped unique constraint, which
* raised 42P10 on every call. Mocked tests provably cannot catch that class.
*
* This test compares two things that cannot lie to each other: the schema
* replayed from `supabase/migrations/*.sql`, and every Supabase query builder
* chain in the source, read through the TypeScript AST. It follows the shape of
* `tests/pg/full-archive-coverage.pg.test.ts`, which likewise reads the schema
* and asserts a contract against checked-in code.
*
* GROUND TRUTH: the migration files. Not a live database (a pg-real test would
* be authoritative but only runs in the `test-pg-real` CI job, so a developer's
* `npm test` would never see it), and not a checked-in snapshot (which rots).
* Replaying the migrations costs ~250ms, runs everywhere, and has no artifact to
* go stale: the migrations are already the repo's contract with prod. No test in
* this file connects to any database.
*
* FAILURE MODE: named columns that resolve confidently are asserted hard, minus
* an explicit baseline of pre-existing breakage below. Expressions the scanner
* cannot resolve (a payload built at runtime, an interpolated select) are counted
* against a documented ceiling instead of failing, because failing on every
* unresolvable expression would make the guard noise and get it disabled.
*/
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
/**
* Pre-existing phantom-column sites, found when this guard was introduced and
* deliberately NOT fixed here (the fix belongs with the owner of each surface).
* Keyed `table.column @ file` so a line shift does not churn but a NEW file
* adopting the same phantom column still fails.
*
* Remove an entry when the site is fixed: a stale entry fails this test on
* purpose, so the list can only shrink.
*/
const KNOWN_PHANTOM_COLUMNS: Record<string, string> = {}
/** Pre-existing `.from()` targets that no migration creates. */
const KNOWN_PHANTOM_TABLES: Record<string, string> = {}
/** Pre-existing literal values that no CHECK constraint allows. */
const KNOWN_PHANTOM_VALUES: Record<string, string> = {}
/** Pre-existing `onConflict` targets with no matching unique constraint. */
const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
/**
* Ceiling on expressions the scanner cannot resolve to a concrete column.
* These are legitimate patterns (a payload assembled at runtime, an interpolated
* select, a spread), not defects: the ceiling exists so a refactor that hides a
* large slice of the query surface behind dynamic construction is visible rather
* than silent. Headroom is deliberate; lower it when the number drops.
*
* Baseline 2026-07-26: 346 (145 dynamic-payload, 116 dynamic-select,
* 48 dynamic-logical, 32 spread-payload, 4 dynamic-column, 1 computed key).
*
* 2026-08-01: 361 after the white-label build (WL-17: brands, teams.kind,
* cockpit, brand mail). The growth is spread across ordinary feature queries;
* ceiling raised 360 -> 370 to restore headroom.
*
* Raised 2026-08-06 for the sandbox seed's payroll + ledger-history builders.
* app/api/sandbox/seed/ follows the pure-row-builder pattern the existing
* customers.ts / pending-operations.ts modules established: the builder returns
* complete row objects and route.ts spreads them, adding only the ids it had to
* insert first (voucher_number, journal_entry_id, account_id). The scanner
* cannot see through that spread. Writing the columns out again in route.ts to
* satisfy the scanner would duplicate every builder's shape at the call site,
* which is the thing the builders exist to prevent, and the row shapes are
* covered by their own unit tests instead.
*
* Baseline 2026-08-06: 370 (158 dynamic-payload, 120 dynamic-select,
* 47 dynamic-logical, 38 spread-payload, 5 dynamic-column, 2 computed key).
*
* 2026-08-12 +3: lib/webshop-orders/ingest.ts builds partial UPDATE payloads
* at runtime (frozen rows get safe fields only; unfrozen rows get optional
* parent/legacy links). Writing the shapes as inline literals would need one
* variant per key combination; the row shapes are covered by ingest.test.ts.
*
* 2026-08-26 +2: the customer pickers in InvoiceEditor and
* NewRecurringScheduleDialog hide archived customers but must keep the one the
* draft already points at, which is a PostgREST logical filter
* `.or('archived_at.is.null,id.eq.<uuid>')`. The id is a runtime value, so the
* expression cannot be a literal; both columns are real and the filter is
* covered by the archived-counterparty tests.
*
* 2026-08-17 +1: lib/import/skattekonto-file/import-service.ts inserts parsed
* statement rows via a mapped batch (same shape as every other file importer);
* the row shape is covered by the execute route tests and the pg-real suite.
*
* 2026-08-21 +1: lib/invoices/peppol-inbound.ts updates the processing state
* of an inbound Peppol document through one helper (five literal shapes:
* routed / unrouted / converted / failed, all partial); the column set is
* pinned by peppol-inbound.test.ts and the pg-real immutability test.
*
* 2026-08-26 merge of add/white-label-infra with main: both sides' growth
* lands at once (WL queries + everything above). Count on the merged tree:
* 383 (162 dynamic-payload, 125 dynamic-select, 50 dynamic-logical,
* 39 spread-payload, 5 dynamic-column, 2 computed key); ceiling re-baselined
* with the usual headroom. The later catch-up merge of #1954 (byte-exact SIE
* upload) brought the merged count to 386.
*/
const UNRESOLVED_CEILING = 388
/**
* Floor on statically resolved column references. Guards the guard: if a change
* to the scanner or to the client wrappers stops resolving chains, this fails
* instead of the net silently going slack.
*
* Baseline 2026-07-26: 13 734.
*/
const RESOLVED_COLUMN_FLOOR = 13_500
let schema: SchemaModel
let scan: ScanResult
const valueSets = new Map<string, Map<string, Set<string>>>()
function closedFor(table: TableModel): Map<string, Set<string>> {
let sets = valueSets.get(table.name)
if (!sets) {
sets = closedValueSets(table)
valueSets.set(table.name, sets)
}
return sets
}
// Explicit hook timeout: this replays every migration AND parses the whole
// app/lib/components/extensions/hooks/scripts tree through the TypeScript
// AST. It runs in ~3s on its own, but vitest's 10s default hook timeout is
// not enough headroom when the file is scheduled alongside the rest of the
// suite and CPU is contended (observed failing exactly that way, then passing
// on re-run). A flaky guard gets disabled, so give it room rather than let it
// half-run.
beforeAll(() => {
schema = buildSchemaFromMigrations(path.join(ROOT, 'supabase', 'migrations'))
scan = scanColumnRefs(
listSourceFiles([
path.join(ROOT, 'app'),
path.join(ROOT, 'lib'),
path.join(ROOT, 'components'),
path.join(ROOT, 'extensions'),
path.join(ROOT, 'hooks'),
path.join(ROOT, 'scripts'),
]),
schema,
ROOT
)
}, 120_000)
/** Split findings into new ones (fail) and baselined ones (tracked). */
function against(
findings: { key: string; where: string }[],
baseline: Record<string, string>
): { unexpected: string[]; stale: string[] } {
const seen = new Set(findings.map((f) => f.key))
return {
unexpected: [
...new Set(findings.filter((f) => !(f.key in baseline)).map((f) => `${f.key} (${f.where})`)),
].sort(),
stale: Object.keys(baseline).filter((k) => !seen.has(k)).sort(),
}
}
describe('schema replay (parser fidelity)', () => {
// If the replay is wrong the whole guard is wrong, in either direction: a
// missed column invents accusations, an invented column hides real ones. These
// anchors are facts established independently of the parser.
it('models the tables that deliberately have no company_id', () => {
const withoutCompanyId = [
'invoice_items',
'journal_entry_lines',
'supplier_invoice_items',
'recurring_invoice_schedule_items',
'rot_rut_payout_request_items',
'agent_messages',
]
for (const name of withoutCompanyId) {
const table = schema.tables.get(name)
expect(table, `${name} missing from the replayed schema`).toBeDefined()
expect(table!.columns.has('company_id'), `${name} should have no company_id`).toBe(false)
expect(table!.columns.size, `${name} parsed with no columns`).toBeGreaterThan(3)
}
})
it('models company_id on the company-scoped tables, including the ones added by dynamic DDL', () => {
// chart_of_accounts / api_keys / audit_log got company_id from an
// `EXECUTE format('ALTER TABLE %I ADD COLUMN ...')` loop in the multi-tenant
// refactor: if the replay misses that, the guard accuses hundreds of correct
// call sites.
const withCompanyId = [
'company_members',
'salary_line_items',
'transaction_voucher_links',
'invoice_deliveries',
'chart_of_accounts',
'api_keys',
'audit_log',
'journal_entries',
]
for (const name of withCompanyId) {
const table = schema.tables.get(name)
expect(table, `${name} missing from the replayed schema`).toBeDefined()
expect(table!.columns.has('company_id'), `${name} should have company_id`).toBe(true)
}
})
it('models a column renamed by DDL inside a DO block', () => {
// 20260515170000_webhooks_v2 renames automation_webhooks -> webhooks inside
// `DO $$ IF ... THEN ... END $$`.
expect(schema.tables.has('webhooks')).toBe(true)
expect(schema.tables.has('automation_webhooks')).toBe(false)
expect(schema.tables.get('supplier_invoice_payments')!.columns.has('user_id')).toBe(true)
})
it('agrees with the full-archive contract, which is validated against real Postgres', () => {
// Independent anchor. tests/pg/full-archive-coverage.pg.test.ts asserts this
// same contract against information_schema on a live database, so agreement
// here means the migration replay reproduces prod's company_id topology for
// 58 dump tables plus every classified table. A drift means the replay is
// wrong, not the contract.
const direct = MASTER_DATA_DUMP_TABLES.filter((t) => !t.via)
expect(
direct.filter((t) => !schema.tables.get(t.name)?.columns.has('company_id')).map((t) => t.name),
'direct-dump tables the replay thinks have no company_id'
).toEqual([])
expect(
MASTER_DATA_DUMP_TABLES.filter((t) => t.via && schema.tables.get(t.name)?.columns.has('company_id')).map(
(t) => t.name
),
'via-tables the replay thinks DO have company_id'
).toEqual([])
expect(
MASTER_DATA_DUMP_TABLES.filter((t) => !schema.tables.get(t.name)?.columns.has(t.pageKey ?? 'id')).map(
(t) => `${t.name}.${t.pageKey ?? 'id'}`
),
'page keys the replay cannot find'
).toEqual([])
const classified = new Set([
...MASTER_DATA_DUMP_TABLES.map((t) => t.name),
...Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES),
...Object.keys(ARCHIVE_EXCLUDED_TABLES),
])
expect(
[...classified].filter((name) => !schema.tables.has(name)).sort(),
'tables the archive contract classifies but the replay never created'
).toEqual([])
const unclassified = [...schema.tables.values()]
.filter((t) => t.columns.has('company_id') && !classified.has(t.name))
.map((t) => t.name)
.sort()
expect(
unclassified,
'company-scoped tables in the replay that the archive contract does not classify. ' +
'Either the replay invented a company_id column, or a new table needs triaging in ' +
'lib/reports/full-archive-export.ts.'
).toEqual([])
})
it('tracks the current source_type CHECK across every widening migration', () => {
const sets = closedFor(schema.tables.get('journal_entries')!)
const sourceType = sets.get('source_type')
expect(sourceType, 'journal_entries.source_type should be a closed value set').toBeDefined()
expect(sourceType!.has('bank_transaction')).toBe(true)
expect(sourceType!.has('stripe_payout')).toBe(true)
// The phantom value that read 0 rows where prod had 3 917.
expect(sourceType!.has('transaction')).toBe(false)
})
})
describe('no phantom columns in Supabase queries', () => {
it('names only columns that exist on the table being queried', () => {
const findings = scan.columnRefs
.filter((ref) => {
const table = schema.tables.get(ref.table)
return table !== undefined && !table.columns.has(ref.column)
})
.map((ref) => ({
key: `${ref.table}.${ref.column} @ ${ref.file}`,
where: `${ref.kind}, line ${ref.line}`,
}))
const { unexpected, stale } = against(findings, KNOWN_PHANTOM_COLUMNS)
expect(
unexpected,
'Query references a column that no migration creates. PostgREST answers 42703 and ' +
'the whole select/filter fails at runtime, however green the mocked test is:\n ' +
unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These phantom columns are fixed: delete them from KNOWN_PHANTOM_COLUMNS so the ' +
'baseline keeps shrinking:\n ' + stale.join('\n ')
).toEqual([])
})
it('reads only tables that exist', () => {
const findings = scan.tableRefs
.filter((ref) => !schema.tables.has(ref.table) && !schema.views.has(ref.table))
.map((ref) => ({ key: `${ref.table} @ ${ref.file}`, where: `line ${ref.line}` }))
const { unexpected, stale } = against(findings, KNOWN_PHANTOM_TABLES)
expect(
unexpected,
'Query targets a table that no migration creates (dropped, renamed, or never shipped):\n ' +
unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These phantom tables are fixed: delete them from KNOWN_PHANTOM_TABLES:\n ' +
stale.join('\n ')
).toEqual([])
})
it('filters and writes only values a CHECK constraint allows', () => {
const findings = scan.valueRefs
.filter((ref) => {
const table = schema.tables.get(ref.table)
if (!table) return false
const allowed = closedFor(table).get(ref.column)
return allowed !== undefined && !allowed.has(ref.value)
})
.map((ref) => ({
key: `${ref.table}.${ref.column} = '${ref.value}' @ ${ref.file}`,
where: `line ${ref.line}`,
}))
const { unexpected, stale } = against(findings, KNOWN_PHANTOM_VALUES)
expect(
unexpected,
'Literal value is not a member of the column CHECK constraint. A filter on it ' +
'silently matches nothing; a write on it raises 23514:\n ' + unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These phantom values are fixed: delete them from KNOWN_PHANTOM_VALUES:\n ' +
stale.join('\n ')
).toEqual([])
})
it('upserts onto a real unique constraint', () => {
const findings = scan.conflictRefs
.filter((ref) => {
const table = schema.tables.get(ref.table)
// A table with no unique set at all means the replay did not find one:
// stay silent rather than accuse.
if (!table || table.uniqueSets.size === 0) return false
return !table.uniqueSets.has([...ref.columns].sort().join(','))
})
.map((ref) => ({
key: `${ref.table} [${ref.columns.join(',')}] @ ${ref.file}`,
where: `line ${ref.line}`,
}))
const { unexpected, stale } = against(findings, KNOWN_STALE_ON_CONFLICT)
expect(
unexpected,
'upsert onConflict names columns with no matching unique constraint or index. ' +
'Postgres raises 42P10 on every call:\n ' + unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These onConflict targets are fixed: delete them from KNOWN_STALE_ON_CONFLICT:\n ' +
stale.join('\n ')
).toEqual([])
})
})
describe('scanner behaviour (the net catches, and does not over-catch)', () => {
const phantoms = (code: string): string[] =>
scanSourceText(code, 'probe.ts', schema)
.columnRefs.filter((ref) => {
const table = schema.tables.get(ref.table)
return table !== undefined && !table.columns.has(ref.column)
})
.map((ref) => `${ref.table}.${ref.column}`)
it('catches a phantom column in a plain filter', () => {
expect(phantoms(`supabase.from('invoice_items').select('id').eq('company_id', c)`)).toEqual([
'invoice_items.company_id',
])
})
it('catches a phantom column inside a backtick template select', () => {
// Blind spot 1: the sweep's own scanner only read single-quoted selects.
const code = [
'const q = supabase',
" .from('invoices')",
' .select(`',
' id,',
' total_amount,',
' customer:customers(name)',
' `)',
].join('\n')
expect(phantoms(code)).toEqual(['invoices.total_amount'])
})
it('catches a phantom column added to a builder after assignment', () => {
// Blind spot 2: `let q = supabase.from(...)` then `q = q.eq(...)`.
const code = [
"let q = supabase.from('journal_entry_lines').select('id')",
"if (x) q = q.eq('company_id', c)",
"q = q.order('description', { ascending: true })",
].join('\n')
expect(phantoms(code).sort()).toEqual([
'journal_entry_lines.company_id',
'journal_entry_lines.description',
])
})
it('catches a phantom column in a dotted embedded filter', () => {
const code = [
"supabase.from('journal_entry_lines')",
" .select('id, journal_entries!inner(id)')",
" .eq('journal_entries.source_type', 'x')",
" .eq('journal_entries.nope', 'y')",
].join('\n')
expect(phantoms(code)).toEqual(['journal_entries.nope'])
})
it('does not accuse embedded resource names, aliases or casts', () => {
const code = [
"supabase.from('invoices')",
" .select('id, items:invoice_items(id, description), customer:customers!invoices_customer_id_fkey(name), total::text, default_dimensions->>project')",
" .order('name', { referencedTable: 'customers' })",
].join('\n')
expect(phantoms(code)).toEqual([])
})
it('resolves an embed named by its foreign key column', () => {
// `company:company_id(...)` embeds `companies` through the FK column.
const code = [
"supabase.from('company_members')",
" .select('role, company:company_id(id, name, nope)')",
].join('\n')
expect(phantoms(code)).toEqual(['companies.nope'])
})
it('never treats rpc arguments as columns', () => {
const code = "supabase.rpc('commit_journal_entry', { p_entry_id: id, not_a_column: 1 })"
const result = scanSourceText(code, 'probe.ts', schema)
expect(result.columnRefs).toEqual([])
expect(result.tableRefs).toEqual([])
})
it('never treats a storage bucket or Buffer.from as a table', () => {
const code = [
"supabase.storage.from('documents').upload(p, f)",
"const b = Buffer.from('abc')",
].join('\n')
expect(scanSourceText(code, 'probe.ts', schema).tableRefs).toEqual([])
})
it('reports a runtime-built payload as unresolved rather than guessing', () => {
const code = "supabase.from('invoices').insert(rows)"
const result = scanSourceText(code, 'probe.ts', schema)
expect(result.columnRefs).toEqual([])
expect(result.unresolved.map((u) => u.reason)).toEqual(['dynamic-payload'])
})
it('does not attribute a builder variable reused for two tables in one scope', () => {
const code = [
'function f() {',
" let q = supabase.from('invoices').select('id')",
" q = supabase.from('customers').select('id')",
" q = q.eq('nope_at_all', 1)",
'}',
].join('\n')
expect(phantoms(code)).toEqual([])
expect(
scanSourceText(code, 'probe.ts', schema).unresolved.map((u) => u.reason)
).toContain('unknown-builder-table')
})
it('catches a phantom value on a CHECK-constrained column', () => {
const code = "supabase.from('journal_entries').select('id').eq('source_type', 'transaction')"
const bad = scanSourceText(code, 'probe.ts', schema).valueRefs.filter((ref) => {
const allowed = closedFor(schema.tables.get(ref.table)!).get(ref.column)
return allowed !== undefined && !allowed.has(ref.value)
})
expect(bad.map((b) => `${b.column}=${b.value}`)).toEqual(['source_type=transaction'])
})
})
describe('scanner coverage', () => {
it('keeps the resolvable query surface large enough for the net to matter', () => {
expect(
scan.columnRefs.length,
`Only ${scan.columnRefs.length} column references resolved (floor ${RESOLVED_COLUMN_FLOOR}). ` +
'Either the scanner stopped following builder chains or a large surface moved behind ' +
'dynamic construction. Fix the scanner rather than lowering the floor.'
).toBeGreaterThanOrEqual(RESOLVED_COLUMN_FLOOR)
})
it('holds the unresolvable-expression ceiling', () => {
const byReason = new Map<string, number>()
for (const item of scan.unresolved) {
byReason.set(item.reason, (byReason.get(item.reason) ?? 0) + 1)
}
const breakdown = [...byReason.entries()]
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${count} ${reason}`)
.join(', ')
expect(
scan.unresolved.length,
`${scan.unresolved.length} unresolvable query expressions (ceiling ${UNRESOLVED_CEILING}): ` +
`${breakdown}. Each one is a column this guard cannot check. Prefer an object literal ` +
'or a literal select string over runtime construction, or raise the ceiling with a reason.'
).toBeLessThanOrEqual(UNRESOLVED_CEILING)
})
})