Files
supabase/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts
T
Alaister YoungandAlaister Young 29493e02d0 [FE-4010] feat(studio): add read-only replica connection option for HA projects (#49485)
For Multigres (HA) projects you can't connect to read replicas directly
— reads go through a read-only load balancer on the primary's host at
port 5433. Since #44695 stripped the pooler UI, HA projects showed no
source option at all in the Connect dialog and still prompted for the
IPv4 add-on. This surfaces it as a first-class, clearly-labeled
read-only source. In the UI it's labeled `Replica (read-only)` rather
than "load balancer" — the primary goes through the same gateway, so
"load balancer" would be confusing from a product perspective
(internally the `load-balancer` source identifier and
`HIGH_AVAILABILITY_LOAD_BALANCER_PORT` constant keep their names).

<img width="883" height="342" alt="Screenshot 2026-08-24 at 11 32 26 PM"
src="https://github.com/user-attachments/assets/3716f6dd-0325-4b9d-adbc-9ece9244de62"
/>

**Added:**
- Source select for HA projects in the Direct tab: `Primary database` +
`Replica (read-only)` (individual replica rows are filtered out —
they're only reachable via the load balancer)
- Replica (load balancer) connection strings on all 9 connection types:
primary host, port `5433`, with the Multigres-required
`sslmode=require&sslnegotiation=direct` params (JDBC gets the
`sslNegotiation` spelling, .NET gets `SSL Negotiation=Direct`)
- `Read-only` badge on the connection code block + note pointing writes
at the primary
- Programmatic labels for the ConnectSheet select/switch/multi-select
fields (the Source combobox previously had no accessible name)

**Changed:**
- The generated-file step (Node.js/Golang/.NET/Python/SQLAlchemy) is now
source-aware — it previously ignored the Source selection entirely (also
affected read replicas on normal projects) and silently rendered the
primary's connection info
- .NET template now emits `Port=` (Npgsql defaults to 5432 when omitted)
and the install step actually installs Npgsql (pinned 9.0.5 — `SSL
Negotiation` requires 9+)
- SQLAlchemy `DATABASE_URL` merges `sslmode=require` into the string's
existing query params instead of a hardcoded suffix that could drop TLS
- Source option labels normalized to sentence case (`Primary database`,
`Read replica (…)`)
- `MultipleCodeBlock` (ui-patterns) accepts an optional `className`
- HA coercion in `useConnectState` extended: a stale replica
`connectionSource` restored from URL/localStorage falls back to the
primary

**Removed:**
- IPv4 add-on admonition for HA projects (the forced-direct method was
tripping it; the add-on doesn't apply to Multigres)

Out of scope (needs platform work): SQL editor / Data API / other
`DatabaseSelector` surfaces — executing against the load balancer
requires a platform-issued connection string, and the load-balancers API
only returns a REST endpoint today. The `5433` port is a client-side
constant (`HIGH_AVAILABILITY_LOAD_BALANCER_PORT`) until the API exposes
it.

## To test

On an HA (Multigres) project:
- Open Connect → Direct: Source shows exactly `Primary database` and
`Replica (read-only)`; selecting the replica shows
`…@<primary-host>:5433/postgres?sslmode=require&sslnegotiation=direct`,
a `Read-only` badge, and the read-only note
- Cycle all 9 connection types with the replica selected — every snippet
carries port 5433 (`.NET` includes `Port=5433;…;SSL
Negotiation=Direct`), badge/note persist
- No "Enable IPv4 add-on" admonition anywhere in the Direct tab
- Switch tabs / hard-reload: source resets to primary with no stale
badge/string combos

On a normal project:
- Direct tab unchanged: no `Replica (read-only)` option, pooler badges
and IPv4 admonitions behave as before, `.NET` now shows `Port=5432` and
no `SSL Negotiation`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added read-only load-balancer connection options for high-availability
projects.
- Added .NET and SQLAlchemy connection examples with required SSL
settings.
- Added clear read-only labels and notices explaining write
restrictions.
- **Bug Fixes**
  - Suppressed IPv4 add-on notices for high-availability connections.
  - Improved connection-source selection and restored-setting handling.
  - Improved connection form identification and accessibility.
- **Style**
  - Added customizable styling support for multi-code-block displays.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-08-28 10:43:55 +01:00

287 lines
11 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import {
appendHighAvailabilitySslParams,
buildConnectionStringPooler,
getConnectionStrings,
getHighAvailabilityLoadBalancerConnectionInfo,
getSelfHostedDirectStrings,
getSelfHostedPoolerStrings,
HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
HIGH_AVAILABILITY_SSL_PARAMS,
} from '../DatabaseSettings.utils'
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
const platform: DeploymentMode = { isPlatform: true, isCli: false, isSelfHosted: false }
const cli: DeploymentMode = { isPlatform: false, isCli: true, isSelfHosted: false }
const selfHosted: DeploymentMode = { isPlatform: false, isCli: false, isSelfHosted: true }
// Minimal ConnectionStrings stub — only `uri` matters for buildConnectionStringPooler.
const makeStrings = (poolerUri: string, directUri: string) =>
({
direct: {
uri: directUri,
psql: '',
golang: '',
jdbc: '',
dotnet: '',
nodejs: '',
php: '',
python: '',
sqlalchemy: '',
},
pooler: {
uri: poolerUri,
psql: '',
golang: '',
jdbc: '',
dotnet: '',
nodejs: '',
php: '',
python: '',
sqlalchemy: '',
},
}) as any
describe('getSelfHostedPoolerStrings', () => {
test('uses POOLER_TENANT_ID and YOUR-PASSWORD placeholders', () => {
const strings = getSelfHostedPoolerStrings('db.example.com', 6543)
expect(strings.uri).toBe(
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@db.example.com:6543/postgres'
)
})
test('threads host, port, and database name through every format', () => {
const strings = getSelfHostedPoolerStrings('db.example.com', 5432, 'mydb')
expect(strings.uri).toContain('db.example.com:5432/mydb')
expect(strings.psql).toContain('db.example.com:5432/mydb')
expect(strings.jdbc).toContain('db.example.com:5432/mydb')
expect(strings.golang).toContain('host=db.example.com')
expect(strings.golang).toContain('port=5432')
expect(strings.golang).toContain('dbname=mydb')
expect(strings.dotnet).toContain('Server=db.example.com')
expect(strings.dotnet).toContain('Port=5432')
expect(strings.dotnet).toContain('Database=mydb')
expect(strings.nodejs).toBe(`DATABASE_URL=${strings.uri}`)
})
test('defaults database name to postgres', () => {
const strings = getSelfHostedPoolerStrings('db.example.com', 6543)
expect(strings.uri.endsWith('/postgres')).toBe(true)
})
})
describe('getSelfHostedDirectStrings', () => {
test('uses plain postgres user (no tenant id)', () => {
const strings = getSelfHostedDirectStrings('db.example.com', 5432)
expect(strings.uri).toBe('postgresql://postgres:[YOUR-PASSWORD]@db.example.com:5432/postgres')
})
test('threads host, port, and database name through every format', () => {
const strings = getSelfHostedDirectStrings('db.example.com', 5432, 'mydb')
expect(strings.uri).toContain('postgres:[YOUR-PASSWORD]@db.example.com:5432/mydb')
expect(strings.jdbc).toContain('db.example.com:5432/mydb')
expect(strings.golang).toContain('user=postgres\n')
expect(strings.dotnet).toContain('User Id=postgres;')
})
})
describe('buildConnectionStringPooler', () => {
const sharedPlatform = makeStrings(
'postgresql://postgres.proj:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres',
'postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5432/postgres'
)
const dedicatedPlatform = makeStrings(
'postgresql://dedicated.proj:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres',
''
)
const connectionInfo = { db_host: 'db.example.com', db_port: 5432 }
test('platform: returns shared + dedicated pooler bag and direct URI', () => {
const result = buildConnectionStringPooler({
deploymentMode: platform,
connectionInfo,
connectionStringsShared: sharedPlatform,
connectionStringsDedicated: dedicatedPlatform,
ipv4Addon: true,
isHighAvailability: false,
})
expect(result.transactionShared).toBe(sharedPlatform.pooler.uri)
expect(result.sessionShared).toBe(sharedPlatform.pooler.uri.replace('6543', '5432'))
expect(result.transactionDedicated).toBe(dedicatedPlatform.pooler.uri)
expect(result.sessionDedicated).toBe(dedicatedPlatform.pooler.uri.replace('6543', '5432'))
expect(result.ipv4SupportedForDedicatedPooler).toBe(true)
expect(result.direct).toBe(sharedPlatform.direct.uri)
})
test('platform without IPv4 addon flips ipv4SupportedForDedicatedPooler', () => {
const result = buildConnectionStringPooler({
deploymentMode: platform,
connectionInfo,
connectionStringsShared: sharedPlatform,
ipv4Addon: false,
isHighAvailability: false,
})
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
expect(result.transactionDedicated).toBeUndefined()
expect(result.sessionDedicated).toBeUndefined()
})
test('CLI: collapses pooler URIs to the direct URI (no pooler in CLI)', () => {
const directUri = sharedPlatform.direct.uri
const result = buildConnectionStringPooler({
deploymentMode: cli,
connectionInfo,
connectionStringsShared: sharedPlatform,
ipv4Addon: false,
isHighAvailability: false,
})
expect(result.direct).toBe(directUri)
expect(result.transactionShared).toBe(directUri)
expect(result.sessionShared).toBe(directUri)
expect(result.transactionDedicated).toBeUndefined()
expect(result.sessionDedicated).toBeUndefined()
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
})
test('self-hosted: uses Supavisor placeholders on 6543/dbPort, direct on dbPort', () => {
const result = buildConnectionStringPooler({
deploymentMode: selfHosted,
connectionInfo: { db_host: 'supabase.example.com', db_port: 5432 },
connectionStringsShared: sharedPlatform,
ipv4Addon: true,
isHighAvailability: false,
})
expect(result.transactionShared).toBe(
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres'
)
expect(result.sessionShared).toBe(
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:5432/postgres'
)
expect(result.direct).toBe(
'postgresql://postgres:[YOUR-PASSWORD]@supabase.example.com:5432/postgres'
)
expect(result.transactionDedicated).toBeUndefined()
expect(result.sessionDedicated).toBeUndefined()
// ipv4Addon=true must NOT leak into self-hosted: there's no dedicated pooler here
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
})
test('self-hosted: falls back to port 5432 when db_port is unset', () => {
const result = buildConnectionStringPooler({
deploymentMode: selfHosted,
connectionInfo: { db_host: 'supabase.example.com', db_port: 0 },
connectionStringsShared: sharedPlatform,
ipv4Addon: false,
isHighAvailability: false,
})
expect(result.sessionShared).toContain(':5432/postgres')
expect(result.direct).toContain(':5432/postgres')
})
test('platform high availability: collapses every slot to the direct URI with SSL params', () => {
const directUri = `${sharedPlatform.direct.uri}?${HIGH_AVAILABILITY_SSL_PARAMS}`
const result = buildConnectionStringPooler({
deploymentMode: platform,
connectionInfo,
connectionStringsShared: sharedPlatform,
connectionStringsDedicated: dedicatedPlatform,
ipv4Addon: true,
isHighAvailability: true,
})
expect(result.direct).toBe(directUri)
expect(result.transactionShared).toBe(directUri)
expect(result.sessionShared).toBe(directUri)
// No pooler on Multigres: dedicated slots stay empty and the IPv4 flag is
// off even when a dedicated pooler config and the addon were passed in
expect(result.transactionDedicated).toBeUndefined()
expect(result.sessionDedicated).toBeUndefined()
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
})
test('platform without high availability appends no SSL params', () => {
const result = buildConnectionStringPooler({
deploymentMode: platform,
connectionInfo,
connectionStringsShared: sharedPlatform,
connectionStringsDedicated: dedicatedPlatform,
ipv4Addon: true,
isHighAvailability: false,
})
expect(result.direct).toBe(sharedPlatform.direct.uri)
expect(result.transactionShared).toBe(sharedPlatform.pooler.uri)
expect(result.direct).not.toContain('sslnegotiation')
expect(result.transactionShared).not.toContain('sslnegotiation')
})
})
describe('appendHighAvailabilitySslParams', () => {
test('appends with ? when the URI has no query string', () => {
expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db')).toBe(
`postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}`
)
})
test('appends with & when the URI already has a query string', () => {
expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db?options=x')).toBe(
`postgresql://u:p@host:5432/db?options=x&${HIGH_AVAILABILITY_SSL_PARAMS}`
)
})
test('does not double-append when sslnegotiation is already present', () => {
const uri = `postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}`
expect(appendHighAvailabilitySslParams(uri)).toBe(uri)
})
test('leaves an empty string untouched', () => {
expect(appendHighAvailabilitySslParams('')).toBe('')
})
})
describe('getHighAvailabilityLoadBalancerConnectionInfo', () => {
test('swaps the port for the load balancer port and preserves the other fields', () => {
const connectionInfo = {
db_user: 'postgres',
db_port: 5432,
db_host: 'db.proj.supabase.co',
db_name: 'postgres',
}
expect(getHighAvailabilityLoadBalancerConnectionInfo(connectionInfo)).toEqual({
db_user: 'postgres',
db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
db_host: 'db.proj.supabase.co',
db_name: 'postgres',
})
})
test('builds a read-only load balancer connection string on port 5433 with SSL params', () => {
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo({
db_user: 'postgres',
db_port: 5432,
db_host: 'db.proj.supabase.co',
db_name: 'postgres',
})
const result = buildConnectionStringPooler({
deploymentMode: platform,
connectionInfo: loadBalancerInfo,
connectionStringsShared: getConnectionStrings({
connectionInfo: loadBalancerInfo,
metadata: { projectRef: 'proj' },
}),
ipv4Addon: false,
isHighAvailability: true,
})
expect(result.direct).toBe(
`postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5433/postgres?${HIGH_AVAILABILITY_SSL_PARAMS}`
)
})
})