Files
hackerspace.sh/__tests__/tenant.test.ts
T
Moheeb Zara 7b8d2ae167 feat(tenant): setup CLI, CD glue, bundled compose, docs + security fix
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.
2026-08-05 13:02:18 -07:00

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)
})
})