Tier 2 productization for single-tenant instances, plus fixes from an adversarial review pass. New: - scripts/setup.mjs: 'pnpm setup' CLI (doctor/provision/create-admin/all), idempotent, creates the first admin via the Supabase Admin API (email_confirm), flag > env > .env.local precedence, masked interactive prompts, --yes/--force. - scripts/apply-migrations.sh + deploy/deploy.sh: reproducible, idempotent CD glue (previously only on the prod droplet), tracked via public._migrations_applied. - docker-compose.full.yml + .env.full.example: one-command app + self-hosted Supabase bundle (compose config validated). - docs/SINGLE_TENANT.md (deploy + CLI guide) and docs/CUSTOMIZE.md (the documented lib/tenant.ts config pattern for white-labeling); README pointer. - __tests__/tenant.test.ts: 19 unit tests for resolveTenantConfig + isMarketingPath. Security (found by review, pre-existing): - Drop the stale handle_space_signup() auth trigger, which derived member role and space from client-controlled raw_user_meta_data and could self-insert an admin. Membership is created only by the vetted server actions. Migration scripts/056_drop_stale_signup_trigger.sql + folded into schema.sql. Fixes: - setup.mjs masked-password prompt hung (callback passed to readline/promises question); use the awaited form. - notifications-enqueue.test: base-URL default is now localhost (platform-domain fallback removed), not hackerspace.sh. - docs accuracy (logo/BrandMark, apply-migrations does not restart PostgREST, --yes). Gates: tsc 0, lint 0 errors, 664 unit tests, build ok.
158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { resolveTenantConfig, isMarketingPath } from '@/lib/tenant'
|
|
|
|
describe('resolveTenantConfig', () => {
|
|
it('applies platform defaults when every flag is empty', () => {
|
|
expect(resolveTenantConfig({})).toEqual({
|
|
singleTenant: false,
|
|
siteName: 'hackerspace.sh',
|
|
appUrl: 'http://localhost:3000',
|
|
spaceSlug: null,
|
|
openJoin: false,
|
|
allowSpaceCreation: true,
|
|
showMarketing: true,
|
|
})
|
|
})
|
|
|
|
it('treats singleTenant as true only for the exact string "true"', () => {
|
|
expect(resolveTenantConfig({ singleTenant: 'true' }).singleTenant).toBe(true)
|
|
expect(resolveTenantConfig({ singleTenant: 'TRUE' }).singleTenant).toBe(false)
|
|
expect(resolveTenantConfig({ singleTenant: '1' }).singleTenant).toBe(false)
|
|
expect(resolveTenantConfig({ singleTenant: '' }).singleTenant).toBe(false)
|
|
})
|
|
|
|
describe('single-tenant mode', () => {
|
|
it('forbids space creation and hides marketing by default', () => {
|
|
const cfg = resolveTenantConfig({ singleTenant: 'true' })
|
|
expect(cfg.allowSpaceCreation).toBe(false)
|
|
expect(cfg.showMarketing).toBe(false)
|
|
})
|
|
|
|
it('lowercases the configured space slug', () => {
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', spaceSlug: 'HeatSync-Labs' })
|
|
.spaceSlug,
|
|
).toBe('heatsync-labs')
|
|
})
|
|
|
|
it('falls back to null slug when unset or blank', () => {
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true' }).spaceSlug,
|
|
).toBeNull()
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', spaceSlug: ' ' }).spaceSlug,
|
|
).toBeNull()
|
|
})
|
|
|
|
it('enables open join only when openJoin is exactly "true"', () => {
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', openJoin: 'true' }).openJoin,
|
|
).toBe(true)
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', openJoin: 'false' }).openJoin,
|
|
).toBe(false)
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', openJoin: 'TRUE' }).openJoin,
|
|
).toBe(false)
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true' }).openJoin,
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('ignores openJoin in multi-tenant mode even when set to "true"', () => {
|
|
expect(
|
|
resolveTenantConfig({ openJoin: 'true' }).openJoin,
|
|
).toBe(false)
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'false', openJoin: 'true' }).openJoin,
|
|
).toBe(false)
|
|
})
|
|
|
|
describe('showMarketing explicit override', () => {
|
|
it('re-enables marketing in single-tenant mode when set to "true"', () => {
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', showMarketing: 'true' })
|
|
.showMarketing,
|
|
).toBe(true)
|
|
})
|
|
|
|
it('disables marketing in multi-tenant mode when set to "false"', () => {
|
|
expect(
|
|
resolveTenantConfig({ showMarketing: 'false' }).showMarketing,
|
|
).toBe(false)
|
|
})
|
|
|
|
it('treats any non-"true" explicit value as false', () => {
|
|
expect(
|
|
resolveTenantConfig({ singleTenant: 'true', showMarketing: 'yes' })
|
|
.showMarketing,
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('appUrl normalization', () => {
|
|
it('strips trailing slashes', () => {
|
|
expect(resolveTenantConfig({ appUrl: 'https://example.org/' }).appUrl).toBe(
|
|
'https://example.org',
|
|
)
|
|
expect(
|
|
resolveTenantConfig({ appUrl: 'https://example.org///' }).appUrl,
|
|
).toBe('https://example.org')
|
|
})
|
|
|
|
it('trims and falls back to the default when blank', () => {
|
|
expect(resolveTenantConfig({ appUrl: '' }).appUrl).toBe(
|
|
'http://localhost:3000',
|
|
)
|
|
expect(resolveTenantConfig({ appUrl: ' ' }).appUrl).toBe(
|
|
'http://localhost:3000',
|
|
)
|
|
})
|
|
|
|
it('leaves a URL with no trailing slash untouched', () => {
|
|
expect(resolveTenantConfig({ appUrl: 'https://example.org' }).appUrl).toBe(
|
|
'https://example.org',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('siteName', () => {
|
|
it('trims whitespace', () => {
|
|
expect(resolveTenantConfig({ siteName: ' Acme Space ' }).siteName).toBe(
|
|
'Acme Space',
|
|
)
|
|
})
|
|
|
|
it('falls back to the default when blank', () => {
|
|
expect(resolveTenantConfig({ siteName: '' }).siteName).toBe('hackerspace.sh')
|
|
expect(resolveTenantConfig({ siteName: ' ' }).siteName).toBe(
|
|
'hackerspace.sh',
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('isMarketingPath', () => {
|
|
it('matches marketing roots and their subpaths', () => {
|
|
expect(isMarketingPath('/')).toBe(true)
|
|
expect(isMarketingPath('/resources')).toBe(true)
|
|
expect(isMarketingPath('/resources/foo')).toBe(true)
|
|
expect(isMarketingPath('/atlas.html')).toBe(true)
|
|
})
|
|
|
|
it('rejects app paths', () => {
|
|
expect(isMarketingPath('/dashboard')).toBe(false)
|
|
expect(isMarketingPath('/login')).toBe(false)
|
|
})
|
|
|
|
it('does not treat a prefix collision as a subpath', () => {
|
|
expect(isMarketingPath('/resourcesx')).toBe(false)
|
|
})
|
|
|
|
it('matches the root exactly without swallowing every path', () => {
|
|
expect(isMarketingPath('/')).toBe(true)
|
|
expect(isMarketingPath('/dashboard')).toBe(false)
|
|
})
|
|
})
|