Files
zpan/server/test/setup.ts
T
Jasper Van 3e6d3ee63b feat: v2.4.0 T5 — /api/ihost/config + Cloudflare for SaaS integration (#316)
* feat: add /api/ihost/config endpoint with Cloudflare for SaaS integration

- Add CfCustomHostnamesClient service (thin CF API wrapper; no-op when CF env vars absent)
- Add /api/ihost/config route (GET/PUT/DELETE) following email-config pattern
- GET lazily refreshes domain verification from CF; PUT upserts config, registers/deregisters CF hostnames; DELETE best-effort CF cleanup + row removal
- PUT rejects enabled=false (must use DELETE); validates customDomain hostname format; validates refererAllowlist entries as URL origins; catches unique constraint → 409
- Add putIhostConfigSchema and IhostConfigResponse to shared schemas/types
- Mount route in app.ts under /api/ihost/config
- Add image_hosting_configs and image_hostings tables to test setup SQL
- Add 22 integration tests covering all acceptance criteria
- Update v2.4.md roadmap with config API notes; add docs/ihost-custom-domain-node.md

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix(ihost-config): restrict PUT/DELETE to owner role, add CF client unit tests, fix CodeQL URL check

- Change requireTeamRole('editor') → requireTeamRole('owner') on PUT and DELETE (spec requires owner/admin only)
- Add explicit editor-role 403 tests for PUT and DELETE
- Add server/services/cf-custom-hostnames.test.ts: 16 unit tests covering register/getStatus/delete success, 409/4xx/network errors, no-op behavior, createCfClient factory
- Add integration tests: GET domainStatus=verified, domainStatus=none, refererAllowlist JSON parsing, CF lazy verification active/pending paths, dnsInstructions CNAME vs manual, APP_HOST rejection, CF register on PUT, CF delete+register on domain change, CF 409 from register, clear refererAllowlist, DELETE best-effort CF cleanup (success + fail-graceful)
- Replace .includes('cloudflare.com') with new URL(url).host === 'api.cloudflare.com' to fix CodeQL CWE-20 incomplete URL substring sanitization
- Make createTestApp accept optional envOverrides to enable CF-configured integration tests

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test(ihost-config): add coverage for uncovered error paths to reach 95%

Add 4 targeted integration tests that cover the previously-uncovered
branches in server/routes/ihost-config.ts:
- PUT INSERT: CF register() throws non-CfConflict error → propagates
- PUT UPDATE: CF delete() fails (best-effort console.warn) → request succeeds
- PUT UPDATE: CF register() throws non-CfConflict error → propagates
- PUT UPDATE: DB unique constraint on UPDATE → 409 (org2 steals org1 domain)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 04:00:17 -04:00

309 lines
11 KiB
TypeScript

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { createApp } from '../app'
import { createAuth } from '../auth'
import * as authSchema from '../db/auth-schema'
import * as schema from '../db/schema'
import type { Platform } from '../platform/interface'
const AUTH_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified INTEGER NOT NULL DEFAULT 0,
image TEXT,
role TEXT,
banned INTEGER DEFAULT 0,
ban_reason TEXT,
ban_expires INTEGER,
username TEXT UNIQUE,
display_username TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
ip_address TEXT,
user_agent TEXT,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
impersonated_by TEXT,
active_organization_id TEXT
);
CREATE INDEX IF NOT EXISTS session_userId_idx ON session(user_id);
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS account_userId_idx ON account(user_id);
CREATE TABLE IF NOT EXISTS verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification(identifier);
CREATE TABLE IF NOT EXISTS organization (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
logo TEXT,
metadata TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE TABLE IF NOT EXISTS member (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS member_organizationId_idx ON member(organization_id);
CREATE INDEX IF NOT EXISTS member_userId_idx ON member(user_id);
CREATE TABLE IF NOT EXISTS invitation (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
expires_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
inviter_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS invitation_organizationId_idx ON invitation(organization_id);
CREATE INDEX IF NOT EXISTS invitation_email_idx ON invitation(email);
`
const APP_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS matters (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
alias TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
type TEXT NOT NULL,
size INTEGER DEFAULT 0,
dirtype INTEGER DEFAULT 0,
parent TEXT NOT NULL DEFAULT '',
object TEXT NOT NULL DEFAULT '',
storage_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
trashed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS storages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
mode TEXT NOT NULL,
bucket TEXT NOT NULL,
endpoint TEXT NOT NULL,
region TEXT NOT NULL DEFAULT 'auto',
access_key TEXT NOT NULL,
secret_key TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '$UID/$RAW_NAME',
custom_host TEXT DEFAULT '',
capacity INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS org_quotas (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
quota INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS system_options (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
public INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS invite_codes (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL,
used_by TEXT,
used_at INTEGER,
expires_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS team_invite_links (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
organization_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
inviter_id TEXT NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS team_invite_links_token_unique ON team_invite_links(token);
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_name TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id);
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
matter_id TEXT NOT NULL,
org_id TEXT NOT NULL,
creator_id TEXT NOT NULL,
password_hash TEXT,
expires_at INTEGER,
download_limit INTEGER,
views INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
CREATE TABLE IF NOT EXISTS share_recipients (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
recipient_user_id TEXT,
recipient_email TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id);
CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id);
CREATE TABLE IF NOT EXISTS image_hosting_configs (
org_id TEXT PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE,
custom_domain TEXT UNIQUE,
cf_hostname_id TEXT,
domain_verified_at INTEGER,
referer_allowlist TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS image_hostings (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
path TEXT NOT NULL,
storage_id TEXT NOT NULL,
storage_key TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
width INTEGER,
height INTEGER,
status TEXT NOT NULL DEFAULT 'draft',
access_count INTEGER NOT NULL DEFAULT 0,
last_accessed_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS image_hostings_org_path_uniq ON image_hostings(org_id, path);
CREATE INDEX IF NOT EXISTS image_hostings_org_created_idx ON image_hostings(org_id, created_at);
CREATE INDEX IF NOT EXISTS image_hostings_token_idx ON image_hostings(token);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
ref_type TEXT,
ref_id TEXT,
metadata TEXT,
read_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications(user_id, created_at);
CREATE INDEX IF NOT EXISTS notifications_user_read_idx ON notifications(user_id, read_at);
CREATE TABLE IF NOT EXISTS image_hosting_configs (
org_id TEXT PRIMARY KEY,
custom_domain TEXT UNIQUE,
cf_hostname_id TEXT,
domain_verified_at INTEGER,
referer_allowlist TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS image_hostings (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
path TEXT NOT NULL,
storage_id TEXT NOT NULL,
storage_key TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
width INTEGER,
height INTEGER,
status TEXT NOT NULL DEFAULT 'draft',
access_count INTEGER NOT NULL DEFAULT 0,
last_accessed_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS image_hostings_org_path_uniq ON image_hostings(org_id, path);
CREATE INDEX IF NOT EXISTS image_hostings_org_created_idx ON image_hostings(org_id, created_at);
CREATE INDEX IF NOT EXISTS image_hostings_token_idx ON image_hostings(token);
`
export async function createTestApp(envOverrides: Record<string, string> = {}) {
const sqlite = new Database(':memory:')
sqlite.exec(AUTH_SCHEMA_SQL)
sqlite.exec(APP_SCHEMA_SQL)
const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } })
const platform: Platform = {
db,
getEnv: (key: string) => envOverrides[key],
}
const auth = await createAuth(db, 'test-secret', 'http://localhost:3000')
const app = createApp(platform, auth)
return { app, db, auth }
}
export async function adminHeaders(app: ReturnType<typeof createApp>) {
// Sign up first user (gets promoted to admin via hook)
await authedHeaders(app, 'admin@example.com', 'password123456')
// Sign in again to get a session that reflects the admin role
const signInRes = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@example.com', password: 'password123456' }),
})
return { Cookie: signInRes.headers.getSetCookie().join('; ') }
}
export async function authedHeaders(
app: ReturnType<typeof createApp>,
email = 'test@example.com',
password = 'password123456',
) {
const signUpRes = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test User', email, password }),
})
const cookies = signUpRes.headers.getSetCookie()
return { Cookie: cookies.join('; ') }
}