diff --git a/.env.example b/.env.example index 9bec51ec64..b724838845 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,14 @@ # Get your API key from: https://console.anthropic.com/ ANTHROPIC_API_KEY=your_anthropic_api_key_here +# Cerebras (High-performance inference) +# Get your API key from: https://cloud.cerebras.ai/settings +CEREBRAS_API_KEY=your_cerebras_api_key_here + +# Fireworks AI (Fast inference with FireAttention engine) +# Get your API key from: https://fireworks.ai/api-keys +FIREWORKS_API_KEY=your_fireworks_api_key_here + # OpenAI GPT models # Get your API key from: https://platform.openai.com/api-keys OPENAI_API_KEY=your_openai_api_key_here @@ -59,6 +67,10 @@ XAI_API_KEY=your_xai_api_key_here # Get your API key from: https://platform.moonshot.ai/console/api-keys MOONSHOT_API_KEY=your_moonshot_api_key_here +# Z.AI (GLM models with JWT authentication) +# Get your API key from: https://open.bigmodel.cn/usercenter/apikeys +ZAI_API_KEY=your_zai_api_key_here + # Hugging Face # Get your API key from: https://huggingface.co/settings/tokens HuggingFace_API_KEY=your_huggingface_api_key_here diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..09b2067227 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + + +## Definition of Done +- [ ] **Shipped**: Merged + deployed +- [ ] **Tested**: Jest unit + integration tests pass; Cypress E2E tests pass +- [ ] **Logged**: Funnel events + audit log entries where applicable +- [ ] **Secured**: Zod validation, RBAC, rate limits, sanitization +- [ ] **Documented**: SETUP.md updated if keys/config changed + +## Test Plan + + +## Checklist +- [ ] No secrets/PII in code or logs +- [ ] Idempotent webhook/job handlers +- [ ] Request size limits enforced +- [ ] Error handling with typed AppError +- [ ] Structured logs with request_id diff --git a/.github/workflows/project-sites.yaml b/.github/workflows/project-sites.yaml new file mode 100644 index 0000000000..ae20dbf20b --- /dev/null +++ b/.github/workflows/project-sites.yaml @@ -0,0 +1,234 @@ +name: Project Sites CI/CD + +on: + push: + branches: [main, staging] + paths: + - 'apps/project-sites/**' + - 'packages/shared/**' + - 'supabase/migrations/**' + pull_request: + branches: [main] + paths: + - 'apps/project-sites/**' + - 'packages/shared/**' + - 'supabase/migrations/**' + +env: + NODE_VERSION: '20.18.0' + +jobs: + # ─── Stage 1: Auto-fix + Lint + Typecheck ────────────────── + check: + name: Typecheck + Lint + Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install shared deps + working-directory: packages/shared + run: npm ci --legacy-peer-deps + + - name: Install worker deps + working-directory: apps/project-sites + run: npm ci --legacy-peer-deps + + - name: Typecheck shared + working-directory: packages/shared + run: npm run typecheck + + - name: Typecheck worker + working-directory: apps/project-sites + run: npm run typecheck + + - name: Lint shared + working-directory: packages/shared + run: npm run lint + + - name: Lint worker + working-directory: apps/project-sites + run: npm run lint + + - name: Format check shared + working-directory: packages/shared + run: npm run format:check + + - name: Format check worker + working-directory: apps/project-sites + run: npm run format:check + + # ─── Stage 2: Unit / Functional Tests (Jest) ─────────────── + test-unit: + name: Unit Tests + needs: [check] + runs-on: ubuntu-latest + env: + ENVIRONMENT: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install shared deps + working-directory: packages/shared + run: npm ci --legacy-peer-deps + + - name: Install worker deps + working-directory: apps/project-sites + run: npm ci --legacy-peer-deps + + - name: Test shared package + working-directory: packages/shared + run: npm run test:unit -- --coverage + + - name: Test worker package + working-directory: apps/project-sites + run: npm run test:unit -- --coverage + + - name: Upload coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-reports + path: | + packages/shared/coverage + apps/project-sites/coverage + + # ─── Stage 3: Deploy to Staging ──────────────────────────── + deploy-staging: + name: Deploy to Staging + needs: [test-unit] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' + runs-on: ubuntu-latest + environment: staging + outputs: + deployment-version: ${{ steps.deploy.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install worker deps + working-directory: apps/project-sites + run: npm ci --legacy-peer-deps + + - name: Deploy Worker to staging + id: deploy + working-directory: apps/project-sites + run: | + npx wrangler deploy --env staging 2>&1 | tee deploy.log + echo "version=$(date +%s)" >> "$GITHUB_OUTPUT" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + + # ─── Stage 4: Playwright E2E on Staging ─────────────────── + e2e-staging: + name: E2E Tests (Staging) + needs: [deploy-staging] + runs-on: ubuntu-latest + env: + BASE_URL: https://sites-staging.megabyte.space + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install worker deps + working-directory: apps/project-sites + run: npm ci --legacy-peer-deps + + - name: Install Playwright browsers + working-directory: apps/project-sites + run: npx playwright install --with-deps chromium + + - name: Run Playwright E2E on staging + working-directory: apps/project-sites + run: npx playwright test + env: + BASE_URL: ${{ env.BASE_URL }} + + - name: Upload test results on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results-staging + path: apps/project-sites/test-results + + # ─── Stage 5: Deploy to Production ───────────────────────── + deploy-production: + name: Deploy to Production + needs: [e2e-staging] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install worker deps + working-directory: apps/project-sites + run: npm ci --legacy-peer-deps + + - name: Deploy Worker to production + working-directory: apps/project-sites + run: npx wrangler deploy --env production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + + # ─── Stage 6: Post-deploy E2E on Production ──────────────── + e2e-production: + name: Post-Deploy E2E (Production) + needs: [deploy-production] + runs-on: ubuntu-latest + env: + BASE_URL: https://sites.megabyte.space + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install worker deps + working-directory: apps/project-sites + run: npm ci --legacy-peer-deps + + - name: Install Playwright browsers + working-directory: apps/project-sites + run: npx playwright install --with-deps chromium + + - name: Run production smoke tests + working-directory: apps/project-sites + run: npx playwright test + env: + BASE_URL: ${{ env.BASE_URL }} + + - name: Upload test results on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results-production + path: apps/project-sites/test-results + + # ─── Rollback if E2E fails ────────────────────────────── + - name: Rollback on failure + if: failure() + working-directory: apps/project-sites + run: | + echo "::error::Production E2E failed - triggering rollback" + npx wrangler rollback --env production --message "Automated rollback: E2E failed" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 4bc03e175d..67ef3de1f5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ functions/build/ *.vars .wrangler _worker.bundle +package-lock.json +test-results/ +playwright-report/ Modelfile modelfiles diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..8e83c789f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,281 @@ +# bolt.diy Monorepo — AI Context Guide + +> **Purpose**: This file is the primary AI onboarding document for the bolt.diy monorepo. +> A new Claude Code session reading only this file should understand the project structure, +> development patterns, and how to get productive immediately. + +## Quick Orientation + +This is a monorepo containing: + +1. **Root app** (`/app`): A Remix + Vite web app for bolt.diy (AI code editor), deployed to Cloudflare Pages at `bolt.megabyte.space` +2. **Project Sites Worker** (`/apps/project-sites`): A Cloudflare Worker (Hono) that powers the SaaS website delivery engine at `sites.megabyte.space` +3. **Shared Package** (`/packages/shared`): Zod schemas, constants, RBAC middleware, utilities shared between packages +4. **Database Schema** (`/supabase/migrations/`): Reference Postgres schema (D1 SQLite equivalent used in production) + +The **primary development focus** in recent sessions has been on `apps/project-sites/` and `packages/shared/`. + +## Repository Structure + +``` +bolt.diy/ +├── app/ # Remix frontend (bolt.diy AI code editor) +├── apps/ +│ └── project-sites/ # Cloudflare Worker → sites.megabyte.space +│ ├── src/ +│ │ ├── index.ts # Hono app entry point +│ │ ├── types/env.ts # Env bindings + Variables +│ │ ├── middleware/ # auth, error_handler, payload_limit, request_id, security_headers +│ │ ├── routes/ # api.ts, health.ts, search.ts, webhooks.ts +│ │ ├── services/ # ai_workflows, analytics, audit, auth, billing, db, domains, sentry, site_serving, webhook +│ │ ├── prompts/ # TS infra: parser, renderer, registry, schemas, observability, types +│ │ ├── workflows/ # site-generation.ts (Cloudflare Workflow) +│ │ ├── lib/ # posthog.ts, sentry.ts +│ │ └── __tests__/ # 25 test suites +│ ├── prompts/ # .prompt.md files (YAML frontmatter + # System/# User) +│ ├── public/ # index.html (marketing SPA), static assets +│ ├── e2e/ # Playwright E2E specs +│ ├── wrangler.toml # Worker config (dev/staging/production) +│ ├── jest.config.cjs +│ └── playwright.config.ts +├── packages/ +│ └── shared/ # @project-sites/shared +│ └── src/ +│ ├── schemas/ # Zod: org, site, billing, auth, audit, webhook, workflow, config, analytics, hostname, api +│ ├── middleware/ # RBAC + entitlements +│ ├── utils/ # errors, crypto, sanitize, redact +│ └── constants/ # DOMAINS, AUTH, PRICING, CAPS, ENTITLEMENTS, ROLES +├── supabase/migrations/ # Reference Postgres schema (D1 SQLite used in prod) +├── docs/ # MkDocs documentation site +├── electron/ # Electron desktop wrapper +└── .github/workflows/ # CI/CD pipelines +``` + +## MANDATORY: Test-Driven Development (TDD) + +> **ALL development in this repository MUST follow strict Test-Driven Development.** +> This is NON-NEGOTIABLE. No feature, bug fix, or refactor may be merged without tests. + +### TDD Workflow (Red → Green → Refactor) + +1. **Write failing tests FIRST** — Before writing any implementation code, write unit tests that describe the expected behavior. Run them and confirm they fail. +2. **Write the minimum code to pass** — Implement just enough to make the tests pass. +3. **Refactor** — Clean up the implementation while keeping all tests green. +4. **Write E2E tests** — After the unit tests pass, write Playwright E2E tests that cover the full user flow (see below). +5. **All tests must pass** — Run `npm test` in both `packages/shared/` and `apps/project-sites/`, plus `npx playwright test` for E2E, before considering any work complete. + +### Unit Test Requirements +- Every new function, route handler, or service method MUST have corresponding unit tests +- Test the happy path AND error/edge cases (invalid input, unauthorized, rate limiting, etc.) +- Mock external dependencies (D1, fetch, KV, R2) — never call real APIs in unit tests +- Minimum coverage expectation: all branches of new code must be tested + +### E2E Test Requirements (Playwright) +- Every user-facing feature MUST have E2E tests that cover the **complete user flow** +- E2E flows should cover realistic scenarios end-to-end: + - Loading the marketing homepage + - Searching for a business + - Selecting a result from the dropdown + - Navigating to the details screen + - Signing in (Google OAuth or Email Magic Link) + - Triggering a website build + - Verifying the waiting/progress screen + - Verifying the built site is accessible +- E2E test files live in `apps/project-sites/e2e/` +- Use the custom fixture from `e2e/fixtures.ts` (blocks external CDN requests) +- Run with: `npx playwright test` in `apps/project-sites/` + +### Test Commands +```bash +# Unit tests +cd apps/project-sites && npm test +cd packages/shared && npm test + +# E2E tests +cd apps/project-sites && npx playwright test + +# All checks (unit + typecheck + lint + format) +cd apps/project-sites && npm run check +cd packages/shared && npm run check +``` + +## MANDATORY: Auto-Deploy After Each Session + +> **After completing all changes**, you MUST deploy to staging (and production if on main). +> If `CLOUDFLARE_API_KEY` and `CLOUDFLARE_EMAIL` are not set as environment variables, +> **ask the user to provide them** before deploying. + +### Deploy Checklist +1. Run all unit tests (`npm test` in both packages) — all must pass +2. Run E2E tests (`npx playwright test`) — all must pass +3. Run typecheck (`npm run typecheck`) — no errors +4. Run lint (`npm run lint`) — no errors +5. Deploy to staging: `cd apps/project-sites && npx wrangler deploy --env staging` +6. Upload marketing homepage: `npx wrangler r2 object put project-sites-staging/marketing/index.html --file public/index.html --content-type text/html --remote` +7. If on main branch, also deploy to production after verifying staging + +### Deployment Credentials +- **CLOUDFLARE_API_KEY** and **CLOUDFLARE_EMAIL** must be set as env vars for `wrangler deploy` +- If not available, **ASK THE USER** for these credentials before deploying +- **NEVER** modify secrets that are already set in the Cloudflare dashboard (Stripe keys, SendGrid, Google OAuth, etc.) +- Only use `wrangler secret put` when explicitly asked to set a NEW secret + +## Critical Development Patterns + +### Git / File Operations +- **`.gitignore` blocks `*.md`** — ALWAYS use `git add -f` for markdown files +- Development branch: `claude/setup-cloudflare-workers-gDWiV` +- Never push to main/master without explicit permission + +### Package Management +- pnpm workspace defined but `pnpm install` fails (electron-builder SSH dep) +- **Use `npm install --legacy-peer-deps`** in sub-packages (`apps/project-sites/`, `packages/shared/`) +- Worker dep: `@project-sites/shared` linked via `"file:../../packages/shared"` + +### TypeScript / Build +- All packages use `"type": "module"` in package.json +- `moduleResolution: "Bundler"` in tsconfig +- `.js` extensions in imports (TypeScript resolves them) +- Typecheck: `npx tsc --noEmit` in each package + +### Testing +- **Jest config MUST be `.cjs`** (not `.js` or `.ts`) because `"type": "module"` +- Jest needs `moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' }` for TS imports +- Test counts: worker tests (25 suites) + shared tests (6 suites) + E2E (6 files) +- Run: `npm test` in `apps/project-sites/` or `packages/shared/` +- E2E: Playwright, run with `npx playwright test` in `apps/project-sites/` + +### Linting +- Local `eslint.config.mjs` in each sub-package (root `@blitz/eslint-plugin` not available) +- **`console.log` is blocked by eslint** — use `console.warn` for structured logs +- Run: `npx eslint --config eslint.config.mjs src` in each package + +### Scripts (in each sub-package) +```bash +npm test # Run unit tests +npm run test:watch # Watch mode +npm run test:coverage # With coverage +npm run typecheck # tsc --noEmit +npm run lint # ESLint +npm run format # Prettier write +npm run format:check # Prettier check +npm run check # All of the above +``` + +## Architecture at a Glance + +### Project Sites Worker Stack +| Layer | Technology | Purpose | +|-------|-----------|---------| +| Ingress/API | Cloudflare Workers + Hono | API gateway, site serving | +| Database | Cloudflare D1 (SQLite) | System of record | +| Cache | Cloudflare KV | Host resolution (60s TTL), prompt hot-patching | +| Storage | Cloudflare R2 | Static sites, marketing assets | +| Background | Cloudflare Workflows | AI site generation pipeline | +| AI | Cloudflare Workers AI | LLM inference (Llama 3.1) | +| Payments | Stripe | Checkout, subscriptions, webhooks | +| Email | Resend / SendGrid | Magic links, transactional | +| Analytics | PostHog (server-side) | Funnel events | +| Errors | Sentry (HTTP API) | Exception tracking | + +### Key Design Decisions +- **No Supabase JS client** — D1 via parameterized SQL for Workers compat +- **Dash-based subdomains**: `{slug}-sites.megabyte.space` (not nested wildcards) +- **R2 paths**: `sites/{slug}/{version}/{file}`, marketing at `marketing/index.html` +- **Queues NOT yet enabled** — `QUEUE` binding is optional in Env type +- **CSP must include `'unsafe-inline'`** — homepage uses inline ` + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+
+ + Your Websites + +
+
+ + + +
+
+ + +
+
Loading your websites...
+
+ + +
+
+ + + + + + + + + + + + + + +
+
+ +

Describe your custom website

+

Include everything important for your website. We'll also search the web for public info about your business.

+ + + + + + + +
+ +
+ +
+
+
Improving with AI...
+
+
+
+ +
+ +
+
+ + + +
+
+
+ + +
+ + + + + +
+ +
+ + +
+ + +
+
+
+
+
+
+
+ + + + + +
+
+ +

We're building your website...

+

Give us a few minutes. We'll notify you when it's ready.

+ + +
+
+
+
+
+ build-progress +
+
+
Initializing build pipeline...
+
+
+
+
+ +
+ + + + + + + + + + + + + + diff --git a/apps/project-sites/public/logo-header.png b/apps/project-sites/public/logo-header.png new file mode 100644 index 0000000000..d69590d83e Binary files /dev/null and b/apps/project-sites/public/logo-header.png differ diff --git a/apps/project-sites/public/logo-header.svg b/apps/project-sites/public/logo-header.svg new file mode 100644 index 0000000000..c231e56d4e --- /dev/null +++ b/apps/project-sites/public/logo-header.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + Project Sites + + diff --git a/apps/project-sites/public/logo-icon.svg b/apps/project-sites/public/logo-icon.svg new file mode 100644 index 0000000000..21b8a0129d --- /dev/null +++ b/apps/project-sites/public/logo-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/project-sites/public/logo.svg b/apps/project-sites/public/logo.svg new file mode 100644 index 0000000000..49b1689e69 --- /dev/null +++ b/apps/project-sites/public/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/project-sites/public/privacy.html b/apps/project-sites/public/privacy.html new file mode 100644 index 0000000000..5c601845a9 --- /dev/null +++ b/apps/project-sites/public/privacy.html @@ -0,0 +1,242 @@ + + + + + + Privacy Policy - Project Sites + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+

Privacy Policy

+

Last updated: February 2025

+ +

This privacy policy explains how Megabyte LLC ("we," "us," or "our"), operating Project Sites at sites.megabyte.space, collects, uses, protects, and handles your Personally Identifiable Information (PII). PII, as described in US privacy law and information security, is information that can be used on its own or with other information to identify, contact, or locate a single person.

+ +

Information We Collect

+ +

What personal information do we collect?

+

When using Project Sites, you may be asked to provide:

+ +

We also automatically collect IP addresses, device identifiers, and browser information for security and analytics purposes.

+ +

When do we collect information?

+

We collect information when you:

+ + +

How We Use Your Information

+

We use collected information to:

+ + +

How We Protect Your Information

+ + +

Cookies

+

We use cookies and similar technologies to:

+ +

You can control cookies through your browser settings. Disabling cookies may affect some functionality.

+ +

Third-Party Services

+

We integrate with the following third-party services:

+ +

Each third-party service has its own privacy policy. We do not sell, trade, or transfer your PII to outside parties except as necessary to operate our service.

+ +

Third-Party Links

+

Our platform may contain links to third-party websites. These sites have their own privacy policies, and we bear no responsibility for their content or practices.

+ +

California Online Privacy Protection Act (CalOPPA)

+

In accordance with CalOPPA:

+ + +

Do Not Track Signals

+

We collect basic analytics data for service improvement. We log IP addresses for security purposes to prevent malicious attacks.

+ +

Children's Privacy (COPPA)

+

Project Sites is not directed at children under 13. We do not knowingly collect personal information from children under 13. If we discover that a child under 13 has provided us with personal information, we will delete it promptly.

+ +

Data Breach Response

+

In accordance with Fair Information Practices, should a data breach occur:

+ + +

CAN-SPAM Compliance

+

We collect your email address to send service-related communications. We agree to:

+ + +

Contact Information

+

If you have questions about this privacy policy, contact us at:

+ +
+ + + + diff --git a/apps/project-sites/public/site.webmanifest b/apps/project-sites/public/site.webmanifest new file mode 100644 index 0000000000..23753d16ea --- /dev/null +++ b/apps/project-sites/public/site.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Project Sites", + "short_name": "Sites", + "start_url": "/", + "id": "/", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#0a0a1a", + "background_color": "#0a0a1a", + "display": "standalone" +} diff --git a/apps/project-sites/public/sw.js b/apps/project-sites/public/sw.js new file mode 100644 index 0000000000..8082b4ff8e --- /dev/null +++ b/apps/project-sites/public/sw.js @@ -0,0 +1,80 @@ +var CACHE_NAME = 'project-sites-v2'; +var ASSETS_TO_CACHE = [ + '/', + '/logo-header.png', + '/logo-icon.svg', + '/icon-192.png', + '/icon-512.png', + '/site.webmanifest' +]; + +// Install: pre-cache essential assets +self.addEventListener('install', function(event) { + event.waitUntil( + caches.open(CACHE_NAME).then(function(cache) { + return cache.addAll(ASSETS_TO_CACHE); + }) + ); + self.skipWaiting(); +}); + +// Activate: clean up old caches +self.addEventListener('activate', function(event) { + event.waitUntil( + caches.keys().then(function(keys) { + return Promise.all( + keys.filter(function(key) { return key !== CACHE_NAME; }) + .map(function(key) { return caches.delete(key); }) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch: stale-while-revalidate for HTML, cache-first for assets +self.addEventListener('fetch', function(event) { + var url = new URL(event.request.url); + + // Only handle same-origin requests + if (url.origin !== self.location.origin) return; + + // Skip API and webhook requests + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/webhooks/')) return; + + // For navigation requests (HTML), use stale-while-revalidate + if (event.request.mode === 'navigate') { + event.respondWith( + caches.open(CACHE_NAME).then(function(cache) { + return cache.match('/').then(function(cached) { + // Always revalidate from '/' (SPA entry point) regardless of the navigation URL + var fetchPromise = fetch('/').then(function(response) { + if (response.ok) { + cache.put('/', response.clone()); + } + return response; + }).catch(function() { + return cached; + }); + return cached || fetchPromise; + }); + }) + ); + return; + } + + // For static assets: cache-first + event.respondWith( + caches.match(event.request).then(function(cached) { + if (cached) return cached; + return fetch(event.request).then(function(response) { + if (response.ok && event.request.method === 'GET') { + var responseClone = response.clone(); + caches.open(CACHE_NAME).then(function(cache) { + cache.put(event.request, responseClone); + }); + } + return response; + }); + }) + ); +}); diff --git a/apps/project-sites/public/terms.html b/apps/project-sites/public/terms.html new file mode 100644 index 0000000000..d3403ae767 --- /dev/null +++ b/apps/project-sites/public/terms.html @@ -0,0 +1,231 @@ + + + + + + Terms of Service - Project Sites + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+

Terms of Service

+

Last updated: February 2025

+ +

This website and platform is owned and operated by Megabyte LLC (also referred to as "Megabyte Labs," "we," "us," or "our"). By visiting sites.megabyte.space and accessing the information, resources, services, products, and tools we provide (collectively, "Project Sites" or the "Service"), you understand and agree to accept and adhere to the following terms and conditions (this "User Agreement"), along with the terms and conditions stated in our Privacy Policy.

+ +

We reserve the right to change this User Agreement from time to time without notice. Your continued use of this site after such modifications will constitute acknowledgment and agreement of the modified terms and conditions.

+ +

Description of Service

+

Project Sites is an AI-powered website generation and hosting platform. The Service allows users to:

+ + +

Responsible Use and Conduct

+

By using our Service, you agree to use it only for lawful purposes as permitted by (a) the terms of this User Agreement and (b) applicable laws and regulations.

+

You understand that:

+ + +

AI-Generated Content

+

Project Sites uses artificial intelligence to generate website content, including text, layouts, and design elements. You acknowledge that:

+ + +

Subscription and Payments

+

Project Sites offers both free and paid subscription tiers:

+ +

All payments are processed through Stripe. By subscribing to a paid plan, you agree to Stripe's Terms of Service. Subscriptions renew automatically unless cancelled before the renewal date.

+ +

User Content

+

You retain ownership of any content you upload or create through the Service. By using the Service, you grant Megabyte LLC a non-exclusive, worldwide license to host, display, and distribute your content as necessary to provide the Service.

+

You agree not to upload, post, or distribute any content that:

+ +

We reserve the right to remove any content that violates these terms.

+ +

Privacy

+

Your privacy is important to us. Please review our Privacy Policy, which explains how we collect, manage, and protect your personal information. The Privacy Policy is incorporated into this User Agreement.

+ +

Limitation of Warranties

+

The Service is provided "as is" and "as available." We do not warrant that:

+ +

You download or obtain content through the Service at your own discretion and risk.

+ +

Limitation of Liability

+

Any claim against us shall be limited to the amount you paid, if any, for use of the Service. Megabyte LLC will not be liable for any direct, indirect, incidental, consequential, or exemplary damages which may be incurred by you as a result of using the Service, to the full extent permitted by applicable law.

+ +

Intellectual Property

+

All content and materials available on Project Sites, including but not limited to text, graphics, code, images, logos, and the platform's software, are the intellectual property of Megabyte LLC and are protected by applicable copyright and trademark law. Unauthorized reproduction, distribution, or transmission of any platform content is strictly prohibited.

+ +

Termination

+

We may, at our sole discretion, suspend or terminate your access to the Service with or without notice and for any reason, including breach of this User Agreement. Upon termination:

+ + +

Governing Law

+

This User Agreement is controlled by Megabyte LLC from offices located in the state of New Jersey, USA. By accessing our Service, you agree that the statutes and laws of New Jersey, without regard to conflict of laws provisions, will apply to all matters relating to the use of the Service. Any action to enforce this agreement shall be brought in the federal or state courts located in New Jersey, USA.

+ +

Disclaimer

+

UNLESS OTHERWISE EXPRESSED, MEGABYTE LLC EXPRESSLY DISCLAIMS ALL WARRANTIES AND CONDITIONS OF ANY KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO THE IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.

+ +

Contact Information

+

If you have questions about these Terms of Service, contact us at:

+ +
+ + + + diff --git a/apps/project-sites/r2-sync/marketing/index.html b/apps/project-sites/r2-sync/marketing/index.html new file mode 100644 index 0000000000..167587bf72 --- /dev/null +++ b/apps/project-sites/r2-sync/marketing/index.html @@ -0,0 +1,3873 @@ + + + + + + + Project Sites - Your Website, Handled. Finally. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + +
+
+ +
+
+ + +
+ + + + + +
+ +
+ + +
+
+

Tell us more about your business

+

Include everything important for your website. We'll also search the web for public info about your business.

+ + + + + + + +
+ + +
+ +
+ +
+
+ + + +
+ + +
+
+ + +
+
+
+
+
+
+
+ + + + + +
+
+ +

We're building your website...

+

Give us a few minutes. We'll notify you when it's ready.

+

+ +
+ + Build in progress + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/project-sites/r2-sync/sites/bella-cucina/v1/index.html b/apps/project-sites/r2-sync/sites/bella-cucina/v1/index.html new file mode 100644 index 0000000000..af5420d15c --- /dev/null +++ b/apps/project-sites/r2-sync/sites/bella-cucina/v1/index.html @@ -0,0 +1,679 @@ + + + + + + Bella Cucina | Authentic Italian Restaurant + + + + + + + + + + +
+
+ Est. 2018 — Downtown Manhattan +

Authentic Italian,
Made with Love

+

Hand-crafted pasta, wood-fired pizza, and timeless Italian recipes passed down through generations. Every dish tells a story.

+ +
+ Scroll to explore +
+ + + + + +
+
+
👨‍🍳
+
+ +

A Family Tradition, A New York Home

+

+ Bella Cucina was born from a simple dream: bring the soul of Italian cooking to the streets of Manhattan. + Founded by Chef Marco Bellini in 2018, every recipe traces back to his grandmother's kitchen in Naples. +

+

+ We make our pasta fresh every morning, source ingredients from local farms and Italian importers, + and fire our pizzas in a custom-built 900°F wood oven. It's not just a meal—it's an experience. +

+
+
+
6
+
Years Open
+
+
+
50K+
+
Guests Served
+
+
+
4.8
+
Google Rating
+
+
+
+
+
+ + +
+
+ +

Loved by Our Guests

+

Don't just take our word for it. Here's what our guests have to say.

+
+ +
+
+
★★★★★
+

"The best Italian food I've had outside of Italy. The cacio e pepe is absolutely perfect—simple, authentic, unforgettable."

+
Sarah M. — Google Reviews
+
+
+
★★★★★
+

"We come here every Friday night. The wood-fired pizza is incredible, and the atmosphere is warm and welcoming. It feels like family."

+
James & Lisa T. — Yelp
+
+
+
★★★★★
+

"Chef Marco's truffle ravioli is a masterpiece. Pair it with a glass of Barolo and you're in heaven. Best date-night spot in the city."

+
David R. — TripAdvisor
+
+
+
+ + +
+
+ +

Come Hungry, Leave Happy

+
+ +
+
+

Location

+

+ 742 Evergreen Terrace
+ Manhattan, NY 10012

+ (212) 555-1234
+ hello@bellacucina.com +

+
+ +
+

Hours

+
+ Monday - Thursday + 11:30 AM - 10:00 PM +
+
+ Friday - Saturday + 11:30 AM - 11:30 PM +
+
+ Sunday + 12:00 PM - 9:00 PM +
+
+ Sunday Brunch + 10:00 AM - 2:00 PM +
+
+ +
+

Reservations

+

+ Walk-ins welcome! For parties of 6 or more, please call ahead or book online.

+ + Call to Reserve + +

+
+
+
+ + + + + + + + + diff --git a/apps/project-sites/samples/demo-site/index.html b/apps/project-sites/samples/demo-site/index.html new file mode 100644 index 0000000000..af5420d15c --- /dev/null +++ b/apps/project-sites/samples/demo-site/index.html @@ -0,0 +1,679 @@ + + + + + + Bella Cucina | Authentic Italian Restaurant + + + + + + + + + + +
+
+ Est. 2018 — Downtown Manhattan +

Authentic Italian,
Made with Love

+

Hand-crafted pasta, wood-fired pizza, and timeless Italian recipes passed down through generations. Every dish tells a story.

+ +
+ Scroll to explore +
+ + + + + +
+
+
👨‍🍳
+
+ +

A Family Tradition, A New York Home

+

+ Bella Cucina was born from a simple dream: bring the soul of Italian cooking to the streets of Manhattan. + Founded by Chef Marco Bellini in 2018, every recipe traces back to his grandmother's kitchen in Naples. +

+

+ We make our pasta fresh every morning, source ingredients from local farms and Italian importers, + and fire our pizzas in a custom-built 900°F wood oven. It's not just a meal—it's an experience. +

+
+
+
6
+
Years Open
+
+
+
50K+
+
Guests Served
+
+
+
4.8
+
Google Rating
+
+
+
+
+
+ + +
+
+ +

Loved by Our Guests

+

Don't just take our word for it. Here's what our guests have to say.

+
+ +
+
+
★★★★★
+

"The best Italian food I've had outside of Italy. The cacio e pepe is absolutely perfect—simple, authentic, unforgettable."

+
Sarah M. — Google Reviews
+
+
+
★★★★★
+

"We come here every Friday night. The wood-fired pizza is incredible, and the atmosphere is warm and welcoming. It feels like family."

+
James & Lisa T. — Yelp
+
+
+
★★★★★
+

"Chef Marco's truffle ravioli is a masterpiece. Pair it with a glass of Barolo and you're in heaven. Best date-night spot in the city."

+
David R. — TripAdvisor
+
+
+
+ + +
+
+ +

Come Hungry, Leave Happy

+
+ +
+
+

Location

+

+ 742 Evergreen Terrace
+ Manhattan, NY 10012

+ (212) 555-1234
+ hello@bellacucina.com +

+
+ +
+

Hours

+
+ Monday - Thursday + 11:30 AM - 10:00 PM +
+
+ Friday - Saturday + 11:30 AM - 11:30 PM +
+
+ Sunday + 12:00 PM - 9:00 PM +
+
+ Sunday Brunch + 10:00 AM - 2:00 PM +
+
+ +
+

Reservations

+

+ Walk-ins welcome! For parties of 6 or more, please call ahead or book online.

+ + Call to Reserve + +

+
+
+
+ + + + + + + + + diff --git a/apps/project-sites/scripts/e2e_server.cjs b/apps/project-sites/scripts/e2e_server.cjs new file mode 100644 index 0000000000..e44bb128c1 --- /dev/null +++ b/apps/project-sites/scripts/e2e_server.cjs @@ -0,0 +1,513 @@ +/** + * Lightweight E2E test server for Cypress tests. + * + * Serves public/index.html and provides mock API stubs that replicate + * the Worker's middleware behavior (request ID, security headers, + * payload limits, auth gates) so Cypress can run locally. + */ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const PORT = Number(process.env.E2E_PORT) || 8787; +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); +const MAX_BODY = 256 * 1024; // 256KB + +// MIME types for static serving +const MIME_TYPES = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +function getContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return MIME_TYPES[ext] || 'application/octet-stream'; +} + +/** Security headers matching securityHeadersMiddleware */ +function setSecurityHeaders(res) { + res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://unpkg.com https://releases.transloadit.com https://js.stripe.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://releases.transloadit.com", + "img-src 'self' data: https:", + "font-src 'self' https://fonts.gstatic.com", + "connect-src 'self' https://api.stripe.com https://lottie.host", + 'frame-src https://js.stripe.com', + "object-src 'none'", + "base-uri 'self'", + ].join('; '), + ); +} + +/** Set request ID header (propagate or generate) */ +function setRequestId(req, res) { + const requestId = req.headers['x-request-id'] || crypto.randomUUID(); + res.setHeader('x-request-id', requestId); + return requestId; +} + +/** Send JSON response */ +function sendJson(res, status, body) { + const json = JSON.stringify(body); + res.setHeader('Content-Type', 'application/json'); + res.writeHead(status); + res.end(json); +} + +/** Read request body as string */ +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BODY + 1024) { + // Slightly over to allow detection + reject(new Error('TOO_LARGE')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const pathname = url.pathname; + const method = req.method || 'GET'; + + // ─── Middleware ────────────────────────────────────── + setSecurityHeaders(res); + const requestId = setRequestId(req, res); + + // ─── Subdomain routing ───────────────────────────── + // Detect non-localhost, non-base-domain hosts as subdomain sites + const host = (req.headers.host || '').split(':')[0]; + const isBaseDomain = + host === 'localhost' || + host === '127.0.0.1' || + host === 'sites.megabyte.space' || + host === 'sites-staging.megabyte.space'; + + // Check for customer site subdomains: {slug}-sites.megabyte.space + if (host.endsWith('-sites.megabyte.space') || host.endsWith('-sites-staging.megabyte.space')) { + return sendJson(res, 404, { + error: { + code: 'NOT_FOUND', + message: 'Site not found', + }, + }); + } + + if (!isBaseDomain && host.includes('.')) { + // Unknown subdomain → 404 + return sendJson(res, 404, { + error: { + code: 'NOT_FOUND', + message: 'Site not found', + request_id: requestId, + }, + }); + } + + // Payload limit check via content-length + const contentLength = req.headers['content-length']; + if (contentLength) { + const size = Number(contentLength); + if (!Number.isNaN(size) && size > MAX_BODY) { + return sendJson(res, 413, { + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Request body exceeds maximum size of ${MAX_BODY} bytes`, + request_id: requestId, + }, + }); + } + } + + // ─── Health ───────────────────────────────────────── + if (pathname === '/health' && method === 'GET') { + return sendJson(res, 200, { + status: 'ok', + version: '0.1.0', + environment: 'e2e-test', + timestamp: new Date().toISOString(), + latency_ms: 1, + checks: { + kv: { status: 'ok', latency_ms: 0 }, + r2: { status: 'ok', latency_ms: 0 }, + }, + }); + } + + // ─── Search API ───────────────────────────────────── + if (pathname === '/api/search/businesses' && method === 'GET') { + const q = url.searchParams.get('q'); + if (!q || q.trim().length === 0) { + return sendJson(res, 400, { + error: { + code: 'BAD_REQUEST', + message: 'Missing required query parameter: q', + request_id: requestId, + }, + }); + } + // Return mock search results + return sendJson(res, 200, { + data: [ + { + place_id: 'ChIJ_mock_1', + name: `${q} Pizza`, + address: '123 Main St, New York, NY', + types: ['restaurant'], + }, + { + place_id: 'ChIJ_mock_2', + name: `${q} Plumbing`, + address: '456 Oak Ave, Brooklyn, NY', + types: ['plumber'], + }, + ], + }); + } + + // ─── Site Lookup ──────────────────────────────────── + if (pathname === '/api/sites/lookup' && method === 'GET') { + const placeId = url.searchParams.get('place_id'); + const slug = url.searchParams.get('slug'); + if (!placeId && !slug) { + return sendJson(res, 400, { + error: { + code: 'BAD_REQUEST', + message: 'Missing required query parameter: place_id or slug', + request_id: requestId, + }, + }); + } + // Default: site not found + return sendJson(res, 200, { data: { exists: false } }); + } + + // ─── Create from Search (requires auth) ───────────── + if (pathname === '/api/sites/create-from-search' && method === 'POST') { + // Check for auth header (mock: accept any Bearer token) + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return sendJson(res, 401, { + error: { + code: 'UNAUTHORIZED', + message: 'Must be authenticated', + request_id: requestId, + }, + }); + } + let body; + try { + const raw = await readBody(req); + body = raw ? JSON.parse(raw) : {}; + } catch { + body = {}; + } + const businessName = (body.business && body.business.name) || body.business_name || 'custom-website'; + if (!businessName) { + return sendJson(res, 400, { + error: { + code: 'BAD_REQUEST', + message: 'Missing required field: business_name (or business.name)', + request_id: requestId, + }, + }); + } + const slug = businessName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 63); + const siteId = `site-e2e-${crypto.randomUUID()}`; + const workflowInstanceId = `wf-${siteId}`; + + // Store in-memory for status polling + if (!global.__e2eWorkflows) global.__e2eWorkflows = {}; + global.__e2eWorkflows[siteId] = { + instanceId: workflowInstanceId, + status: 'running', + steps: ['research-profile'], + createdAt: Date.now(), + }; + + // Simulate workflow progression over time + setTimeout(() => { + if (global.__e2eWorkflows[siteId]) { + global.__e2eWorkflows[siteId].steps.push('research-social', 'research-brand', 'research-selling-points', 'research-images'); + } + }, 2000); + setTimeout(() => { + if (global.__e2eWorkflows[siteId]) { + global.__e2eWorkflows[siteId].steps.push('generate-website'); + } + }, 4000); + setTimeout(() => { + if (global.__e2eWorkflows[siteId]) { + global.__e2eWorkflows[siteId].steps.push('generate-privacy-page', 'generate-terms-page', 'score-website'); + } + }, 6000); + setTimeout(() => { + if (global.__e2eWorkflows[siteId]) { + global.__e2eWorkflows[siteId].steps.push('upload-to-r2', 'update-site-status'); + global.__e2eWorkflows[siteId].status = 'complete'; + } + }, 8000); + + return sendJson(res, 201, { + data: { + site_id: siteId, + slug, + status: 'building', + workflow_instance_id: workflowInstanceId, + }, + }); + } + + // ─── Workflow Status (auth-gated) ───────────────────── + const workflowMatch = pathname.match(/^\/api\/sites\/([^/]+)\/workflow$/); + if (workflowMatch && method === 'GET') { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return sendJson(res, 401, { + error: { + code: 'UNAUTHORIZED', + message: 'Must be authenticated', + request_id: requestId, + }, + }); + } + const siteId = workflowMatch[1]; + const wf = (global.__e2eWorkflows || {})[siteId]; + if (!wf) { + return sendJson(res, 200, { + data: { + site_id: siteId, + workflow_available: true, + instance_id: null, + workflow_status: null, + site_status: 'building', + }, + }); + } + return sendJson(res, 200, { + data: { + site_id: siteId, + workflow_available: true, + instance_id: wf.instanceId, + workflow_status: wf.status, + workflow_steps_completed: wf.steps, + workflow_error: null, + workflow_output: wf.status === 'complete' ? { + siteId, + slug: 'test-site', + version: new Date().toISOString(), + quality: 0.85, + pages: ['index.html', 'privacy.html', 'terms.html', 'research.json'], + } : null, + site_status: wf.status === 'complete' ? 'published' : 'building', + }, + }); + } + + // ─── Site Status Poll (for waiting screen) ────────────── + const siteStatusMatch = pathname.match(/^\/api\/sites\/([^/]+)$/); + if (siteStatusMatch && method === 'GET') { + const siteId = siteStatusMatch[1]; + const wf = (global.__e2eWorkflows || {})[siteId]; + if (wf) { + return sendJson(res, 200, { + id: siteId, + slug: siteId.replace(/^site-e2e-/, '').substring(0, 20), + status: wf.status === 'complete' ? 'published' : 'building', + }); + } + return sendJson(res, 200, { + id: siteId, + slug: 'unknown-site', + status: 'building', + }); + } + + // ─── Pre-built Sites Search ──────────────────────────── + if (pathname === '/api/sites/search' && method === 'GET') { + return sendJson(res, 200, { data: [] }); + } + + // ─── Auth endpoints ───────────────────────────────── + if (pathname === '/api/auth/magic-link' && method === 'POST') { + let body; + try { + const raw = await readBody(req); + body = raw ? JSON.parse(raw) : {}; + } catch { + body = {}; + } + if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { + return sendJson(res, 400, { + error: { + code: 'BAD_REQUEST', + message: 'Invalid email address', + request_id: requestId, + }, + }); + } + return sendJson(res, 200, { data: { expires_at: new Date(Date.now() + 600000).toISOString() } }); + } + + if (pathname === '/api/auth/google' && method === 'GET') { + return sendJson(res, 400, { + error: { + code: 'BAD_REQUEST', + message: 'Missing OAuth configuration', + request_id: requestId, + }, + }); + } + + // ─── Webhook endpoints ────────────────────────────── + if (pathname === '/webhooks/stripe' && method === 'POST') { + const sig = req.headers['stripe-signature']; + if (!sig) { + return sendJson(res, 400, { + error: { + code: 'BAD_REQUEST', + message: 'Missing stripe-signature header', + request_id: requestId, + }, + }); + } + return sendJson(res, 401, { + error: { + code: 'WEBHOOK_SIGNATURE_INVALID', + message: 'Invalid signature', + request_id: requestId, + }, + }); + } + + // ─── Auth-gated API routes (return 401) ───────────── + const authGatedRoutes = [ + '/api/sites', + '/api/billing/subscription', + '/api/billing/entitlements', + '/api/billing/checkout', + '/api/hostnames', + '/api/audit-logs', + ]; + + for (const route of authGatedRoutes) { + if (pathname === route || pathname.startsWith(route + '/')) { + return sendJson(res, 401, { + error: { + code: 'UNAUTHORIZED', + message: 'Must be authenticated', + request_id: requestId, + }, + }); + } + } + + // ─── Unknown API routes ───────────────────────────── + if (pathname.startsWith('/api/')) { + return sendJson(res, 404, { + error: { + code: 'NOT_FOUND', + message: 'Route not found', + request_id: requestId, + }, + }); + } + + // ─── Static file serving ──────────────────────────── + let filePath; + if (pathname === '/' || pathname === '/index.html') { + filePath = path.join(PUBLIC_DIR, 'index.html'); + } else { + filePath = path.join(PUBLIC_DIR, pathname); + } + + // Prevent directory traversal + if (!filePath.startsWith(PUBLIC_DIR)) { + return sendJson(res, 403, { error: { code: 'FORBIDDEN', message: 'Access denied' } }); + } + + try { + const stat = fs.statSync(filePath); + if (stat.isFile()) { + const content = fs.readFileSync(filePath); + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Cache-Control', 'public, max-age=60'); + res.writeHead(200); + res.end(content); + return; + } + } catch { + // File not found - try appending .html for extensionless paths (e.g. /privacy → privacy.html) + if (!path.extname(filePath)) { + try { + const htmlPath = filePath + '.html'; + const stat2 = fs.statSync(htmlPath); + if (stat2.isFile()) { + const content = fs.readFileSync(htmlPath); + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'public, max-age=60'); + res.writeHead(200); + res.end(content); + return; + } + } catch { + // Also not found, fall through + } + } + } + + // Fallback: for SPA paths, serve index.html + const indexPath = path.join(PUBLIC_DIR, 'index.html'); + try { + const content = fs.readFileSync(indexPath); + res.setHeader('Content-Type', 'text/html'); + res.writeHead(200); + res.end(content); + } catch { + sendJson(res, 404, { + error: { + code: 'NOT_FOUND', + message: 'Site not found', + request_id: requestId, + }, + }); + } +}); + +server.listen(PORT, () => { + console.log(`E2E test server running at http://localhost:${PORT}`); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + server.close(); + process.exit(0); +}); +process.on('SIGTERM', () => { + server.close(); + process.exit(0); +}); diff --git a/apps/project-sites/scripts/upload_to_r2.sh b/apps/project-sites/scripts/upload_to_r2.sh new file mode 100755 index 0000000000..62ed156ada --- /dev/null +++ b/apps/project-sites/scripts/upload_to_r2.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Sync the r2-sync/ folder to R2 bucket. +# +# Usage: +# ./scripts/upload_to_r2.sh [staging|production] +# +# The r2-sync/ folder mirrors the R2 bucket structure: +# r2-sync/ +# marketing/index.html → marketing homepage (sites.megabyte.space) +# sites/{slug}/{version}/... → customer sites ({slug}-sites.megabyte.space) +# +# Prerequisites: +# - wrangler authenticated (CLOUDFLARE_API_TOKEN or `wrangler login`) +# - R2 buckets created + +set -euo pipefail + +ENVIRONMENT="${1:-staging}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +SYNC_DIR="$PROJECT_DIR/r2-sync" + +# Map environment to bucket name +case "$ENVIRONMENT" in + production) BUCKET="project-sites-production" ;; + staging) BUCKET="project-sites-staging" ;; + *) BUCKET="project-sites" ;; +esac + +echo "=== Project Sites R2 Sync ===" +echo "Environment: $ENVIRONMENT" +echo "Bucket: $BUCKET" +echo "Source: $SYNC_DIR" +echo "" + +if [ ! -d "$SYNC_DIR" ]; then + echo "ERROR: r2-sync/ directory not found at $SYNC_DIR" + exit 1 +fi + +# Walk the sync directory and upload each file +UPLOADED=0 +while IFS= read -r -d '' file; do + # Get the relative path from the sync dir + rel_path="${file#$SYNC_DIR/}" + + # Determine content type from extension + ext="${file##*.}" + case "$ext" in + html) content_type="text/html" ;; + css) content_type="text/css" ;; + js) content_type="application/javascript" ;; + json) content_type="application/json" ;; + png) content_type="image/png" ;; + jpg|jpeg) content_type="image/jpeg" ;; + gif) content_type="image/gif" ;; + svg) content_type="image/svg+xml" ;; + ico) content_type="image/x-icon" ;; + webp) content_type="image/webp" ;; + woff) content_type="font/woff" ;; + woff2) content_type="font/woff2" ;; + ttf) content_type="font/ttf" ;; + xml) content_type="application/xml" ;; + txt) content_type="text/plain" ;; + webmanifest) content_type="application/manifest+json" ;; + *) content_type="application/octet-stream" ;; + esac + + echo " ▸ $rel_path ($content_type)" + npx wrangler r2 object put "$BUCKET/$rel_path" \ + --file "$file" \ + --content-type "$content_type" \ + --remote + + UPLOADED=$((UPLOADED + 1)) +done < <(find "$SYNC_DIR" -type f -print0) + +echo "" +echo "=== Sync Complete ===" +echo "Uploaded $UPLOADED files to $BUCKET" +echo "" +echo "Marketing homepage: https://sites.megabyte.space/" +echo "Demo site: https://bella-cucina-sites.megabyte.space/" diff --git a/apps/project-sites/src/__tests__/ai_workflows.test.ts b/apps/project-sites/src/__tests__/ai_workflows.test.ts new file mode 100644 index 0000000000..42ec2fd36b --- /dev/null +++ b/apps/project-sites/src/__tests__/ai_workflows.test.ts @@ -0,0 +1,373 @@ +/** + * Tests for the ai_workflows module. + * + * Validates that runPrompt, researchBusiness, generateSiteHtml, + * scoreQuality, generateSiteCopy, and runSiteGenerationWorkflow + * correctly orchestrate prompt resolution, rendering, AI calls, + * and output parsing. + * + * The Workers AI binding (env.AI.run) is mocked to return appropriate + * fixture responses for each prompt type. + */ + +import { webcrypto } from 'node:crypto'; + +if (!globalThis.crypto?.subtle) { + (globalThis as any).crypto = webcrypto; +} + +import type { Env } from '../types/env.js'; +import { + runPrompt, + researchBusiness, + generateSiteHtml, + scoreQuality, + generateSiteCopy, + runSiteGenerationWorkflow, + registerAllPrompts, +} from '../services/ai_workflows.js'; +import { clearRegistry, getStats } from '../prompts/registry.js'; + +// ─── Mock AI Responses ─────────────────────────────────────────── + +const MOCK_RESEARCH_RESPONSE = JSON.stringify({ + business_name: "Mario's Ristorante", + tagline: 'Authentic Italian since 1985', + description: + 'A family-owned Italian restaurant serving traditional recipes passed down through generations.', + services: ['Dine-in', 'Takeout', 'Catering', 'Private Events'], + hours: [ + { day: 'Monday-Thursday', hours: '11am-9pm' }, + { day: 'Friday-Saturday', hours: '11am-10pm' }, + { day: 'Sunday', hours: '12pm-8pm' }, + ], + faq: [ + { question: 'Do you accept reservations?', answer: 'Yes, call us or book online.' }, + { question: 'Is parking available?', answer: 'Free parking behind the building.' }, + { question: 'Gluten-free options?', answer: 'Yes, ask for our GF menu.' }, + ], + seo_title: "Mario's Ristorante - Italian Dining", + seo_description: + 'Family-owned Italian restaurant. Dine-in, takeout, catering. Traditional recipes since 1985.', +}); + +const MOCK_HTML_RESPONSE = + 'Mario\'s Ristorante' + + '

Mario\'s Ristorante

Authentic Italian since 1985

' + + '

Our Services

'; + +const MOCK_SCORE_RESPONSE = JSON.stringify({ + scores: { + accuracy: 0.85, + completeness: 0.9, + professionalism: 0.88, + seo: 0.75, + accessibility: 0.7, + }, + overall: 0.82, + issues: ['Missing alt attributes on images'], + suggestions: ['Add structured data markup'], +}); + +const MOCK_COPY_RESPONSE = + "# Welcome to Mario's Ristorante\n\n" + + '## Your Neighborhood Italian Kitchen in Boston\n\n' + + '**Call Now** | **View Menu**\n\n' + + '- Fresh ingredients daily\n' + + '- Family recipes since 1985\n' + + '- Private event hosting\n\n' + + '### About Us\n\nWe are a family-owned restaurant...'; + +// ─── Mock Env ──────────────────────────────────────────────────── + +function createMockEnv(aiRunImpl?: jest.Mock): Env { + const aiRun = + aiRunImpl ?? + jest + .fn() + .mockImplementation( + (_model: string, params: { messages: Array<{ role: string; content: string }> }) => { + const userContent = params.messages.find((m) => m.role === 'user')?.content ?? ''; + + // Route the mock response based on what appears in the user prompt + if (userContent.includes('Research this business')) { + return Promise.resolve({ response: MOCK_RESEARCH_RESPONSE }); + } + if (userContent.includes('Generate the complete HTML website')) { + return Promise.resolve({ response: MOCK_HTML_RESPONSE }); + } + if (userContent.includes('Score the following website HTML')) { + return Promise.resolve({ response: MOCK_SCORE_RESPONSE }); + } + if (userContent.includes('Hero headline') || userContent.includes('benefit-led')) { + return Promise.resolve({ response: MOCK_COPY_RESPONSE }); + } + + return Promise.resolve({ response: '{}' }); + }, + ); + + return { + AI: { run: aiRun }, + ENVIRONMENT: 'test', + CACHE_KV: {} as any, + PROMPT_STORE: {} as any, + DB: {} as any, + SITES_BUCKET: {} as any, + QUEUE: {} as any, + STRIPE_SECRET_KEY: 'sk_test_xxx', + STRIPE_PUBLISHABLE_KEY: 'pk_test_xxx', + STRIPE_WEBHOOK_SECRET: 'whsec_xxx', + CF_API_TOKEN: 'test-cf-token', + CF_ZONE_ID: 'test-zone-id', + SENDGRID_API_KEY: 'SG.test', + GOOGLE_CLIENT_ID: 'test-client-id', + GOOGLE_CLIENT_SECRET: 'test-client-secret', + GOOGLE_PLACES_API_KEY: 'test-places-key', + SENTRY_DSN: 'https://test@sentry.io/123', + POSTHOG_API_KEY: 'phc_test', + } as unknown as Env; +} + +// ─── Test Suite ────────────────────────────────────────────────── + +beforeEach(() => { + clearRegistry(); + registerAllPrompts(); +}); + +describe('registerAllPrompts', () => { + it('populates the registry with all prompts', () => { + // clearRegistry + registerAllPrompts already called in beforeEach + const stats = getStats(); + + // 5 legacy + 8 v2 = 13 prompts, legacy has 4 unique IDs + 8 v2 = 12 unique + expect(stats.totalPrompts).toBe(13); + expect(stats.uniqueIds).toBe(12); + }); + + it('configures variant weights for site_copy', () => { + const stats = getStats(); + + expect(stats.variantConfigs).toBe(1); + }); + + it('is idempotent when called multiple times', () => { + registerAllPrompts(); // call a second time + const stats = getStats(); + + // registerAll overwrites existing keys, so counts stay the same + expect(stats.totalPrompts).toBe(13); + expect(stats.uniqueIds).toBe(12); + }); +}); + +describe('runPrompt', () => { + it('calls AI.run and returns an LlmCallResult for research_business', async () => { + const env = createMockEnv(); + const result = await runPrompt(env, 'research_business', 2, { + business_name: 'Test Biz', + }); + + expect(result.success).toBe(true); + expect(result.output).toBe(MOCK_RESEARCH_RESPONSE); + expect(result.promptId).toBe('research_business'); + expect(result.promptVersion).toBe(2); + expect(result.model).toBe('@cf/meta/llama-3.1-70b-instruct'); + expect(typeof result.latencyMs).toBe('number'); + expect(env.AI.run).toHaveBeenCalledTimes(1); + }); + + it('throws for an unknown prompt ID', async () => { + const env = createMockEnv(); + + await expect(runPrompt(env, 'nonexistent_prompt', 1, { foo: 'bar' })).rejects.toThrow( + 'Prompt not found: nonexistent_prompt@1', + ); + }); + + it('throws for a valid prompt ID but wrong version', async () => { + const env = createMockEnv(); + + await expect( + runPrompt(env, 'research_business', 99, { business_name: 'Test' }), + ).rejects.toThrow('Prompt not found: research_business@99'); + }); + + it('passes rendered messages to AI.run with correct structure', async () => { + const env = createMockEnv(); + await runPrompt(env, 'research_business', 2, { + business_name: 'Acme Corp', + }); + + const callArgs = (env.AI.run as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe('@cf/meta/llama-3.1-70b-instruct'); + + const payload = callArgs[1]; + expect(payload.messages).toHaveLength(2); + expect(payload.messages[0].role).toBe('system'); + expect(payload.messages[1].role).toBe('user'); + expect(payload.messages[1].content).toContain('Acme Corp'); + expect(typeof payload.temperature).toBe('number'); + expect(typeof payload.max_tokens).toBe('number'); + }); +}); + +describe('researchBusiness', () => { + it('calls AI and returns a parsed ResearchResult', async () => { + const env = createMockEnv(); + const result = await researchBusiness(env, { + businessName: "Mario's Ristorante", + }); + + expect(result.businessName).toBe("Mario's Ristorante"); + expect(result.tagline).toBe('Authentic Italian since 1985'); + expect(result.services).toEqual(['Dine-in', 'Takeout', 'Catering', 'Private Events']); + expect(result.hours).toHaveLength(3); + expect(result.faq).toHaveLength(3); + expect(result.seoTitle).toBeTruthy(); + expect(result.seoDescription).toBeTruthy(); + }); + + it('passes optional fields to the prompt', async () => { + const env = createMockEnv(); + await researchBusiness(env, { + businessName: 'Test Biz', + businessPhone: '555-0000', + businessAddress: '123 Main St', + googlePlaceId: 'ChIJabc123', + additionalContext: 'Open late on weekends', + }); + + const callArgs = (env.AI.run as jest.Mock).mock.calls[0]; + const userContent = callArgs[1].messages[1].content; + expect(userContent).toContain('Test Biz'); + expect(userContent).toContain('555-0000'); + expect(userContent).toContain('123 Main St'); + expect(userContent).toContain('ChIJabc123'); + expect(userContent).toContain('Open late on weekends'); + }); +}); + +describe('generateSiteHtml', () => { + it('calls AI and returns HTML string', async () => { + const env = createMockEnv(); + const researchData = { + businessName: "Mario's Ristorante", + tagline: 'Authentic Italian since 1985', + description: 'Family-owned Italian restaurant.', + services: ['Dine-in', 'Takeout', 'Catering'], + hours: [{ day: 'Mon-Fri', hours: '11am-9pm' }], + faq: [ + { question: 'Reservations?', answer: 'Yes.' }, + { question: 'Parking?', answer: 'Yes.' }, + { question: 'GF options?', answer: 'Yes.' }, + ], + seoTitle: "Mario's Ristorante", + seoDescription: 'Italian dining in Boston.', + }; + + const html = await generateSiteHtml(env, researchData); + + expect(html).toContain(''); + expect(html).toContain('Mario'); + expect(env.AI.run).toHaveBeenCalledTimes(1); + }); +}); + +describe('scoreQuality', () => { + it('calls AI and returns parsed QualityScore', async () => { + const env = createMockEnv(); + const score = await scoreQuality(env, MOCK_HTML_RESPONSE); + + expect(score.scores.accuracy).toBe(0.85); + expect(score.scores.completeness).toBe(0.9); + expect(score.scores.professionalism).toBe(0.88); + expect(score.scores.seo).toBe(0.75); + expect(score.scores.accessibility).toBe(0.7); + expect(score.overall).toBe(0.82); + expect(score.issues).toContain('Missing alt attributes on images'); + expect(score.suggestions).toContain('Add structured data markup'); + }); + + it('truncates HTML content to 4000 characters', async () => { + const env = createMockEnv(); + const longHtml = '' + 'x'.repeat(5000); + + await scoreQuality(env, longHtml); + + const callArgs = (env.AI.run as jest.Mock).mock.calls[0]; + const userContent = callArgs[1].messages[1].content; + // The html_content should be truncated — the rendered user prompt + // should not contain the full 5000+ char string + expect(userContent.length).toBeLessThan(longHtml.length + 500); + }); +}); + +describe('generateSiteCopy', () => { + it('calls AI with A/B variant selection using orgId seed', async () => { + const env = createMockEnv(); + const result = await generateSiteCopy( + env, + { + businessName: "Mario's Ristorante", + city: 'Boston', + services: ['Dine-in', 'Catering'], + tone: 'friendly', + }, + 'org_12345', + ); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + expect(env.AI.run).toHaveBeenCalledTimes(1); + }); + + it('works without an orgId (no variant selection)', async () => { + const env = createMockEnv(); + const result = await generateSiteCopy(env, { + businessName: 'Quick Fix Plumbing', + city: 'Denver', + services: ['Repairs'], + tone: 'no-nonsense', + }); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); + +describe('runSiteGenerationWorkflow', () => { + it('runs research, generate, and score steps in sequence', async () => { + const env = createMockEnv(); + const result = await runSiteGenerationWorkflow(env, { + businessName: "Mario's Ristorante", + }); + + // Verify all three steps produced results + expect(result.research.businessName).toBe("Mario's Ristorante"); + expect(result.html).toContain(''); + expect(result.quality.overall).toBe(0.82); + + // AI.run should have been called 3 times (research, generate, score) + expect(env.AI.run).toHaveBeenCalledTimes(3); + }); + + it('passes optional fields through to researchBusiness', async () => { + const env = createMockEnv(); + await runSiteGenerationWorkflow(env, { + businessName: 'Test Biz', + businessPhone: '555-1111', + businessAddress: '1 Test St', + googlePlaceId: 'ChIJtest', + }); + + // First AI.run call should be research_business + const firstCallArgs = (env.AI.run as jest.Mock).mock.calls[0]; + const userContent = firstCallArgs[1].messages[1].content; + expect(userContent).toContain('Test Biz'); + expect(userContent).toContain('555-1111'); + expect(userContent).toContain('1 Test St'); + expect(userContent).toContain('ChIJtest'); + }); +}); diff --git a/apps/project-sites/src/__tests__/analytics.test.ts b/apps/project-sites/src/__tests__/analytics.test.ts new file mode 100644 index 0000000000..12d4e7bbfc --- /dev/null +++ b/apps/project-sites/src/__tests__/analytics.test.ts @@ -0,0 +1,212 @@ +import type { Env } from '../types/env.js'; +import { + captureEvent, + capturePageView, + identifyUser, + captureFunnelEvent, +} from '../services/analytics.js'; + +const mockFetch = jest.fn().mockResolvedValue({ ok: true }); +(global as any).fetch = mockFetch; + +function makeEnv(overrides?: Partial): Env { + return { + POSTHOG_API_KEY: 'phk_test123', + POSTHOG_HOST: 'https://us.i.posthog.com', + ...overrides, + } as unknown as Env; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── captureEvent ───────────────────────────────────────────── + +describe('captureEvent', () => { + it('sends POST to PostHog /capture/ endpoint', async () => { + const env = makeEnv(); + + await captureEvent(env, 'test_event', 'user-1'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://us.i.posthog.com/capture/'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('application/json'); + }); + + it('includes api_key, event, distinct_id, and timestamp in request body', async () => { + const env = makeEnv(); + + await captureEvent(env, 'signup', 'user-42'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.api_key).toBe('phk_test123'); + expect(body.event).toBe('signup'); + expect(body.distinct_id).toBe('user-42'); + expect(body.timestamp).toBeDefined(); + // Timestamp should be a valid ISO string + expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); + }); + + it('includes custom properties in request body', async () => { + const env = makeEnv(); + + await captureEvent(env, 'click', 'user-1', { button: 'submit', page: '/home' }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.properties.button).toBe('submit'); + expect(body.properties.page).toBe('/home'); + }); + + it('uses default host https://us.i.posthog.com when POSTHOG_HOST not set', async () => { + const env = makeEnv({ POSTHOG_HOST: undefined }); + + await captureEvent(env, 'test', 'user-1'); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('https://us.i.posthog.com/capture/'); + }); + + it('uses custom POSTHOG_HOST when set', async () => { + const env = makeEnv({ POSTHOG_HOST: 'https://eu.posthog.com' }); + + await captureEvent(env, 'test', 'user-1'); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('https://eu.posthog.com/capture/'); + }); + + it('skips if POSTHOG_API_KEY is empty string', async () => { + const env = makeEnv({ POSTHOG_API_KEY: '' }); + + await captureEvent(env, 'test', 'user-1'); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips if POSTHOG_API_KEY is missing (undefined)', async () => { + const env = makeEnv({ POSTHOG_API_KEY: undefined as unknown as string }); + + await captureEvent(env, 'test', 'user-1'); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not throw on fetch error (logs instead)', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const env = makeEnv(); + + await expect(captureEvent(env, 'test', 'user-1')).resolves.not.toThrow(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('includes $lib and $lib_version in properties', async () => { + const env = makeEnv(); + + await captureEvent(env, 'test', 'user-1'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.properties.$lib).toBe('project-sites-worker'); + expect(body.properties.$lib_version).toBe('0.1.0'); + }); +}); + +// ─── capturePageView ────────────────────────────────────────── + +describe('capturePageView', () => { + it('sends $pageview event with $current_url property', async () => { + const env = makeEnv(); + + await capturePageView(env, 'visitor-1', 'https://example.com/about'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.event).toBe('$pageview'); + expect(body.properties.$current_url).toBe('https://example.com/about'); + }); + + it('includes additional properties alongside $current_url', async () => { + const env = makeEnv(); + + await capturePageView(env, 'visitor-1', 'https://example.com/', { referrer: 'google.com' }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.properties.$current_url).toBe('https://example.com/'); + expect(body.properties.referrer).toBe('google.com'); + }); +}); + +// ─── identifyUser ───────────────────────────────────────────── + +describe('identifyUser', () => { + it('sends $identify event with $set properties', async () => { + const env = makeEnv(); + + await identifyUser(env, 'user-99', { email: 'a@b.com', plan: 'pro' }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.event).toBe('$identify'); + expect(body.distinct_id).toBe('user-99'); + expect(body.properties.$set).toEqual({ email: 'a@b.com', plan: 'pro' }); + }); + + it('skips if no API key', async () => { + const env = makeEnv({ POSTHOG_API_KEY: '' }); + + await identifyUser(env, 'user-99', { email: 'a@b.com' }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not throw on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Connection refused')); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const env = makeEnv(); + + await expect(identifyUser(env, 'user-99', { email: 'a@b.com' })).resolves.not.toThrow(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('includes timestamp in request body', async () => { + const env = makeEnv(); + + await identifyUser(env, 'user-99'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.timestamp).toBeDefined(); + expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); + }); +}); + +// ─── captureFunnelEvent ─────────────────────────────────────── + +describe('captureFunnelEvent', () => { + it('sends funnel_{step} event with org_id and site_id', async () => { + const env = makeEnv(); + + await captureFunnelEvent(env, 'user-1', 'signup', 'org-abc', 'site-xyz'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.event).toBe('funnel_signup'); + expect(body.properties.org_id).toBe('org-abc'); + expect(body.properties.site_id).toBe('site-xyz'); + expect(body.properties.funnel_step).toBe('signup'); + }); + + it('handles missing orgId and siteId by sending null', async () => { + const env = makeEnv(); + + await captureFunnelEvent(env, 'user-1', 'landing'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.event).toBe('funnel_landing'); + expect(body.properties.org_id).toBeNull(); + expect(body.properties.site_id).toBeNull(); + }); +}); diff --git a/apps/project-sites/src/__tests__/api_routes.test.ts b/apps/project-sites/src/__tests__/api_routes.test.ts new file mode 100644 index 0000000000..47ac1a4178 --- /dev/null +++ b/apps/project-sites/src/__tests__/api_routes.test.ts @@ -0,0 +1,318 @@ +/** + * Functional / integration tests for API routes. + * Mounts the full route tree and tests multi-step flows. + */ + +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), + dbExecute: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +jest.mock('../services/audit.js', () => ({ + writeAuditLog: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../lib/sentry.js', () => ({ + captureError: jest.fn(), + captureMessage: jest.fn(), + createSentry: jest.fn(), +})); + +jest.mock('../lib/posthog.js', () => ({ + capture: jest.fn(), + trackAuth: jest.fn(), + trackSite: jest.fn(), + trackError: jest.fn(), +})); + +import { Hono } from 'hono'; +import type { Env, Variables } from '../types/env.js'; +import { errorHandler } from '../middleware/error_handler.js'; +import { api } from '../routes/api.js'; +import { dbQueryOne } from '../services/db.js'; + +const mockDbQueryOne = dbQueryOne as jest.Mock; + +const originalFetch = global.fetch; +let mockFetch: jest.Mock; + +const createMockEnv = (overrides: Partial = {}): Env => + ({ + ENVIRONMENT: 'test', + DB: {} as D1Database, + RESEND_API_KEY: 'test-resend-key', + SENDGRID_API_KEY: 'test-sendgrid-key', + GOOGLE_CLIENT_ID: 'test-google-id', + GOOGLE_CLIENT_SECRET: 'test-google-secret', + STRIPE_SECRET_KEY: 'test-stripe-key', + STRIPE_WEBHOOK_SECRET: 'test-stripe-webhook', + ...overrides, + }) as unknown as Env; + +function createApp(envOverrides: Partial = {}) { + const app = new Hono<{ Bindings: Env; Variables: Variables }>(); + app.onError(errorHandler); + app.route('/', api); + const env = createMockEnv(envOverrides); + return { app, env }; +} + +function makeRequest( + app: Hono<{ Bindings: Env; Variables: Variables }>, + env: Env, + path: string, + options?: RequestInit, +) { + return app.request(path, options, env); +} + +function createAuthenticatedApp( + vars: Partial = {}, + envOverrides: Partial = {}, +) { + const authedApp = new Hono<{ Bindings: Env; Variables: Variables }>(); + authedApp.onError(errorHandler); + authedApp.use('*', async (c, next) => { + if (vars.userId) c.set('userId', vars.userId); + if (vars.orgId) c.set('orgId', vars.orgId); + if (vars.requestId) c.set('requestId', vars.requestId); + await next(); + }); + authedApp.route('/', api); + const env = createMockEnv(envOverrides); + return { app: authedApp, env }; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockFetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'mock-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + global.fetch = mockFetch; +}); + +afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; +}); + +// ─── Contact Form Routes ──────────────────────────────────── + +describe('POST /api/contact', () => { + it('returns 200 with success for valid contact form', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Jane Doe', + email: 'jane@example.com', + message: 'Hello, I have a question about your platform.', + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.success).toBe(true); + // Should have made 2 fetch calls (notification + confirmation emails) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('returns 400 for missing email', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Bob', + message: 'This is a test message.', + }), + }); + + expect(res.status).toBe(400); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns 400 for XSS in message', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Bob', + email: 'bob@test.com', + message: 'Hello world', + }), + }); + + expect(res.status).toBe(400); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns 400 for message too short', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Bob', + email: 'bob@test.com', + message: 'Short', + }), + }); + + expect(res.status).toBe(400); + }); + + it('handles email provider failure gracefully', async () => { + mockFetch.mockResolvedValue(new Response('error', { status: 500 })); + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Bob', + email: 'bob@test.com', + message: 'Testing email provider failure scenario.', + }), + }); + + // Both providers fail → error propagated + expect(res.status).toBe(400); + }); + + it('falls back to SendGrid when Resend fails', async () => { + mockFetch + .mockResolvedValueOnce(new Response('error', { status: 500 })) // Resend fails (notification) + .mockResolvedValueOnce(new Response('', { status: 202 })) // SendGrid succeeds + .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'x' }), { status: 200 })); // Resend succeeds (confirmation) + + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Jane', + email: 'jane@test.com', + message: 'Testing Resend to SendGrid fallback.', + }), + }); + + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('returns error when no email provider configured', async () => { + const { app, env } = createApp({ + RESEND_API_KEY: undefined, + SENDGRID_API_KEY: undefined, + } as any); + + const res = await makeRequest(app, env, '/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Bob', + email: 'bob@test.com', + message: 'Testing no email providers.', + }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.message).toContain('Email delivery is not configured'); + }); +}); + +// ─── Auth Routes (unauthenticated) ────────────────────────── + +describe('POST /api/auth/magic-link', () => { + it('returns 400 for missing email', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/auth/magic-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid email format', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/auth/magic-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'not-an-email' }), + }); + + expect(res.status).toBe(400); + }); +}); + +// ─── GET /api/auth/me ─────────────────────────────────────── + +describe('GET /api/auth/me', () => { + it('returns 401 when not authenticated', async () => { + const { app, env } = createApp(); + + const res = await makeRequest(app, env, '/api/auth/me'); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns user info when authenticated', async () => { + mockDbQueryOne.mockResolvedValueOnce({ + email: 'alice@example.com', + display_name: 'Alice', + }); + + const { app, env } = createAuthenticatedApp({ + userId: 'user-123', + orgId: 'org-456', + }); + + const res = await makeRequest(app, env, '/api/auth/me'); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + user_id: 'user-123', + org_id: 'org-456', + email: 'alice@example.com', + display_name: 'Alice', + }); + }); + + it('returns 401 when user not found in DB', async () => { + mockDbQueryOne.mockResolvedValueOnce(null); + + const { app, env } = createAuthenticatedApp({ + userId: 'deleted-user', + orgId: 'org-456', + }); + + const res = await makeRequest(app, env, '/api/auth/me'); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); +}); diff --git a/apps/project-sites/src/__tests__/audit.test.ts b/apps/project-sites/src/__tests__/audit.test.ts new file mode 100644 index 0000000000..4f25ac27b6 --- /dev/null +++ b/apps/project-sites/src/__tests__/audit.test.ts @@ -0,0 +1,170 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbInsert: jest.fn().mockResolvedValue({ error: null }), +})); + +import { dbQuery, dbInsert } from '../services/db.js'; +import { writeAuditLog, getAuditLogs } from '../services/audit.js'; +import { createAuditLogSchema } from '@project-sites/shared'; + +const mockInsert = dbInsert as jest.MockedFunction; +const mockQuery = dbQuery as jest.MockedFunction; + +const mockDb = {} as D1Database; + +const validEntry = { + org_id: crypto.randomUUID(), + actor_id: crypto.randomUUID(), + action: 'auth.login', + target_type: 'session', + target_id: crypto.randomUUID(), + request_id: crypto.randomUUID(), +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── writeAuditLog ─────────────────────────────────────────── + +describe('writeAuditLog', () => { + it('writes valid audit entry to DB', async () => { + mockInsert.mockResolvedValue({ error: null }); + + await writeAuditLog(mockDb, validEntry); + + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'audit_logs', + expect.objectContaining({ + org_id: validEntry.org_id, + action: validEntry.action, + actor_id: validEntry.actor_id, + target_type: validEntry.target_type, + target_id: validEntry.target_id, + request_id: validEntry.request_id, + }), + ); + }); + + it('does not throw on DB failure (logs error instead)', async () => { + mockInsert.mockResolvedValue({ error: 'DB write failed' }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(writeAuditLog(mockDb, validEntry)).resolves.not.toThrow(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('validates entry with createAuditLogSchema', async () => { + mockInsert.mockResolvedValue({ error: null }); + + await writeAuditLog(mockDb, validEntry); + + // Verify the row passed to dbInsert matches the schema-parsed output + const call = mockInsert.mock.calls[0]; + const row = call[2] as Record; + const parsed = createAuditLogSchema.parse(validEntry); + expect(row).toEqual( + expect.objectContaining({ + org_id: parsed.org_id, + actor_id: parsed.actor_id, + action: parsed.action, + target_type: parsed.target_type, + target_id: parsed.target_id, + request_id: parsed.request_id, + }), + ); + }); + + it('throws on invalid entry (schema validation failure)', async () => { + const invalidEntry = { + // Missing required org_id + action: 'auth.login', + actor_id: null, + } as any; + + await expect(writeAuditLog(mockDb, invalidEntry)).rejects.toThrow(); + }); + + it('adds created_at timestamp', async () => { + mockInsert.mockResolvedValue({ error: null }); + + await writeAuditLog(mockDb, validEntry); + + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'audit_logs', + expect.objectContaining({ + created_at: expect.any(String), + }), + ); + + const call = mockInsert.mock.calls[0]; + const row = call[2] as Record; + // Verify created_at is a valid ISO date + expect(new Date(row.created_at as string).toISOString()).toBe(row.created_at); + }); +}); + +// ─── getAuditLogs ──────────────────────────────────────────── + +describe('getAuditLogs', () => { + const orgId = crypto.randomUUID(); + + it('returns data array on success', async () => { + const logs = [ + { id: 'log-1', action: 'auth.login' }, + { id: 'log-2', action: 'billing.changed' }, + ]; + mockQuery.mockResolvedValue({ data: logs, error: null }); + + const result = await getAuditLogs(mockDb, orgId); + + expect(result.data).toEqual(logs); + expect(result.error).toBeNull(); + }); + + it('returns empty array when no logs', async () => { + mockQuery.mockResolvedValue({ data: [], error: null }); + + const result = await getAuditLogs(mockDb, orgId); + + expect(result.data).toEqual([]); + expect(result.error).toBeNull(); + }); + + it('uses default limit=50 and offset=0', async () => { + mockQuery.mockResolvedValue({ data: [], error: null }); + + await getAuditLogs(mockDb, orgId); + + expect(mockQuery).toHaveBeenCalledWith( + mockDb, + expect.stringContaining('LIMIT'), + expect.arrayContaining([orgId, 50, 0]), + ); + }); + + it('passes custom limit and offset', async () => { + mockQuery.mockResolvedValue({ data: [], error: null }); + + await getAuditLogs(mockDb, orgId, { limit: 10, offset: 20 }); + + expect(mockQuery).toHaveBeenCalledWith( + mockDb, + expect.stringContaining('LIMIT'), + expect.arrayContaining([orgId, 10, 20]), + ); + }); + + it('returns error when DB fails', async () => { + mockQuery.mockResolvedValue({ data: [], error: 'Query failed' }); + + const result = await getAuditLogs(mockDb, orgId); + + expect(result.data).toEqual([]); + expect(result.error).toBe('Query failed'); + }); +}); diff --git a/apps/project-sites/src/__tests__/auth.test.ts b/apps/project-sites/src/__tests__/auth.test.ts new file mode 100644 index 0000000000..5908e37e2b --- /dev/null +++ b/apps/project-sites/src/__tests__/auth.test.ts @@ -0,0 +1,468 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), + dbExecute: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +import { dbQuery, dbQueryOne, dbInsert, dbUpdate, dbExecute } from '../services/db.js'; +import { + createMagicLink, + verifyMagicLink, + createGoogleOAuthState, + handleGoogleOAuthCallback, + createSession, + getSession, + revokeSession, + getUserSessions, +} from '../services/auth.js'; +import { AppError } from '@project-sites/shared'; + +const mockDbQuery = dbQuery as jest.MockedFunction; +const mockDbQueryOne = dbQueryOne as jest.MockedFunction; +const mockDbInsert = dbInsert as jest.MockedFunction; +const mockDbUpdate = dbUpdate as jest.MockedFunction; +const mockDbExecute = dbExecute as jest.MockedFunction; + +const mockEnv = { + ENVIRONMENT: 'staging', + GOOGLE_CLIENT_ID: 'test-google-client-id', + GOOGLE_CLIENT_SECRET: 'test-google-client-secret', + RESEND_API_KEY: 'test-resend-api-key', +} as any; + +const mockDb = {} as D1Database; + +const originalFetch = global.fetch; + +beforeEach(() => { + jest.clearAllMocks(); + // Default: mock fetch to return 200 for email sends + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'mock-msg-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +// --------------------------------------------------------------------------- +// createMagicLink +// --------------------------------------------------------------------------- +describe('createMagicLink', () => { + const input = { email: 'user@example.com' }; + + beforeEach(() => { + mockDbInsert.mockResolvedValue({ error: null }); + }); + + it('returns a 64-character hex token', async () => { + const result = await createMagicLink(mockDb, mockEnv, input); + expect(result.token).toMatch(/^[0-9a-f]{64}$/); + }); + + it('returns expires_at as an ISO 8601 string', async () => { + const result = await createMagicLink(mockDb, mockEnv, input); + expect(() => new Date(result.expires_at).toISOString()).not.toThrow(); + expect(new Date(result.expires_at).getTime()).toBeGreaterThan(Date.now()); + }); + + it('calls dbInsert on magic_links table', async () => { + await createMagicLink(mockDb, mockEnv, input); + + expect(mockDbInsert).toHaveBeenCalledWith( + mockDb, + 'magic_links', + expect.objectContaining({ + email: 'user@example.com', + used: 0, + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// verifyMagicLink +// --------------------------------------------------------------------------- +describe('verifyMagicLink', () => { + const token = 'a'.repeat(64); + const input = { token }; + + it('returns email when a valid token is found', async () => { + const futureDate = new Date(Date.now() + 3_600_000).toISOString(); + mockDbQueryOne.mockResolvedValueOnce({ + id: 'link-1', + email: 'user@example.com', + redirect_url: null, + used: 0, + expires_at: futureDate, + }); + mockDbUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + const result = await verifyMagicLink(mockDb, input); + expect(result.email).toBe('user@example.com'); + expect(result.redirect_url).toBeNull(); + }); + + it('throws unauthorized when no matching link is found', async () => { + mockDbQueryOne.mockResolvedValueOnce(null); + + await expect(verifyMagicLink(mockDb, input)).rejects.toThrow(AppError); + + mockDbQueryOne.mockResolvedValueOnce(null); + await expect(verifyMagicLink(mockDb, input)).rejects.toThrow('Invalid or expired magic link'); + }); + + it('throws unauthorized when the link is expired', async () => { + const pastDate = new Date(Date.now() - 3_600_000).toISOString(); + mockDbQueryOne.mockResolvedValueOnce({ + id: 'link-2', + email: 'old@example.com', + redirect_url: null, + used: 0, + expires_at: pastDate, + }); + + await expect(verifyMagicLink(mockDb, input)).rejects.toThrow('Magic link has expired'); + }); + + it('marks the link as used after successful verification', async () => { + const futureDate = new Date(Date.now() + 3_600_000).toISOString(); + mockDbQueryOne.mockResolvedValueOnce({ + id: 'link-3', + email: 'mark@example.com', + redirect_url: null, + used: 0, + expires_at: futureDate, + }); + mockDbUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + await verifyMagicLink(mockDb, input); + + expect(mockDbUpdate).toHaveBeenCalledWith( + mockDb, + 'magic_links', + expect.objectContaining({ used: 1 }), + 'id = ?', + ['link-3'], + ); + }); +}); + +// --------------------------------------------------------------------------- +// sendEmail fallback behavior +// --------------------------------------------------------------------------- +describe('sendEmail fallback (Resend → SendGrid)', () => { + const input = { email: 'fallback@example.com' }; + + beforeEach(() => { + mockDbInsert.mockResolvedValue({ error: null }); + }); + + it('falls back to SendGrid when Resend returns a non-200 status', async () => { + const envWithBoth = { + ...mockEnv, + RESEND_API_KEY: 'test-resend-key', + SENDGRID_API_KEY: 'test-sendgrid-key', + } as any; + + const mockFetch = jest.fn() + // First call (Resend) → 403 error + .mockResolvedValueOnce( + new Response('Domain not verified', { status: 403 }), + ) + // Second call (SendGrid) → 202 success + .mockResolvedValueOnce( + new Response('', { status: 202 }), + ); + global.fetch = mockFetch; + + const result = await createMagicLink(mockDb, envWithBoth, input); + expect(result.token).toMatch(/^[0-9a-f]{64}$/); + + // Verify both providers were called + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.resend.com/emails'); + expect(mockFetch.mock.calls[1][0]).toBe('https://api.sendgrid.com/v3/mail/send'); + }); + + it('uses only Resend when it succeeds', async () => { + const envWithBoth = { + ...mockEnv, + RESEND_API_KEY: 'test-resend-key', + SENDGRID_API_KEY: 'test-sendgrid-key', + } as any; + + const mockFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'msg-1' }), { status: 200 }), + ); + global.fetch = mockFetch; + + await createMagicLink(mockDb, envWithBoth, input); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.resend.com/emails'); + }); + + it('throws when Resend fails and SendGrid is not configured', async () => { + const envResendOnly = { + ...mockEnv, + RESEND_API_KEY: 'test-resend-key', + } as any; + + global.fetch = jest.fn().mockResolvedValueOnce( + new Response('Unauthorized', { status: 401 }), + ); + + await expect(createMagicLink(mockDb, envResendOnly, input)).rejects.toThrow( + 'Failed to send email (status 401)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// createGoogleOAuthState +// --------------------------------------------------------------------------- +describe('createGoogleOAuthState', () => { + beforeEach(() => { + mockDbInsert.mockResolvedValue({ error: null }); + }); + + it('returns an authUrl containing accounts.google.com', async () => { + const result = await createGoogleOAuthState(mockDb, mockEnv); + expect(result.authUrl).toContain('accounts.google.com'); + }); + + it('returns a hex state string', async () => { + const result = await createGoogleOAuthState(mockDb, mockEnv); + expect(result.state).toMatch(/^[0-9a-f]{64}$/); + }); + + it('stores the state in the oauth_states table', async () => { + const result = await createGoogleOAuthState(mockDb, mockEnv); + + expect(mockDbInsert).toHaveBeenCalledWith( + mockDb, + 'oauth_states', + expect.objectContaining({ + state: result.state, + provider: 'google', + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// handleGoogleOAuthCallback +// --------------------------------------------------------------------------- +describe('handleGoogleOAuthCallback', () => { + it('returns email and user info on successful callback', async () => { + const futureDate = new Date(Date.now() + 600_000).toISOString(); + + // dbQueryOne: find state + mockDbQueryOne.mockResolvedValueOnce({ + id: 'state-1', + state: 'valid-state', + expires_at: futureDate, + }); + // dbExecute: delete used state + mockDbExecute.mockResolvedValueOnce({ error: null, changes: 1 }); + + // global.fetch: token exchange + (global.fetch as jest.Mock) + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: 'mock-access-token' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + // global.fetch: userinfo + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + email: 'google-user@gmail.com', + name: 'Google User', + picture: 'https://example.com/avatar.jpg', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await handleGoogleOAuthCallback(mockDb, mockEnv, 'auth-code', 'valid-state'); + + expect(result.email).toBe('google-user@gmail.com'); + expect(result.display_name).toBe('Google User'); + expect(result.avatar_url).toBe('https://example.com/avatar.jpg'); + }); + + it('throws unauthorized when the state is not found', async () => { + mockDbQueryOne.mockResolvedValueOnce(null); + + await expect(handleGoogleOAuthCallback(mockDb, mockEnv, 'code', 'bad-state')).rejects.toThrow( + 'Invalid OAuth state', + ); + }); + + it('throws unauthorized when the state is expired', async () => { + const pastDate = new Date(Date.now() - 600_000).toISOString(); + + mockDbQueryOne.mockResolvedValueOnce({ + id: 'state-2', + state: 'expired-state', + expires_at: pastDate, + }); + + await expect( + handleGoogleOAuthCallback(mockDb, mockEnv, 'code', 'expired-state'), + ).rejects.toThrow('OAuth state expired'); + }); +}); + +// --------------------------------------------------------------------------- +// createSession +// --------------------------------------------------------------------------- +describe('createSession', () => { + beforeEach(() => { + mockDbInsert.mockResolvedValue({ error: null }); + }); + + it('returns a 64-character hex token and expires_at', async () => { + const result = await createSession(mockDb, 'user-id-1'); + + expect(result.token).toMatch(/^[0-9a-f]{64}$/); + expect(new Date(result.expires_at).getTime()).toBeGreaterThan(Date.now()); + }); + + it('creates a session record in the sessions table', async () => { + await createSession(mockDb, 'user-id-2', 'Chrome on macOS', '192.168.1.1'); + + expect(mockDbInsert).toHaveBeenCalledWith( + mockDb, + 'sessions', + expect.objectContaining({ + user_id: 'user-id-2', + device_info: 'Chrome on macOS', + ip_address: '192.168.1.1', + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// getSession +// --------------------------------------------------------------------------- +describe('getSession', () => { + const token = 'b'.repeat(64); + + it('returns session data for a valid token', async () => { + const futureDate = new Date(Date.now() + 86_400_000).toISOString(); + + mockDbQueryOne.mockResolvedValueOnce({ + id: 'sess-1', + user_id: 'user-1', + expires_at: futureDate, + }); + mockDbUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); // update last_active_at + + const result = await getSession(mockDb, token); + + expect(result).toEqual({ + id: 'sess-1', + user_id: 'user-1', + expires_at: futureDate, + }); + }); + + it('returns null when no session matches the token', async () => { + mockDbQueryOne.mockResolvedValueOnce(null); + + const result = await getSession(mockDb, token); + expect(result).toBeNull(); + }); + + it('returns null when the session is expired', async () => { + const pastDate = new Date(Date.now() - 86_400_000).toISOString(); + + mockDbQueryOne.mockResolvedValueOnce({ + id: 'sess-2', + user_id: 'user-2', + expires_at: pastDate, + }); + + const result = await getSession(mockDb, token); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// revokeSession +// --------------------------------------------------------------------------- +describe('revokeSession', () => { + it('calls dbUpdate with deleted_at set on the sessions table', async () => { + mockDbUpdate.mockResolvedValue({ error: null, changes: 1 }); + + await revokeSession(mockDb, 'sess-to-revoke'); + + expect(mockDbUpdate).toHaveBeenCalledWith( + mockDb, + 'sessions', + expect.objectContaining({ + deleted_at: expect.any(String), + }), + 'id = ?', + ['sess-to-revoke'], + ); + }); + + it('passes a valid ISO date as deleted_at', async () => { + mockDbUpdate.mockResolvedValue({ error: null, changes: 1 }); + + await revokeSession(mockDb, 'sess-99'); + + const updates = mockDbUpdate.mock.calls[0][2] as Record; + expect(updates.deleted_at).toBeDefined(); + // updated_at is added internally by dbUpdate, not by the service + expect(() => new Date(updates.deleted_at as string).toISOString()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// getUserSessions +// --------------------------------------------------------------------------- +describe('getUserSessions', () => { + it('returns an empty array when no sessions exist', async () => { + mockDbQuery.mockResolvedValueOnce({ data: [], error: null }); + + const result = await getUserSessions(mockDb, 'user-no-sessions'); + expect(result).toEqual([]); + }); + + it('returns active sessions for the given user', async () => { + const sessions = [ + { + id: 's1', + device_info: 'Firefox', + last_active_at: new Date().toISOString(), + created_at: new Date().toISOString(), + }, + { + id: 's2', + device_info: null, + last_active_at: new Date().toISOString(), + created_at: new Date().toISOString(), + }, + ]; + mockDbQuery.mockResolvedValueOnce({ data: sessions, error: null }); + + const result = await getUserSessions(mockDb, 'user-with-sessions'); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('s1'); + expect(result[1].device_info).toBeNull(); + }); +}); diff --git a/apps/project-sites/src/__tests__/billing.test.ts b/apps/project-sites/src/__tests__/billing.test.ts new file mode 100644 index 0000000000..756aa036b2 --- /dev/null +++ b/apps/project-sites/src/__tests__/billing.test.ts @@ -0,0 +1,518 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +jest.mock('@project-sites/shared', () => { + const actual = jest.requireActual('@project-sites/shared'); + return { + ...actual, + hmacSha256: jest.fn().mockResolvedValue('mock-signature'), + }; +}); + +import { dbQueryOne, dbInsert, dbUpdate } from '../services/db.js'; +import { + getOrCreateStripeCustomer, + createCheckoutSession, + handleCheckoutCompleted, + handleSubscriptionUpdated, + handleSubscriptionDeleted, + handlePaymentFailed, + getOrgEntitlements, + getOrgSubscription, + createBillingPortalSession, +} from '../services/billing.js'; + +const mockQueryOne = dbQueryOne as jest.MockedFunction; +const mockInsert = dbInsert as jest.MockedFunction; +const mockUpdate = dbUpdate as jest.MockedFunction; + +const mockEnv = { + STRIPE_SECRET_KEY: 'sk_test_123', + STRIPE_PUBLISHABLE_KEY: 'pk_test_123', + SALE_WEBHOOK_URL: undefined, + SALE_WEBHOOK_SECRET: undefined, +} as any; + +const mockDb = {} as D1Database; + +const originalFetch = global.fetch; + +beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +// --------------------------------------------------------------------------- +// getOrCreateStripeCustomer +// --------------------------------------------------------------------------- +describe('getOrCreateStripeCustomer', () => { + it('returns existing customer ID when subscription has one', async () => { + mockQueryOne.mockResolvedValueOnce({ id: 'sub_1', stripe_customer_id: 'cus_existing' }); + + const result = await getOrCreateStripeCustomer(mockDb, mockEnv, 'org_1', 'a@b.com'); + + expect(result).toEqual({ stripe_customer_id: 'cus_existing' }); + expect(mockQueryOne).toHaveBeenCalledTimes(1); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('creates new Stripe customer when none exists', async () => { + mockQueryOne.mockResolvedValueOnce(null); + mockInsert.mockResolvedValueOnce({ error: null }); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cus_new' }), + text: async () => '', + }); + + const result = await getOrCreateStripeCustomer(mockDb, mockEnv, 'org_1', 'a@b.com'); + + expect(result).toEqual({ stripe_customer_id: 'cus_new' }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.stripe.com/v1/customers', + expect.objectContaining({ method: 'POST' }), + ); + // Should insert subscription record + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'subscriptions', + expect.objectContaining({ + org_id: 'org_1', + stripe_customer_id: 'cus_new', + plan: 'free', + status: 'active', + }), + ); + }); + + it('throws on Stripe API failure', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + text: async () => 'Stripe error', + }); + + await expect(getOrCreateStripeCustomer(mockDb, mockEnv, 'org_1', 'a@b.com')).rejects.toThrow( + 'Failed to create Stripe customer', + ); + }); +}); + +// --------------------------------------------------------------------------- +// createCheckoutSession +// --------------------------------------------------------------------------- +describe('createCheckoutSession', () => { + const opts = { + orgId: 'org_1', + customerEmail: 'a@b.com', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }; + + function mockExistingCustomer() { + mockQueryOne.mockResolvedValueOnce({ id: 'sub_1', stripe_customer_id: 'cus_existing' }); + } + + it('returns checkout_url and session_id on success', async () => { + mockExistingCustomer(); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cs_123', url: 'https://checkout.stripe.com/cs_123' }), + text: async () => '', + }); + + const result = await createCheckoutSession(mockDb, mockEnv, opts); + + expect(result).toEqual({ + checkout_url: 'https://checkout.stripe.com/cs_123', + session_id: 'cs_123', + }); + }); + + it('includes org_id in metadata', async () => { + mockExistingCustomer(); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cs_123', url: 'https://checkout.stripe.com/cs_123' }), + text: async () => '', + }); + + await createCheckoutSession(mockDb, mockEnv, opts); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as URLSearchParams; + expect(body.get('metadata[org_id]')).toBe('org_1'); + }); + + it('throws on Stripe API failure', async () => { + mockExistingCustomer(); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + text: async () => 'Checkout error', + }); + + await expect(createCheckoutSession(mockDb, mockEnv, opts)).rejects.toThrow( + 'Failed to create Stripe checkout', + ); + }); +}); + +// --------------------------------------------------------------------------- +// handleCheckoutCompleted +// --------------------------------------------------------------------------- +describe('handleCheckoutCompleted', () => { + it('updates subscription to paid/active', async () => { + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + await handleCheckoutCompleted(mockDb, mockEnv, { + customer: 'cus_1', + subscription: 'sub_1', + metadata: { org_id: 'org_1' }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'subscriptions', + expect.objectContaining({ + plan: 'paid', + status: 'active', + stripe_subscription_id: 'sub_1', + dunning_stage: 0, + }), + 'org_id = ?', + ['org_1'], + ); + }); + + it('throws badRequest when org_id missing from metadata', async () => { + await expect( + handleCheckoutCompleted(mockDb, mockEnv, { + customer: 'cus_1', + subscription: 'sub_1', + metadata: {}, + }), + ).rejects.toThrow('Missing org_id in checkout metadata'); + }); + + it('calls sale webhook when URL configured', async () => { + const envWithWebhook = { + ...mockEnv, + SALE_WEBHOOK_URL: 'https://hooks.example.com/sale', + SALE_WEBHOOK_SECRET: 'whsec_test', + }; + + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); + + await handleCheckoutCompleted(mockDb, envWithWebhook, { + customer: 'cus_1', + subscription: 'sub_1', + metadata: { org_id: 'org_1', site_id: 'site_1' }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://hooks.example.com/sale', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Webhook-Signature': 'mock-signature', + }), + }), + ); + + // Verify the body contains expected fields + const webhookCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = JSON.parse(webhookCall[1].body); + expect(body.org_id).toBe('org_1'); + expect(body.site_id).toBe('site_1'); + expect(body.stripe_customer_id).toBe('cus_1'); + expect(body.stripe_subscription_id).toBe('sub_1'); + expect(body.plan).toBe('paid'); + }); +}); + +// --------------------------------------------------------------------------- +// handleSubscriptionUpdated +// --------------------------------------------------------------------------- +describe('handleSubscriptionUpdated', () => { + it('updates subscription status and period dates', async () => { + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + const periodStart = 1700000000; + const periodEnd = 1702592000; + + await handleSubscriptionUpdated(mockDb, { + id: 'sub_1', + status: 'active', + cancel_at_period_end: false, + current_period_start: periodStart, + current_period_end: periodEnd, + metadata: { org_id: 'org_1' }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'subscriptions', + expect.objectContaining({ + status: 'active', + cancel_at_period_end: 0, + current_period_start: new Date(periodStart * 1000).toISOString(), + current_period_end: new Date(periodEnd * 1000).toISOString(), + }), + 'org_id = ?', + ['org_1'], + ); + }); + + it('does nothing when org_id missing', async () => { + const result = await handleSubscriptionUpdated(mockDb, { + id: 'sub_1', + status: 'active', + cancel_at_period_end: false, + current_period_start: 1700000000, + current_period_end: 1702592000, + metadata: {}, + }); + + expect(result).toBeUndefined(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('passes cancel_at_period_end correctly', async () => { + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + await handleSubscriptionUpdated(mockDb, { + id: 'sub_1', + status: 'active', + cancel_at_period_end: true, + current_period_start: 1700000000, + current_period_end: 1702592000, + metadata: { org_id: 'org_1' }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'subscriptions', + expect.objectContaining({ + cancel_at_period_end: 1, + }), + 'org_id = ?', + ['org_1'], + ); + }); +}); + +// --------------------------------------------------------------------------- +// handleSubscriptionDeleted +// --------------------------------------------------------------------------- +describe('handleSubscriptionDeleted', () => { + it('sets plan=free, status=canceled', async () => { + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + await handleSubscriptionDeleted(mockDb, { + id: 'sub_1', + metadata: { org_id: 'org_1' }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'subscriptions', + expect.objectContaining({ + plan: 'free', + status: 'canceled', + stripe_subscription_id: null, + }), + 'org_id = ?', + ['org_1'], + ); + }); + + it('does nothing when org_id missing', async () => { + const result = await handleSubscriptionDeleted(mockDb, { + id: 'sub_1', + metadata: {}, + }); + + expect(result).toBeUndefined(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// handlePaymentFailed +// --------------------------------------------------------------------------- +describe('handlePaymentFailed', () => { + it('sets status=past_due', async () => { + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + await handlePaymentFailed(mockDb, { + subscription: 'sub_1', + metadata: { org_id: 'org_1' }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'subscriptions', + expect.objectContaining({ + status: 'past_due', + }), + 'org_id = ?', + ['org_1'], + ); + }); + + it('does nothing when org_id missing', async () => { + const result = await handlePaymentFailed(mockDb, { + subscription: 'sub_1', + metadata: {}, + }); + + expect(result).toBeUndefined(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// getOrgEntitlements +// --------------------------------------------------------------------------- +describe('getOrgEntitlements', () => { + it('returns paid entitlements when sub is paid+active', async () => { + mockQueryOne.mockResolvedValueOnce({ plan: 'paid', status: 'active' }); + + const result = await getOrgEntitlements(mockDb, 'org_1'); + + expect(result).toEqual({ + org_id: 'org_1', + plan: 'paid', + topBarHidden: true, + maxCustomDomains: 5, + chatEnabled: true, + analyticsEnabled: true, + }); + }); + + it('returns free entitlements when sub is free', async () => { + mockQueryOne.mockResolvedValueOnce({ plan: 'free', status: 'active' }); + + const result = await getOrgEntitlements(mockDb, 'org_1'); + + expect(result).toEqual({ + org_id: 'org_1', + plan: 'free', + topBarHidden: false, + maxCustomDomains: 0, + chatEnabled: true, + analyticsEnabled: false, + }); + }); + + it('returns free entitlements when no subscription found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getOrgEntitlements(mockDb, 'org_1'); + + expect(result).toEqual({ + org_id: 'org_1', + plan: 'free', + topBarHidden: false, + maxCustomDomains: 0, + chatEnabled: true, + analyticsEnabled: false, + }); + }); +}); + +// --------------------------------------------------------------------------- +// getOrgSubscription +// --------------------------------------------------------------------------- +describe('getOrgSubscription', () => { + it('returns subscription data when found', async () => { + mockQueryOne.mockResolvedValueOnce({ + plan: 'paid', + status: 'active', + stripe_customer_id: 'cus_1', + stripe_subscription_id: 'sub_1', + cancel_at_period_end: 0, + current_period_end: '2024-12-31T00:00:00Z', + }); + + const result = await getOrgSubscription(mockDb, 'org_1'); + + expect(result).toEqual({ + plan: 'paid', + status: 'active', + stripe_customer_id: 'cus_1', + stripe_subscription_id: 'sub_1', + cancel_at_period_end: false, + current_period_end: '2024-12-31T00:00:00Z', + }); + }); + + it('returns null when not found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getOrgSubscription(mockDb, 'org_1'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// createBillingPortalSession +// --------------------------------------------------------------------------- +describe('createBillingPortalSession', () => { + it('returns portal_url on success', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ url: 'https://billing.stripe.com/session/xyz' }), + text: async () => '', + }); + + const result = await createBillingPortalSession( + mockEnv, + 'cus_1', + 'https://example.com/settings', + ); + + expect(result).toEqual({ portal_url: 'https://billing.stripe.com/session/xyz' }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.stripe.com/v1/billing_portal/sessions', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer sk_test_123', + }), + }), + ); + }); + + it('throws on Stripe API failure', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + text: async () => 'Portal error', + }); + + await expect( + createBillingPortalSession(mockEnv, 'cus_1', 'https://example.com/settings'), + ).rejects.toThrow('Failed to create billing portal'); + }); +}); diff --git a/apps/project-sites/src/__tests__/contact.test.ts b/apps/project-sites/src/__tests__/contact.test.ts new file mode 100644 index 0000000000..13ed3c7b20 --- /dev/null +++ b/apps/project-sites/src/__tests__/contact.test.ts @@ -0,0 +1,328 @@ +import { handleContactForm } from '../services/contact.js'; +import { AppError } from '@project-sites/shared'; + +const mockEnv = { + ENVIRONMENT: 'staging', + RESEND_API_KEY: 'test-resend-key', + SENDGRID_API_KEY: 'test-sendgrid-key', +} as any; + +const originalFetch = global.fetch; +const mockFetch = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch.mockResolvedValue( + new Response(JSON.stringify({ id: 'mock-msg-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +// --------------------------------------------------------------------------- +// Valid submission +// --------------------------------------------------------------------------- +describe('handleContactForm – valid submission', () => { + const validInput = { + name: 'Jane Doe', + email: 'jane@example.com', + phone: '+1234567890', + message: 'Hello, I have a question about your services.', + }; + + it('sends two emails (notification + confirmation)', async () => { + await handleContactForm(mockEnv, validInput); + + // Two fetch calls: one for notification, one for confirmation + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call: notification to team + const firstCallUrl = mockFetch.mock.calls[0][0]; + expect(firstCallUrl).toBe('https://api.resend.com/emails'); + const firstBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(firstBody.to).toEqual(['hey@megabyte.space']); + expect(firstBody.subject).toContain('Jane Doe'); + expect(firstBody.reply_to).toBe('jane@example.com'); + + // Second call: confirmation to user + const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(secondBody.to).toEqual(['jane@example.com']); + expect(secondBody.subject).toContain('received your message'); + }); + + it('works without a phone number', async () => { + const input = { name: 'Bob', email: 'bob@test.com', message: 'This is my test message.' }; + await handleContactForm(mockEnv, input); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('HTML-escapes user input in email body', async () => { + const input = { + name: 'Test User', + email: 'test@example.com', + message: 'Hello & goodbye "friend"', + }; + await handleContactForm(mockEnv, input); + + const notificationBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(notificationBody.html).toContain('<b>User</b>'); + expect(notificationBody.html).toContain('& goodbye "friend"'); + }); +}); + +// --------------------------------------------------------------------------- +// Validation errors +// --------------------------------------------------------------------------- +describe('handleContactForm – validation', () => { + it('rejects missing name', async () => { + await expect( + handleContactForm(mockEnv, { email: 'a@b.com', message: 'Long enough message' }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects empty name', async () => { + await expect( + handleContactForm(mockEnv, { name: '', email: 'a@b.com', message: 'Long enough message' }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects invalid email', async () => { + await expect( + handleContactForm(mockEnv, { name: 'Bob', email: 'not-an-email', message: 'Long enough message' }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects missing email', async () => { + await expect( + handleContactForm(mockEnv, { name: 'Bob', message: 'Long enough message' }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects missing message', async () => { + await expect( + handleContactForm(mockEnv, { name: 'Bob', email: 'a@b.com' }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects short message (< 10 chars)', async () => { + await expect( + handleContactForm(mockEnv, { name: 'Bob', email: 'a@b.com', message: 'Short' }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects script tags in name', async () => { + await expect( + handleContactForm(mockEnv, { + name: '', + email: 'a@b.com', + message: 'A normal message here.', + }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects script tags in message', async () => { + await expect( + handleContactForm(mockEnv, { + name: 'Bob', + email: 'a@b.com', + message: 'Hello world', + }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects javascript: in message', async () => { + await expect( + handleContactForm(mockEnv, { + name: 'Bob', + email: 'a@b.com', + message: 'Check this link: javascript:alert(1)', + }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects name that is too long (> 200 chars)', async () => { + await expect( + handleContactForm(mockEnv, { + name: 'A'.repeat(201), + email: 'a@b.com', + message: 'A normal message here.', + }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects phone that is too long (> 20 chars)', async () => { + await expect( + handleContactForm(mockEnv, { + name: 'Bob', + email: 'a@b.com', + phone: '1'.repeat(21), + message: 'A normal message here.', + }), + ).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Email provider errors +// --------------------------------------------------------------------------- +describe('handleContactForm – email providers', () => { + it('falls back to SendGrid when Resend fails', async () => { + mockFetch + .mockResolvedValueOnce(new Response('error', { status: 500 })) // Resend fails (notification) + .mockResolvedValueOnce(new Response('', { status: 202 })) // SendGrid succeeds (notification) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'x' }), { status: 200 })); // Resend succeeds (confirmation) + + await handleContactForm(mockEnv, { + name: 'Jane', + email: 'jane@test.com', + message: 'Testing fallback behavior.', + }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.resend.com/emails'); + expect(mockFetch.mock.calls[1][0]).toBe('https://api.sendgrid.com/v3/mail/send'); + }); + + it('throws when no email provider is configured', async () => { + const noEmailEnv = { ENVIRONMENT: 'staging' } as any; + + await expect( + handleContactForm(noEmailEnv, { + name: 'Bob', + email: 'bob@test.com', + message: 'Testing no provider configured.', + }), + ).rejects.toThrow('Email delivery is not configured'); + }); + + it('throws when both providers fail', async () => { + mockFetch.mockResolvedValue(new Response('error', { status: 500 })); + + await expect( + handleContactForm(mockEnv, { + name: 'Bob', + email: 'bob@test.com', + message: 'Testing both providers failing.', + }), + ).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Coverage: escapeHtml, email content, boundary values +// --------------------------------------------------------------------------- +describe('handleContactForm – coverage gaps', () => { + it('escapes all HTML special characters in notification email', async () => { + const input = { + name: 'A & B "test" ', + email: 'test@example.com', + message: 'Chars: & < > " should all be escaped properly', + }; + await handleContactForm(mockEnv, input); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.html).toContain('&'); + expect(body.html).toContain('<'); + expect(body.html).toContain('>'); + expect(body.html).toContain('"'); + expect(body.html).not.toContain(''); + }); + + it('notification email contains all form fields', async () => { + const input = { + name: 'Alice', + email: 'alice@example.com', + phone: '+15551234567', + message: 'Please contact me about your premium plan.', + }; + await handleContactForm(mockEnv, input); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.html).toContain('Alice'); + expect(body.html).toContain('alice@example.com'); + expect(body.html).toContain('+15551234567'); + expect(body.html).toContain('premium plan'); + expect(body.subject).toContain('Alice'); + expect(body.reply_to).toBe('alice@example.com'); + }); + + it('confirmation email contains user name and message copy', async () => { + const input = { + name: 'Bob', + email: 'bob@example.com', + message: 'I would like a demo of the platform.', + }; + await handleContactForm(mockEnv, input); + + const body = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(body.html).toContain('Bob'); + expect(body.html).toContain('demo of the platform'); + expect(body.subject).toContain('received your message'); + expect(body.to).toEqual(['bob@example.com']); + }); + + it('notification email omits phone row when not provided', async () => { + const input = { + name: 'Charlie', + email: 'charlie@test.com', + message: 'No phone number here.', + }; + await handleContactForm(mockEnv, input); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.html).not.toContain('Phone:'); + }); + + it('accepts name at boundary (200 chars)', async () => { + const input = { + name: 'A'.repeat(200), + email: 'test@example.com', + message: 'Testing maximum name length boundary.', + }; + await handleContactForm(mockEnv, input); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('accepts message at minimum boundary (10 chars)', async () => { + const input = { + name: 'Test', + email: 'test@example.com', + message: '1234567890', + }; + await handleContactForm(mockEnv, input); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('sends via SendGrid only when Resend key is missing', async () => { + const sendGridOnlyEnv = { + ENVIRONMENT: 'staging', + SENDGRID_API_KEY: 'test-sendgrid-key', + } as any; + + await handleContactForm(sendGridOnlyEnv, { + name: 'Test', + email: 'test@example.com', + message: 'Testing SendGrid-only path.', + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.sendgrid.com/v3/mail/send'); + expect(mockFetch.mock.calls[1][0]).toBe('https://api.sendgrid.com/v3/mail/send'); + }); +}); diff --git a/apps/project-sites/src/__tests__/db.test.ts b/apps/project-sites/src/__tests__/db.test.ts new file mode 100644 index 0000000000..47beb31ec2 --- /dev/null +++ b/apps/project-sites/src/__tests__/db.test.ts @@ -0,0 +1,554 @@ +import { dbQuery, dbExecute, dbQueryOne, dbInsert, dbUpdate } from '../services/db.js'; + +// --------------------------------------------------------------------------- +// Mock D1 factory +// --------------------------------------------------------------------------- + +interface MockD1 { + db: D1Database; + prepare: jest.Mock; + bind: jest.Mock; + all: jest.Mock; + run: jest.Mock; +} + +function createMockD1(overrides?: { + allResult?: unknown; + runResult?: unknown; + allError?: Error; + runError?: Error; + bindError?: Error; + prepareError?: Error; +}): MockD1 { + const mockAll = overrides?.allError + ? jest.fn().mockRejectedValue(overrides.allError) + : jest.fn().mockResolvedValue(overrides?.allResult ?? { results: [] }); + + const mockRun = overrides?.runError + ? jest.fn().mockRejectedValue(overrides.runError) + : jest.fn().mockResolvedValue(overrides?.runResult ?? { meta: { changes: 0 } }); + + const mockBind = overrides?.bindError + ? jest.fn().mockImplementation(() => { + throw overrides.bindError; + }) + : jest.fn().mockReturnValue({ all: mockAll, run: mockRun }); + + const mockPrepare = overrides?.prepareError + ? jest.fn().mockImplementation(() => { + throw overrides.prepareError; + }) + : jest.fn().mockReturnValue({ bind: mockBind }); + + return { + db: { prepare: mockPrepare } as unknown as D1Database, + prepare: mockPrepare, + bind: mockBind, + all: mockAll, + run: mockRun, + }; +} + +// --------------------------------------------------------------------------- +// dbQuery +// --------------------------------------------------------------------------- + +describe('dbQuery', () => { + it('returns empty data array when no rows match', async () => { + const { db } = createMockD1(); + + const result = await dbQuery(db, 'SELECT * FROM sites'); + + expect(result).toEqual({ data: [], error: null }); + }); + + it('returns typed rows on success', async () => { + const rows = [ + { id: '1', slug: 'alpha' }, + { id: '2', slug: 'beta' }, + ]; + const { db } = createMockD1({ allResult: { results: rows } }); + + const result = await dbQuery<{ id: string; slug: string }>( + db, + 'SELECT id, slug FROM sites WHERE org_id = ?', + ['org-1'], + ); + + expect(result.data).toEqual(rows); + expect(result.error).toBeNull(); + }); + + it('passes SQL and params through prepare().bind()', async () => { + const { db, prepare, bind } = createMockD1(); + + await dbQuery(db, 'SELECT * FROM sites WHERE org_id = ? AND status = ?', ['org-1', 'active']); + + expect(prepare).toHaveBeenCalledWith('SELECT * FROM sites WHERE org_id = ? AND status = ?'); + expect(bind).toHaveBeenCalledWith('org-1', 'active'); + }); + + it('defaults params to empty array (bind called with no arguments)', async () => { + const { db, bind } = createMockD1(); + + await dbQuery(db, 'SELECT 1'); + + expect(bind).toHaveBeenCalledWith(); + }); + + it('handles results being undefined (returns empty array)', async () => { + const { db } = createMockD1({ allResult: { results: undefined } }); + + const result = await dbQuery(db, 'SELECT 1'); + + expect(result.data).toEqual([]); + expect(result.error).toBeNull(); + }); + + it('returns error message when D1 throws an Error', async () => { + const { db } = createMockD1({ allError: new Error('D1_ERROR: table not found') }); + + const result = await dbQuery(db, 'SELECT * FROM nonexistent'); + + expect(result.data).toEqual([]); + expect(result.error).toBe('D1_ERROR: table not found'); + }); + + it('returns generic message when D1 throws a non-Error', async () => { + const mockAll = jest.fn().mockRejectedValue('string-error'); + const mockBind = jest.fn().mockReturnValue({ all: mockAll, run: jest.fn() }); + const mockPrepare = jest.fn().mockReturnValue({ bind: mockBind }); + const db = { prepare: mockPrepare } as unknown as D1Database; + + const result = await dbQuery(db, 'SELECT 1'); + + expect(result.data).toEqual([]); + expect(result.error).toBe('Unknown D1 error'); + }); + + it('catches errors thrown during prepare()', async () => { + const { db } = createMockD1({ prepareError: new Error('SQL syntax error') }); + + const result = await dbQuery(db, 'INVALID SQL %%%'); + + expect(result.data).toEqual([]); + expect(result.error).toBe('SQL syntax error'); + }); + + it('catches errors thrown during bind()', async () => { + const { db } = createMockD1({ bindError: new Error('parameter count mismatch') }); + + const result = await dbQuery(db, 'SELECT * FROM sites WHERE id = ?', ['a', 'b']); + + expect(result.data).toEqual([]); + expect(result.error).toBe('parameter count mismatch'); + }); +}); + +// --------------------------------------------------------------------------- +// dbExecute +// --------------------------------------------------------------------------- + +describe('dbExecute', () => { + it('returns zero changes and no error on success with no rows affected', async () => { + const { db } = createMockD1(); + + const result = await dbExecute(db, 'DELETE FROM sites WHERE id = ?', ['nonexistent']); + + expect(result).toEqual({ error: null, changes: 0 }); + }); + + it('returns the number of changed rows', async () => { + const { db } = createMockD1({ runResult: { meta: { changes: 3 } } }); + + const result = await dbExecute(db, 'UPDATE sites SET status = ? WHERE org_id = ?', [ + 'archived', + 'org-1', + ]); + + expect(result.changes).toBe(3); + expect(result.error).toBeNull(); + }); + + it('passes SQL and params through prepare().bind().run()', async () => { + const { db, prepare, bind, run } = createMockD1(); + + await dbExecute(db, 'DELETE FROM sessions WHERE token_hash = ?', ['abc123']); + + expect(prepare).toHaveBeenCalledWith('DELETE FROM sessions WHERE token_hash = ?'); + expect(bind).toHaveBeenCalledWith('abc123'); + expect(run).toHaveBeenCalled(); + }); + + it('defaults params to empty array', async () => { + const { db, bind } = createMockD1(); + + await dbExecute(db, 'DELETE FROM expired_sessions'); + + expect(bind).toHaveBeenCalledWith(); + }); + + it('handles meta.changes being undefined (returns 0)', async () => { + const { db } = createMockD1({ runResult: { meta: {} } }); + + const result = await dbExecute(db, 'INSERT INTO log (msg) VALUES (?)', ['test']); + + expect(result.changes).toBe(0); + expect(result.error).toBeNull(); + }); + + it('handles meta being undefined (returns 0)', async () => { + const { db } = createMockD1({ runResult: {} }); + + const result = await dbExecute(db, 'INSERT INTO log (msg) VALUES (?)', ['test']); + + expect(result.changes).toBe(0); + expect(result.error).toBeNull(); + }); + + it('returns error message when D1 throws an Error', async () => { + const { db } = createMockD1({ runError: new Error('UNIQUE constraint failed') }); + + const result = await dbExecute(db, 'INSERT INTO sites (slug) VALUES (?)', ['duplicate-slug']); + + expect(result.error).toBe('UNIQUE constraint failed'); + expect(result.changes).toBe(0); + }); + + it('returns generic message when D1 throws a non-Error', async () => { + const mockRun = jest.fn().mockRejectedValue(42); + const mockBind = jest.fn().mockReturnValue({ all: jest.fn(), run: mockRun }); + const mockPrepare = jest.fn().mockReturnValue({ bind: mockBind }); + const db = { prepare: mockPrepare } as unknown as D1Database; + + const result = await dbExecute(db, 'INSERT INTO sites (slug) VALUES (?)', ['x']); + + expect(result.error).toBe('Unknown D1 error'); + expect(result.changes).toBe(0); + }); + + it('catches errors thrown during prepare()', async () => { + const { db } = createMockD1({ prepareError: new Error('bad SQL') }); + + const result = await dbExecute(db, 'NOT VALID SQL'); + + expect(result.error).toBe('bad SQL'); + expect(result.changes).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// dbQueryOne +// --------------------------------------------------------------------------- + +describe('dbQueryOne', () => { + it('returns the first row when results exist', async () => { + const rows = [ + { id: '1', slug: 'first' }, + { id: '2', slug: 'second' }, + ]; + const { db } = createMockD1({ allResult: { results: rows } }); + + const result = await dbQueryOne<{ id: string; slug: string }>( + db, + 'SELECT * FROM sites WHERE org_id = ?', + ['org-1'], + ); + + expect(result).toEqual({ id: '1', slug: 'first' }); + }); + + it('returns null when no rows match', async () => { + const { db } = createMockD1({ allResult: { results: [] } }); + + const result = await dbQueryOne(db, 'SELECT * FROM sites WHERE id = ?', ['nonexistent']); + + expect(result).toBeNull(); + }); + + it('returns null when results is undefined', async () => { + const { db } = createMockD1({ allResult: { results: undefined } }); + + const result = await dbQueryOne(db, 'SELECT 1'); + + expect(result).toBeNull(); + }); + + it('returns null when D1 throws (error path in dbQuery)', async () => { + const { db } = createMockD1({ allError: new Error('table not found') }); + + const result = await dbQueryOne(db, 'SELECT * FROM nonexistent'); + + expect(result).toBeNull(); + }); + + it('passes params through to dbQuery', async () => { + const { db, prepare, bind } = createMockD1(); + + await dbQueryOne(db, 'SELECT * FROM sessions WHERE token_hash = ?', ['hash-abc']); + + expect(prepare).toHaveBeenCalledWith('SELECT * FROM sessions WHERE token_hash = ?'); + expect(bind).toHaveBeenCalledWith('hash-abc'); + }); + + it('defaults params to empty array', async () => { + const { db, bind } = createMockD1(); + + await dbQueryOne(db, 'SELECT COUNT(*) as cnt FROM sites'); + + expect(bind).toHaveBeenCalledWith(); + }); +}); + +// --------------------------------------------------------------------------- +// dbInsert +// --------------------------------------------------------------------------- + +describe('dbInsert', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-06-15T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('generates correct INSERT SQL from row keys', async () => { + const { db, prepare } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', { id: 'site-1', slug: 'my-site', business_name: 'Acme' }); + + const sql = prepare.mock.calls[0][0] as string; + expect(sql).toContain('INSERT INTO sites'); + expect(sql).toContain('created_at'); + expect(sql).toContain('updated_at'); + expect(sql).toContain('id'); + expect(sql).toContain('slug'); + expect(sql).toContain('business_name'); + }); + + it('auto-adds created_at and updated_at timestamps', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', { id: 'site-1' }); + + const boundValues = bind.mock.calls[0] as unknown[]; + expect(boundValues).toContain('2025-06-15T12:00:00.000Z'); + // Both created_at and updated_at should be set + const timestampCount = boundValues.filter((v) => v === '2025-06-15T12:00:00.000Z').length; + expect(timestampCount).toBe(2); + }); + + it('does not override caller-supplied created_at', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', { id: 'site-1', created_at: '2024-01-01T00:00:00.000Z' }); + + const boundValues = bind.mock.calls[0] as unknown[]; + // The caller's created_at should win because row is spread after defaults + expect(boundValues).toContain('2024-01-01T00:00:00.000Z'); + }); + + it('does not override caller-supplied updated_at', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', { id: 'site-1', updated_at: '2024-01-01T00:00:00.000Z' }); + + const boundValues = bind.mock.calls[0] as unknown[]; + expect(boundValues).toContain('2024-01-01T00:00:00.000Z'); + }); + + it('generates correct number of ? placeholders', async () => { + const { db, prepare } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', { id: 'site-1', slug: 'my-site', status: 'draft' }); + + const sql = prepare.mock.calls[0][0] as string; + // 3 user fields + 2 timestamp fields = 5 placeholders + const placeholders = sql.match(/\?/g); + expect(placeholders).toHaveLength(5); + }); + + it('returns null error on success', async () => { + const { db } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + const result = await dbInsert(db, 'sites', { id: 'site-1', slug: 'my-site' }); + + expect(result.error).toBeNull(); + }); + + it('returns error on D1 failure', async () => { + const { db } = createMockD1({ + runError: new Error('UNIQUE constraint failed: sites.slug'), + }); + + const result = await dbInsert(db, 'sites', { id: 'site-2', slug: 'duplicate' }); + + expect(result.error).toBe('UNIQUE constraint failed: sites.slug'); + }); + + it('binds values in the same order as keys', async () => { + const { db, prepare, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', { id: 'x', slug: 'y', status: 'z' }); + + const sql = prepare.mock.calls[0][0] as string; + const colsPart = sql.match(/\(([^)]+)\) VALUES/)?.[1] ?? ''; + const cols = colsPart.split(',').map((c) => c.trim()); + + const boundValues = bind.mock.calls[0] as unknown[]; + // Each column's position should match its value's position in bind args + const idIndex = cols.indexOf('id'); + expect(boundValues[idIndex]).toBe('x'); + + const slugIndex = cols.indexOf('slug'); + expect(boundValues[slugIndex]).toBe('y'); + + const statusIndex = cols.indexOf('status'); + expect(boundValues[statusIndex]).toBe('z'); + }); + + it('handles an empty row object (only timestamps)', async () => { + const { db, prepare, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbInsert(db, 'sites', {}); + + const sql = prepare.mock.calls[0][0] as string; + expect(sql).toContain('created_at'); + expect(sql).toContain('updated_at'); + const placeholders = sql.match(/\?/g); + expect(placeholders).toHaveLength(2); + expect(bind).toHaveBeenCalledWith('2025-06-15T12:00:00.000Z', '2025-06-15T12:00:00.000Z'); + }); +}); + +// --------------------------------------------------------------------------- +// dbUpdate +// --------------------------------------------------------------------------- + +describe('dbUpdate', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-06-15T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('generates correct UPDATE SQL', async () => { + const { db, prepare } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbUpdate(db, 'sites', { status: 'published' }, 'id = ?', ['site-1']); + + const sql = prepare.mock.calls[0][0] as string; + expect(sql).toBe('UPDATE sites SET status = ?, updated_at = ? WHERE id = ?'); + }); + + it('auto-adds updated_at timestamp', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbUpdate(db, 'sites', { status: 'published' }, 'id = ?', ['site-1']); + + const boundValues = bind.mock.calls[0] as unknown[]; + expect(boundValues).toContain('2025-06-15T12:00:00.000Z'); + }); + + it('places SET values before WHERE params in bind order', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbUpdate( + db, + 'sites', + { status: 'published', current_build_version: 'v2' }, + 'id = ? AND org_id = ?', + ['site-1', 'org-1'], + ); + + const boundValues = bind.mock.calls[0] as unknown[]; + // SET values: status, current_build_version, updated_at, then WHERE: site-1, org-1 + expect(boundValues[0]).toBe('published'); + expect(boundValues[1]).toBe('v2'); + expect(boundValues[2]).toBe('2025-06-15T12:00:00.000Z'); + expect(boundValues[3]).toBe('site-1'); + expect(boundValues[4]).toBe('org-1'); + }); + + it('returns changes count on success', async () => { + const { db } = createMockD1({ runResult: { meta: { changes: 5 } } }); + + const result = await dbUpdate(db, 'sites', { status: 'archived' }, 'org_id = ?', ['org-1']); + + expect(result.changes).toBe(5); + expect(result.error).toBeNull(); + }); + + it('returns zero changes when no rows match', async () => { + const { db } = createMockD1({ runResult: { meta: { changes: 0 } } }); + + const result = await dbUpdate(db, 'sites', { status: 'published' }, 'id = ?', ['nonexistent']); + + expect(result.changes).toBe(0); + expect(result.error).toBeNull(); + }); + + it('returns error on D1 failure', async () => { + const { db } = createMockD1({ runError: new Error('no such column: bad_col') }); + + const result = await dbUpdate(db, 'sites', { bad_col: 'value' }, 'id = ?', ['site-1']); + + expect(result.error).toBe('no such column: bad_col'); + expect(result.changes).toBe(0); + }); + + it('defaults whereParams to empty array', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbUpdate(db, 'sites', { status: 'archived' }, '1 = 1'); + + const boundValues = bind.mock.calls[0] as unknown[]; + // Only SET values: status, updated_at (no WHERE params) + expect(boundValues).toHaveLength(2); + expect(boundValues[0]).toBe('archived'); + expect(boundValues[1]).toBe('2025-06-15T12:00:00.000Z'); + }); + + it('overrides caller-supplied updated_at with current timestamp', async () => { + const { db, bind } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbUpdate( + db, + 'sites', + { status: 'published', updated_at: '2020-01-01T00:00:00.000Z' }, + 'id = ?', + ['site-1'], + ); + + const boundValues = bind.mock.calls[0] as unknown[]; + // The spread order in dbUpdate is { ...updates, updated_at: now } so current time wins + const updatedAtValue = boundValues.find( + (v) => typeof v === 'string' && (v as string).startsWith('2025-'), + ); + expect(updatedAtValue).toBe('2025-06-15T12:00:00.000Z'); + }); + + it('handles multiple SET columns', async () => { + const { db, prepare } = createMockD1({ runResult: { meta: { changes: 1 } } }); + + await dbUpdate( + db, + 'sites', + { status: 'published', business_name: 'New Name', slug: 'new-slug' }, + 'id = ?', + ['site-1'], + ); + + const sql = prepare.mock.calls[0][0] as string; + expect(sql).toContain('status = ?'); + expect(sql).toContain('business_name = ?'); + expect(sql).toContain('slug = ?'); + expect(sql).toContain('updated_at = ?'); + expect(sql).toContain('WHERE id = ?'); + }); +}); diff --git a/apps/project-sites/src/__tests__/domains.test.ts b/apps/project-sites/src/__tests__/domains.test.ts new file mode 100644 index 0000000000..a1f928077e --- /dev/null +++ b/apps/project-sites/src/__tests__/domains.test.ts @@ -0,0 +1,708 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +import { dbQuery, dbQueryOne, dbInsert, dbUpdate } from '../services/db.js'; +import { + createCustomHostname, + checkHostnameStatus, + deleteCustomHostname, + provisionFreeDomain, + provisionCustomDomain, + getSiteHostnames, + getHostnameByDomain, + verifyPendingHostnames, + setPrimaryHostname, + checkCnameTarget, + getPrimaryHostname, +} from '../services/domains.js'; +import { AppError } from '@project-sites/shared'; + +const mockQuery = dbQuery as jest.MockedFunction; +const mockQueryOne = dbQueryOne as jest.MockedFunction; +const mockInsert = dbInsert as jest.MockedFunction; +const mockUpdate = dbUpdate as jest.MockedFunction; + +const mockEnv = { + CF_API_TOKEN: 'test-cf-token', + CF_ZONE_ID: 'test-zone-id', +} as any; + +const mockDb = {} as D1Database; + +const originalFetch = global.fetch; + +beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +// --------------------------------------------------------------------------- +// createCustomHostname +// --------------------------------------------------------------------------- +describe('createCustomHostname', () => { + it('returns cf_id, status, and ssl_status on success', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { id: 'cf-host-123', status: 'pending', ssl: { status: 'pending_validation' } }, + }), + text: async () => '', + }); + + const result = await createCustomHostname(mockEnv, 'app.example.com'); + + expect(result).toEqual({ + cf_id: 'cf-host-123', + status: 'pending', + ssl_status: 'pending_validation', + }); + }); + + it('throws badRequest on CF API failure', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + text: async () => 'Zone not found', + }); + + const err = await createCustomHostname(mockEnv, 'bad.example.com').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).message).toMatch(/Failed to create custom hostname/); + expect((err as AppError).statusCode).toBe(400); + }); + + it('sends correct auth header and body', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { id: 'cf-1', status: 'pending', ssl: { status: 'pending_validation' } }, + }), + text: async () => '', + }); + + await createCustomHostname(mockEnv, 'test.example.com'); + + expect(global.fetch).toHaveBeenCalledWith( + `https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames`, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-cf-token', + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + hostname: 'test.example.com', + ssl: { + method: 'http', + type: 'dv', + settings: { min_tls_version: '1.2' }, + }, + }), + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// checkHostnameStatus +// --------------------------------------------------------------------------- +describe('checkHostnameStatus', () => { + it('returns status, ssl_status, and empty verification_errors', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { status: 'active', ssl: { status: 'active' } }, + }), + text: async () => '', + }); + + const result = await checkHostnameStatus(mockEnv, 'cf-host-123'); + + expect(result).toEqual({ + status: 'active', + ssl_status: 'active', + verification_errors: [], + }); + }); + + it('returns verification_errors array when present', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { + status: 'pending', + ssl: { status: 'pending_validation' }, + verification_errors: ['CNAME not found', 'DNS timeout'], + }, + }), + text: async () => '', + }); + + const result = await checkHostnameStatus(mockEnv, 'cf-host-456'); + + expect(result.verification_errors).toEqual(['CNAME not found', 'DNS timeout']); + }); + + it('throws notFound when hostname not found', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + text: async () => 'Not found', + }); + + const err = await checkHostnameStatus(mockEnv, 'cf-nonexistent').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).message).toMatch(/Custom hostname not found/); + expect((err as AppError).statusCode).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// deleteCustomHostname +// --------------------------------------------------------------------------- +describe('deleteCustomHostname', () => { + it('succeeds with no return value', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: { id: 'cf-host-123' } }), + text: async () => '', + }); + + const result = await deleteCustomHostname(mockEnv, 'cf-host-123'); + + expect(result).toBeUndefined(); + expect(global.fetch).toHaveBeenCalledWith( + `https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames/cf-host-123`, + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + Authorization: 'Bearer test-cf-token', + }), + }), + ); + }); + + it('ignores 404 errors', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + text: async () => 'Not found', + }); + + await expect(deleteCustomHostname(mockEnv, 'cf-already-gone')).resolves.toBeUndefined(); + }); + + it('throws on other errors', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + text: async () => 'Internal Server Error', + }); + + const err = await deleteCustomHostname(mockEnv, 'cf-host-err').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).message).toMatch(/Failed to delete custom hostname/); + expect((err as AppError).statusCode).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// provisionFreeDomain +// --------------------------------------------------------------------------- +describe('provisionFreeDomain', () => { + it('returns hostname in format slug-sites.megabyte.space', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { id: 'cf-free-1', status: 'pending', ssl: { status: 'pending_validation' } }, + }), + text: async () => '', + }); + + mockInsert.mockResolvedValueOnce({ error: null }); + + const result = await provisionFreeDomain(mockDb, mockEnv, { + org_id: 'org-1', + site_id: 'site-1', + slug: 'my-app', + }); + + expect(result.hostname).toBe('my-app-sites.megabyte.space'); + expect(result.status).toBe('pending'); + }); + + it('returns existing hostname if already exists', async () => { + mockQueryOne.mockResolvedValueOnce({ id: 'existing-id', status: 'active' }); + + const result = await provisionFreeDomain(mockDb, mockEnv, { + org_id: 'org-1', + site_id: 'site-1', + slug: 'existing-app', + }); + + expect(result).toEqual({ + hostname: 'existing-app-sites.megabyte.space', + status: 'active', + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('creates new hostname when none exists', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { id: 'cf-new-1', status: 'active', ssl: { status: 'active' } }, + }), + text: async () => '', + }); + + mockInsert.mockResolvedValueOnce({ error: null }); + + const result = await provisionFreeDomain(mockDb, mockEnv, { + org_id: 'org-2', + site_id: 'site-2', + slug: 'new-app', + }); + + expect(result).toEqual({ + hostname: 'new-app-sites.megabyte.space', + status: 'active', + }); + + // Verify CF API was called + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Verify DB insert was called + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'hostnames', + expect.objectContaining({ + org_id: 'org-2', + site_id: 'site-2', + hostname: 'new-app-sites.megabyte.space', + type: 'free_subdomain', + status: 'active', + cf_custom_hostname_id: 'cf-new-1', + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// provisionCustomDomain +// --------------------------------------------------------------------------- +describe('provisionCustomDomain', () => { + it('returns hostname and status on success', async () => { + // Domain limit check + mockQuery.mockResolvedValueOnce({ data: [], error: null }); + // Existing hostname check + mockQueryOne.mockResolvedValueOnce(null); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { id: 'cf-custom-1', status: 'pending', ssl: { status: 'pending_validation' } }, + }), + text: async () => '', + }); + + // DB insert + mockInsert.mockResolvedValueOnce({ error: null }); + + const result = await provisionCustomDomain(mockDb, mockEnv, { + org_id: 'org-1', + site_id: 'site-1', + hostname: 'app.example.com', + }); + + expect(result).toEqual({ + hostname: 'app.example.com', + status: 'pending', + }); + }); + + it('throws conflict when max domains reached', async () => { + const fiveDomains = Array.from({ length: 5 }, (_, i) => ({ id: `dom-${i}` })); + mockQuery.mockResolvedValueOnce({ data: fiveDomains, error: null }); + + await expect( + provisionCustomDomain(mockDb, mockEnv, { + org_id: 'org-full', + site_id: 'site-1', + hostname: 'sixth.example.com', + }), + ).rejects.toThrow(/Maximum custom domains/); + }); + + it('throws conflict when hostname already registered', async () => { + // Domain limit check: under limit + mockQuery.mockResolvedValueOnce({ data: [{ id: 'dom-1' }], error: null }); + // Existing hostname check: already taken + mockQueryOne.mockResolvedValueOnce({ id: 'existing-host' }); + + await expect( + provisionCustomDomain(mockDb, mockEnv, { + org_id: 'org-1', + site_id: 'site-1', + hostname: 'taken.example.com', + }), + ).rejects.toThrow(/already registered/); + }); + + it('creates CF hostname and DB record', async () => { + mockQuery.mockResolvedValueOnce({ data: [], error: null }); + mockQueryOne.mockResolvedValueOnce(null); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { id: 'cf-custom-2', status: 'active', ssl: { status: 'active' } }, + }), + text: async () => '', + }); + + mockInsert.mockResolvedValueOnce({ error: null }); + + await provisionCustomDomain(mockDb, mockEnv, { + org_id: 'org-3', + site_id: 'site-3', + hostname: 'custom.example.com', + }); + + // CF API called with hostname + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('custom_hostnames'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('custom.example.com'), + }), + ); + + // DB insert with correct fields + expect(mockInsert).toHaveBeenCalledTimes(1); + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'hostnames', + expect.objectContaining({ + org_id: 'org-3', + site_id: 'site-3', + hostname: 'custom.example.com', + type: 'custom_cname', + status: 'active', + cf_custom_hostname_id: 'cf-custom-2', + ssl_status: 'active', + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// getSiteHostnames +// --------------------------------------------------------------------------- +describe('getSiteHostnames', () => { + it('returns array of hostnames', async () => { + const hostnames = [ + { + id: 'h1', + hostname: 'app-sites.megabyte.space', + type: 'free_subdomain', + status: 'active', + ssl_status: 'active', + }, + { + id: 'h2', + hostname: 'custom.example.com', + type: 'custom_cname', + status: 'pending', + ssl_status: 'pending_validation', + }, + ]; + mockQuery.mockResolvedValueOnce({ data: hostnames, error: null }); + + const result = await getSiteHostnames(mockDb, 'site-1'); + + expect(result).toEqual(hostnames); + expect(result).toHaveLength(2); + }); + + it('returns empty array when none found', async () => { + mockQuery.mockResolvedValueOnce({ data: [], error: null }); + + const result = await getSiteHostnames(mockDb, 'site-empty'); + + expect(result).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// getHostnameByDomain +// --------------------------------------------------------------------------- +describe('getHostnameByDomain', () => { + it('returns hostname record when found', async () => { + const record = { + id: 'h1', + site_id: 'site-1', + org_id: 'org-1', + type: 'custom_cname', + status: 'active', + }; + mockQueryOne.mockResolvedValueOnce(record); + + const result = await getHostnameByDomain(mockDb, 'custom.example.com'); + + expect(result).toEqual(record); + }); + + it('returns null when not found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getHostnameByDomain(mockDb, 'nonexistent.example.com'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// verifyPendingHostnames +// --------------------------------------------------------------------------- +describe('verifyPendingHostnames', () => { + it('returns { verified: 1, failed: 0 } when hostname becomes active', async () => { + mockQuery.mockResolvedValueOnce({ + data: [ + { id: 'h-pending', cf_custom_hostname_id: 'cf-pending-1', hostname: 'pending.example.com' }, + ], + error: null, + }); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { status: 'active', ssl: { status: 'active' } }, + }), + text: async () => '', + }); + + // PATCH update + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + const result = await verifyPendingHostnames(mockDb, mockEnv); + + expect(result).toEqual({ verified: 1, failed: 0 }); + + // Verify dbUpdate was called with active status + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'hostnames', + expect.objectContaining({ + status: 'active', + ssl_status: 'active', + verification_errors: null, + }), + 'id = ?', + ['h-pending'], + ); + }); + + it('returns { verified: 0, failed: 1 } when verification errors', async () => { + mockQuery.mockResolvedValueOnce({ + data: [{ id: 'h-fail', cf_custom_hostname_id: 'cf-fail-1', hostname: 'fail.example.com' }], + error: null, + }); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + result: { + status: 'pending', + ssl: { status: 'pending_validation' }, + verification_errors: ['CNAME record missing'], + }, + }), + text: async () => '', + }); + + // PATCH update + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + const result = await verifyPendingHostnames(mockDb, mockEnv); + + expect(result).toEqual({ verified: 0, failed: 1 }); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'hostnames', + expect.objectContaining({ + status: 'verification_failed', + verification_errors: JSON.stringify(['CNAME record missing']), + }), + 'id = ?', + ['h-fail'], + ); + }); + + it('returns { verified: 0, failed: 0 } when no pending hostnames', async () => { + mockQuery.mockResolvedValueOnce({ data: [], error: null }); + + const result = await verifyPendingHostnames(mockDb, mockEnv); + + expect(result).toEqual({ verified: 0, failed: 0 }); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// setPrimaryHostname +// --------------------------------------------------------------------------- +describe('setPrimaryHostname', () => { + it('sets a hostname as primary and clears others', async () => { + mockQueryOne.mockResolvedValueOnce({ id: 'h-123' }); + mockUpdate.mockResolvedValueOnce({ error: null, changes: 3 }); + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + await setPrimaryHostname(mockDb, 'site-1', 'h-123'); + + expect(mockQueryOne).toHaveBeenCalledWith( + mockDb, + expect.stringContaining('SELECT id FROM hostnames'), + ['h-123', 'site-1'], + ); + + // First call: clear all primary + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'hostnames', + { is_primary: 0 }, + 'site_id = ?', + ['site-1'], + ); + + // Second call: set primary + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'hostnames', + { is_primary: 1 }, + 'id = ?', + ['h-123'], + ); + }); + + it('throws notFound if hostname does not belong to site', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const err = await setPrimaryHostname(mockDb, 'site-1', 'h-missing').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// checkCnameTarget +// --------------------------------------------------------------------------- +describe('checkCnameTarget', () => { + it('returns CNAME target when record exists', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Answer: [{ type: 5, data: 'sites.megabyte.space.' }], + }), + }); + + const result = await checkCnameTarget('www.example.com'); + + expect(result).toBe('sites.megabyte.space'); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('cloudflare-dns.com/dns-query'), + expect.objectContaining({ + headers: { accept: 'application/dns-json' }, + }), + ); + }); + + it('returns null when no CNAME record exists', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ Answer: [] }), + }); + + const result = await checkCnameTarget('www.example.com'); + + expect(result).toBeNull(); + }); + + it('returns null when DNS query fails', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + }); + + const result = await checkCnameTarget('www.example.com'); + + expect(result).toBeNull(); + }); + + it('returns null when fetch throws', async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const result = await checkCnameTarget('www.example.com'); + + expect(result).toBeNull(); + }); + + it('strips trailing dot from CNAME data', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Answer: [{ type: 5, data: 'target.example.com.' }], + }), + }); + + const result = await checkCnameTarget('alias.example.com'); + + expect(result).toBe('target.example.com'); + }); +}); + +// --------------------------------------------------------------------------- +// getPrimaryHostname +// --------------------------------------------------------------------------- +describe('getPrimaryHostname', () => { + it('returns primary hostname when one exists', async () => { + mockQueryOne.mockResolvedValueOnce({ hostname: 'www.custom.com' }); + + const result = await getPrimaryHostname(mockDb, 'site-1'); + + expect(result).toBe('www.custom.com'); + expect(mockQueryOne).toHaveBeenCalledWith( + mockDb, + expect.stringContaining('ORDER BY COALESCE(is_primary, 0) DESC'), + ['site-1'], + ); + }); + + it('returns null when no hostnames exist', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getPrimaryHostname(mockDb, 'site-empty'); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/project-sites/src/__tests__/error_handler.test.ts b/apps/project-sites/src/__tests__/error_handler.test.ts new file mode 100644 index 0000000000..7f593fe0dc --- /dev/null +++ b/apps/project-sites/src/__tests__/error_handler.test.ts @@ -0,0 +1,76 @@ +import { AppError, badRequest, unauthorized, notFound, internalError } from '@project-sites/shared'; +import { ZodError, z } from 'zod'; + +describe('AppError integration', () => { + it('serializes to standard error envelope', () => { + const err = badRequest('Invalid input', { field: 'email' }); + const json = err.toJSON(); + + expect(json).toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'Invalid input', + request_id: undefined, + details: { field: 'email' }, + }, + }); + }); + + it('has correct HTTP status codes', () => { + expect(badRequest('test').statusCode).toBe(400); + expect(unauthorized().statusCode).toBe(401); + expect(notFound().statusCode).toBe(404); + expect(internalError().statusCode).toBe(500); + }); + + it('includes request_id when provided', () => { + const err = new AppError({ + code: 'BAD_REQUEST', + message: 'test', + statusCode: 400, + requestId: 'req-abc-123', + }); + expect(err.toJSON().error.request_id).toBe('req-abc-123'); + }); + + it('preserves error cause chain', () => { + const original = new Error('DB connection failed'); + const wrapped = internalError('Service unavailable', original); + expect(wrapped.cause).toBe(original); + }); +}); + +describe('ZodError handling', () => { + it('produces structured validation errors', () => { + const schema = z.object({ + email: z.string().email(), + age: z.number().min(0), + }); + + try { + schema.parse({ email: 'not-email', age: -1 }); + } catch (err) { + expect(err).toBeInstanceOf(ZodError); + const zodErr = err as ZodError; + expect(zodErr.issues.length).toBeGreaterThanOrEqual(2); + expect(zodErr.issues[0]!.path).toContain('email'); + } + }); + + it('can be mapped to API error format', () => { + const schema = z.object({ name: z.string().min(1) }); + + try { + schema.parse({ name: '' }); + } catch (err) { + if (err instanceof ZodError) { + const issues = err.issues.map((i) => ({ + path: i.path.join('.'), + message: i.message, + })); + expect(issues[0]!.path).toBe('name'); + expect(issues[0]!.message).toBeTruthy(); + } + } + }); +}); diff --git a/apps/project-sites/src/__tests__/error_handler_integration.test.ts b/apps/project-sites/src/__tests__/error_handler_integration.test.ts new file mode 100644 index 0000000000..86692a1070 --- /dev/null +++ b/apps/project-sites/src/__tests__/error_handler_integration.test.ts @@ -0,0 +1,268 @@ +jest.mock('../lib/sentry.js', () => ({ + captureError: jest.fn(), + captureMessage: jest.fn(), + createSentry: jest.fn(), +})); + +jest.mock('../lib/posthog.js', () => ({ + capture: jest.fn(), + trackAuth: jest.fn(), + trackSite: jest.fn(), + trackError: jest.fn(), +})); + +import { Hono } from 'hono'; +import { errorHandler } from '../middleware/error_handler.js'; +import { + AppError, + badRequest, + unauthorized, + forbidden, + notFound, + internalError, +} from '@project-sites/shared'; +import { z } from 'zod'; + +/** + * Integration tests for the error handler middleware. + * Creates a real Hono app with the error handler attached and tests + * actual HTTP responses for each error type. + */ + +const createApp = () => { + const app = new Hono<{ Bindings: any; Variables: any }>(); + app.onError(errorHandler); + + // Route that throws a 400 AppError + app.get('/throw-app-error-400', (c) => { + throw badRequest('Bad input'); + }); + + // Route that throws a 500 AppError + app.get('/throw-app-error-500', (c) => { + throw internalError('Server broke'); + }); + + // Route that throws a 401 AppError + app.get('/throw-app-error-401', (c) => { + throw unauthorized('Not allowed'); + }); + + // Route that throws a 403 AppError + app.get('/throw-app-error-403', (c) => { + throw forbidden('No access'); + }); + + // Route that throws a 404 AppError + app.get('/throw-app-error-404', (c) => { + throw notFound('Missing resource'); + }); + + // Route that triggers a ZodError + app.get('/throw-zod-error', (c) => { + z.string().parse(123); + return c.text('ok'); + }); + + // Route that triggers a ZodError with an object schema (multiple issues) + app.get('/throw-zod-error-multi', (c) => { + z.object({ name: z.string(), age: z.number() }).parse({ name: 42, age: 'not-a-number' }); + return c.text('ok'); + }); + + // Route that throws a standard Error + app.get('/throw-unknown', (c) => { + throw new Error('something broke'); + }); + + // Route that throws a non-standard Error (TypeError, not AppError or ZodError) + app.get('/throw-string', () => { + throw new TypeError('type mismatch error'); + }); + + // Route that sets a requestId before throwing + app.get('/throw-with-request-id', (c) => { + c.set('requestId', 'req-xyz-789'); + throw badRequest('Known request'); + }); + + // Healthy route + app.get('/ok', (c) => c.text('ok')); + + return app; +}; + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── AppError handling ───────────────────────────────────────── + +describe('errorHandler - AppError handling', () => { + it('returns correct status code for 400 error', async () => { + const app = createApp(); + const res = await app.request('/throw-app-error-400'); + + expect(res.status).toBe(400); + }); + + it('returns JSON body with code and message', async () => { + const app = createApp(); + const res = await app.request('/throw-app-error-400'); + const body = await res.json(); + + expect(body.error).toBeDefined(); + expect(body.error.code).toBe('BAD_REQUEST'); + expect(body.error.message).toBe('Bad input'); + }); + + it('logs with warn level for 4xx errors', async () => { + const consoleSpy = jest.spyOn(console, 'warn'); + const app = createApp(); + await app.request('/throw-app-error-400'); + + const logCall = consoleSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.code === 'BAD_REQUEST'; + } catch { + return false; + } + }); + expect(logCall).toBeDefined(); + const parsed = JSON.parse(logCall![0] as string); + expect(parsed.level).toBe('warn'); + }); + + it('returns correct status code for 500 error and logs with error level', async () => { + const consoleSpy = jest.spyOn(console, 'warn'); + const app = createApp(); + const res = await app.request('/throw-app-error-500'); + + expect(res.status).toBe(500); + + const logCall = consoleSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.code === 'INTERNAL_ERROR' && parsed.message === 'Server broke'; + } catch { + return false; + } + }); + expect(logCall).toBeDefined(); + const parsed = JSON.parse(logCall![0] as string); + expect(parsed.level).toBe('error'); + }); +}); + +// ─── ZodError handling ───────────────────────────────────────── + +describe('errorHandler - ZodError handling', () => { + it('returns 400 status for validation errors', async () => { + const app = createApp(); + const res = await app.request('/throw-zod-error'); + + expect(res.status).toBe(400); + }); + + it('returns JSON body with VALIDATION_ERROR code', async () => { + const app = createApp(); + const res = await app.request('/throw-zod-error'); + const body = await res.json(); + + expect(body.error).toBeDefined(); + expect(body.error.code).toBe('VALIDATION_ERROR'); + expect(body.error.message).toBe('Request validation failed'); + expect(body.error).toHaveProperty('request_id'); + }); + + it('includes issues array with path and message', async () => { + const app = createApp(); + const res = await app.request('/throw-zod-error-multi'); + const body = await res.json(); + + expect(body.error.details).toBeDefined(); + expect(Array.isArray(body.error.details.issues)).toBe(true); + expect(body.error.details.issues.length).toBeGreaterThanOrEqual(2); + + for (const issue of body.error.details.issues) { + expect(issue).toHaveProperty('path'); + expect(issue).toHaveProperty('message'); + expect(typeof issue.path).toBe('string'); + expect(typeof issue.message).toBe('string'); + } + }); +}); + +// ─── Unknown error handling ──────────────────────────────────── + +describe('errorHandler - Unknown error handling', () => { + it('returns 500 for standard Error', async () => { + const app = createApp(); + const res = await app.request('/throw-unknown'); + + expect(res.status).toBe(500); + }); + + it('returns JSON body with INTERNAL_ERROR code and generic message', async () => { + const app = createApp(); + const res = await app.request('/throw-unknown'); + const body = await res.json(); + + expect(body.error).toBeDefined(); + expect(body.error.code).toBe('INTERNAL_ERROR'); + expect(body.error.message).toBe('Internal server error'); + expect(body.error).toHaveProperty('request_id'); + }); + + it('returns 500 for non-AppError/non-ZodError thrown values (TypeError)', async () => { + const app = createApp(); + const res = await app.request('/throw-string'); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.code).toBe('INTERNAL_ERROR'); + }); +}); + +// ─── General behavior ────────────────────────────────────────── + +describe('errorHandler - General behavior', () => { + it('uses unknown as request_id when not set', async () => { + const app = createApp(); + const res = await app.request('/throw-app-error-400'); + const body = await res.json(); + + expect(body.error.request_id).toBeUndefined(); + + // Verify the console log used 'unknown' as the request_id + const consoleSpy = jest.spyOn(console, 'warn'); + await app.request('/throw-app-error-400'); + const logCall = consoleSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.code === 'BAD_REQUEST'; + } catch { + return false; + } + }); + expect(logCall).toBeDefined(); + const parsed = JSON.parse(logCall![0] as string); + expect(parsed.request_id).toBe('unknown'); + }); + + it('successful routes are not affected by error handler', async () => { + const app = createApp(); + const res = await app.request('/ok'); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toBe('ok'); + }); +}); diff --git a/apps/project-sites/src/__tests__/health_route.test.ts b/apps/project-sites/src/__tests__/health_route.test.ts new file mode 100644 index 0000000000..aefbc4b254 --- /dev/null +++ b/apps/project-sites/src/__tests__/health_route.test.ts @@ -0,0 +1,186 @@ +import { Hono } from 'hono'; +import { health } from '../routes/health.js'; + +/** + * Integration tests for the /health route. + * Mocks KV and R2 bindings passed via Hono's app.request() env parameter. + */ + +const createApp = (envOverrides: Record = {}) => { + const mockEnv = { + ENVIRONMENT: 'test', + CACHE_KV: { get: jest.fn().mockResolvedValue(null) }, + SITES_BUCKET: { head: jest.fn().mockResolvedValue(null) }, + ...envOverrides, + }; + + const app = new Hono<{ Bindings: any; Variables: any }>(); + app.route('/', health); + + return { app, mockEnv }; +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── Happy path ──────────────────────────────────────────────── + +describe('GET /health - happy path', () => { + it('returns 200 status', async () => { + const { app, mockEnv } = createApp(); + const res = await app.request('/health', undefined, mockEnv); + + expect(res.status).toBe(200); + }); + + it('returns status ok when all checks pass', async () => { + const { app, mockEnv } = createApp(); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.status).toBe('ok'); + }); + + it('response includes version, environment, timestamp, latency_ms, and checks', async () => { + const { app, mockEnv } = createApp(); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body).toHaveProperty('version', '0.1.0'); + expect(body).toHaveProperty('environment', 'test'); + expect(body).toHaveProperty('timestamp'); + expect(body).toHaveProperty('latency_ms'); + expect(typeof body.latency_ms).toBe('number'); + expect(body).toHaveProperty('checks'); + expect(body.checks).toHaveProperty('kv'); + expect(body.checks).toHaveProperty('r2'); + }); + + it('kv and r2 checks report ok status with latency_ms', async () => { + const { app, mockEnv } = createApp(); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.checks.kv.status).toBe('ok'); + expect(typeof body.checks.kv.latency_ms).toBe('number'); + expect(body.checks.r2.status).toBe('ok'); + expect(typeof body.checks.r2.latency_ms).toBe('number'); + }); +}); + +// ─── KV failure ──────────────────────────────────────────────── + +describe('GET /health - KV failure', () => { + it('returns degraded when KV throws', async () => { + const { app, mockEnv } = createApp({ + CACHE_KV: { get: jest.fn().mockRejectedValue(new Error('KV is down')) }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.status).toBe('degraded'); + }); + + it('KV check includes error message', async () => { + const { app, mockEnv } = createApp({ + CACHE_KV: { get: jest.fn().mockRejectedValue(new Error('KV connection timeout')) }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.checks.kv.status).toBe('error'); + expect(body.checks.kv.message).toBe('KV connection timeout'); + }); +}); + +// ─── R2 failure ──────────────────────────────────────────────── + +describe('GET /health - R2 failure', () => { + it('returns degraded when R2 throws', async () => { + const { app, mockEnv } = createApp({ + SITES_BUCKET: { head: jest.fn().mockRejectedValue(new Error('R2 is down')) }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.status).toBe('degraded'); + }); + + it('R2 check includes error message', async () => { + const { app, mockEnv } = createApp({ + SITES_BUCKET: { head: jest.fn().mockRejectedValue(new Error('R2 bucket unreachable')) }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.checks.r2.status).toBe('error'); + expect(body.checks.r2.message).toBe('R2 bucket unreachable'); + }); +}); + +// ─── Both fail ───────────────────────────────────────────────── + +describe('GET /health - both KV and R2 fail', () => { + it('returns degraded when both KV and R2 throw', async () => { + const { app, mockEnv } = createApp({ + CACHE_KV: { get: jest.fn().mockRejectedValue(new Error('KV fail')) }, + SITES_BUCKET: { head: jest.fn().mockRejectedValue(new Error('R2 fail')) }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.status).toBe('degraded'); + expect(body.checks.kv.status).toBe('error'); + expect(body.checks.r2.status).toBe('error'); + }); +}); + +// ─── Edge cases ──────────────────────────────────────────────── + +describe('GET /health - edge cases', () => { + it('defaults environment to development when ENVIRONMENT is not set', async () => { + const { app } = createApp(); + // Pass env without ENVIRONMENT key + const envWithoutEnv = { + CACHE_KV: { get: jest.fn().mockResolvedValue(null) }, + SITES_BUCKET: { head: jest.fn().mockResolvedValue(null) }, + }; + const res = await app.request('/health', undefined, envWithoutEnv); + const body = await res.json(); + + expect(body.environment).toBe('development'); + }); + + it('timestamp is a valid ISO 8601 format', async () => { + const { app, mockEnv } = createApp(); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + const parsed = new Date(body.timestamp); + expect(parsed.toISOString()).toBe(body.timestamp); + expect(isNaN(parsed.getTime())).toBe(false); + }); + + it('KV error from non-Error thrown value uses fallback message', async () => { + const { app, mockEnv } = createApp({ + CACHE_KV: { get: jest.fn().mockRejectedValue('string error') }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.checks.kv.status).toBe('error'); + expect(body.checks.kv.message).toBe('KV check failed'); + }); + + it('R2 error from non-Error thrown value uses fallback message', async () => { + const { app, mockEnv } = createApp({ + SITES_BUCKET: { head: jest.fn().mockRejectedValue(42) }, + }); + const res = await app.request('/health', undefined, mockEnv); + const body = await res.json(); + + expect(body.checks.r2.status).toBe('error'); + expect(body.checks.r2.message).toBe('R2 check failed'); + }); +}); diff --git a/apps/project-sites/src/__tests__/meta_tags.test.ts b/apps/project-sites/src/__tests__/meta_tags.test.ts new file mode 100644 index 0000000000..33e58a2ec5 --- /dev/null +++ b/apps/project-sites/src/__tests__/meta_tags.test.ts @@ -0,0 +1,259 @@ +/** + * @module meta_tags.test + * @description Tests for meta tag presence and correctness across all pages, + * including the marketing homepage and color scheme consistency. + * + * Covers: + * - Marketing homepage meta tag completeness + * - Color scheme consistency (megabyte.space brand colors) + * - Top bar accent color consistency + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { generateTopBar } from '../services/site_serving'; + +const PUBLIC_DIR = path.resolve(__dirname, '../../public'); + +// ─── Helper ──────────────────────────────────────────────────── + +function readPublicFile(filename: string): string { + return fs.readFileSync(path.join(PUBLIC_DIR, filename), 'utf-8'); +} + +// ─── Marketing Homepage Meta Tags ────────────────────────────── + +describe('Marketing Homepage Meta Tags', () => { + let html: string; + + beforeAll(() => { + html = readPublicFile('index.html'); + }); + + it('has correct ', () => { + expect(html).toContain('<title>Project Sites - Your Website, Handled. Finally.'); + }); + + it('has meta description', () => { + expect(html).toContain(' { + expect(html).toContain(''); + }); + + it('has meta robots', () => { + expect(html).toContain(''); + }); + + it('has canonical URL', () => { + expect(html).toContain(''); + }); + + // Open Graph + it('has og:site_name', () => { + expect(html).toContain(''); + }); + + it('has og:type', () => { + expect(html).toContain(''); + }); + + it('has og:title', () => { + expect(html).toContain(''); + }); + + it('has og:description', () => { + expect(html).toMatch(//) + }); + + it('has og:image', () => { + expect(html).toContain(''); + }); + + it('has og:url', () => { + expect(html).toContain(''); + }); + + it('has al:web:url', () => { + expect(html).toContain(''); + }); + + // Twitter Card + it('has twitter:card', () => { + expect(html).toContain(''); + }); + + it('has twitter:site', () => { + expect(html).toContain(''); + }); + + it('has twitter:creator', () => { + expect(html).toContain(''); + }); + + it('has twitter:title', () => { + expect(html).toContain(''); + }); + + it('has twitter:description', () => { + expect(html).toMatch(//) + }); + + it('has twitter:image', () => { + expect(html).toContain(''); + }); + + // PWA + it('has manifest', () => { + expect(html).toContain(''); + }); + + it('has mobile-web-app-capable', () => { + expect(html).toContain(''); + }); + + it('has apple-mobile-web-app-capable', () => { + expect(html).toContain(''); + }); + + it('has theme-color', () => { + expect(html).toContain(''); + }); + + // Favicons + it('has favicon.ico', () => { + expect(html).toContain('rel="icon" href="/favicon.ico"'); + }); + + it('has SVG icon', () => { + expect(html).toContain('rel="icon" type="image/svg+xml" href="/logo-icon.svg"'); + }); + + it('has apple-touch-icon', () => { + expect(html).toContain('rel="apple-touch-icon"'); + }); + + // JSON-LD + it('has WebSite JSON-LD', () => { + expect(html).toContain('"@type": "WebSite"'); + expect(html).toContain('"name": "Project Sites"'); + }); + + it('has SoftwareApplication JSON-LD', () => { + expect(html).toContain('"@type": ["SoftwareApplication", "WebApplication"]'); + }); + + it('has Organization JSON-LD', () => { + expect(html).toContain('"@type": "Organization"'); + expect(html).toContain('"name": "Megabyte Labs"'); + }); + + // Font + it('loads Inter font', () => { + expect(html).toContain('fonts.googleapis.com/css2?family=Inter'); + }); + + it('uses Inter as primary font family', () => { + expect(html).toContain("--font: 'Inter'"); + expect(html).toContain('font-family: var(--font)'); + }); + + // Preconnect + it('has preconnect for Google Fonts', () => { + expect(html).toContain(' { + expect(html).toContain('x-posthog-key'); + expect(html).toContain('posthog.init'); + }); + + // Contact form on homepage + it('has contact form section on homepage', () => { + expect(html).toContain('id="contact-section"'); + expect(html).toContain('id="contact-form"'); + expect(html).toContain('id="contact-name"'); + expect(html).toContain('id="contact-email"'); + expect(html).toContain('id="contact-message"'); + }); + + it('has hey@megabyte.space as support email', () => { + expect(html).toContain('hey@megabyte.space'); + }); +}); + +// ─── Brand Color Consistency ─────────────────────────────────── + +describe('Brand Color Consistency', () => { + it('homepage uses #50a5db accent (megabyte.space blue)', () => { + const html = readPublicFile('index.html'); + expect(html).toContain('--accent: #50a5db'); + }); + + it('homepage does not use old accent #64ffda', () => { + const html = readPublicFile('index.html'); + expect(html).not.toContain('#64ffda'); + }); + + it('homepage does not use old green accent #4ade80', () => { + const html = readPublicFile('index.html'); + expect(html).not.toContain('#4ade80'); + }); + + it('homepage accent-dim uses rgba(80, 165, 219, ...)', () => { + const html = readPublicFile('index.html'); + expect(html).toContain('rgba(80, 165, 219, 0.12)'); + }); + + it('homepage accent-glow uses rgba(80, 165, 219, ...)', () => { + const html = readPublicFile('index.html'); + expect(html).toContain('rgba(80, 165, 219, 0.25)'); + }); + + it('homepage keeps dark background #0a0a1a', () => { + const html = readPublicFile('index.html'); + expect(html).toContain('--bg-primary: #0a0a1a'); + }); + + it('homepage keeps secondary color #7c3aed', () => { + const html = readPublicFile('index.html'); + expect(html).toContain('--secondary: #7c3aed'); + }); + + it('top bar uses #50a5db accent', () => { + const topBar = generateTopBar('test-slug'); + expect(topBar).toContain('#50a5db'); + expect(topBar).not.toContain('#64ffda'); + }); +}); + +// ─── Email Template Brand Colors ─────────────────────────────── + +describe('Email Template Brand Colors', () => { + it('auth magic link email uses #50a5db accent', () => { + // Read the auth service to verify color usage + const authTs = fs.readFileSync( + path.resolve(__dirname, '../services/auth.ts'), + 'utf-8', + ); + expect(authTs).toContain('#50a5db'); + expect(authTs).not.toContain('#64ffda'); + }); + + it('contact email templates use #50a5db accent', () => { + const contactTs = fs.readFileSync( + path.resolve(__dirname, '../services/contact.ts'), + 'utf-8', + ); + expect(contactTs).toContain('#50a5db'); + expect(contactTs).not.toContain('#64ffda'); + }); +}); diff --git a/apps/project-sites/src/__tests__/middleware.test.ts b/apps/project-sites/src/__tests__/middleware.test.ts new file mode 100644 index 0000000000..0d7656e393 --- /dev/null +++ b/apps/project-sites/src/__tests__/middleware.test.ts @@ -0,0 +1,198 @@ +import { Hono } from 'hono'; +import { requestIdMiddleware } from '../middleware/request_id.js'; +import { payloadLimitMiddleware } from '../middleware/payload_limit.js'; +import { securityHeadersMiddleware } from '../middleware/security_headers.js'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── requestIdMiddleware ───────────────────────────────────── + +describe('requestIdMiddleware', () => { + function createApp() { + const app = new Hono<{ Bindings: any; Variables: any }>(); + app.use('*', requestIdMiddleware); + app.get('/test', (c) => c.text('ok')); + return app; + } + + it('sets x-request-id response header', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('x-request-id')).toBeTruthy(); + }); + + it('uses provided x-request-id from request', async () => { + const app = createApp(); + const customId = 'my-custom-request-id-123'; + const res = await app.request('/test', { + headers: { 'x-request-id': customId }, + }); + + expect(res.headers.get('x-request-id')).toBe(customId); + }); + + it('generates UUID when no x-request-id provided', async () => { + const app = createApp(); + const res = await app.request('/test'); + + const id = res.headers.get('x-request-id'); + expect(id).toBeTruthy(); + expect(id).not.toBe(''); + }); + + it('generated ID is valid UUID format', async () => { + const app = createApp(); + const res = await app.request('/test'); + + const id = res.headers.get('x-request-id'); + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + expect(id).toMatch(uuidRegex); + }); +}); + +// ─── payloadLimitMiddleware ────────────────────────────────── + +describe('payloadLimitMiddleware', () => { + function createApp() { + const app = new Hono<{ Bindings: any; Variables: any }>(); + app.use('*', payloadLimitMiddleware); + app.get('/test', (c) => c.text('ok')); + app.post('/test', (c) => c.text('ok')); + return app; + } + + it('allows requests under size limit', async () => { + const app = createApp(); + const res = await app.request('/test', { + method: 'POST', + headers: { 'content-length': '1024' }, + }); + + expect(res.status).toBe(200); + }); + + it('allows requests without content-length', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.status).toBe(200); + }); + + it('throws payloadTooLarge for oversized requests', async () => { + const app = createApp(); + app.onError((err, c) => { + const status = (err as any).statusCode || 500; + return c.json({ error: (err as any).message || 'error' }, status); + }); + + const res = await app.request('/test', { + method: 'POST', + headers: { 'content-length': '999999999' }, + }); + + expect(res.status).toBe(413); + }); + + it('allows exact limit size', async () => { + const app = createApp(); + // DEFAULT_CAPS.MAX_REQUEST_BODY_BYTES = 262144 (256 * 1024) + const res = await app.request('/test', { + method: 'POST', + headers: { 'content-length': '262144' }, + }); + + expect(res.status).toBe(200); + }); + + it('handles non-numeric content-length', async () => { + const app = createApp(); + const res = await app.request('/test', { + method: 'POST', + headers: { 'content-length': 'not-a-number' }, + }); + + // parseInt('not-a-number', 10) is NaN, so the check is skipped + expect(res.status).toBe(200); + }); +}); + +// ─── securityHeadersMiddleware ─────────────────────────────── + +describe('securityHeadersMiddleware', () => { + function createApp() { + const app = new Hono<{ Bindings: any; Variables: any }>(); + app.use('*', securityHeadersMiddleware); + app.get('/test', (c) => c.text('ok')); + return app; + } + + it('sets Strict-Transport-Security header', async () => { + const app = createApp(); + const res = await app.request('/test'); + + const hsts = res.headers.get('Strict-Transport-Security'); + expect(hsts).toBe('max-age=63072000; includeSubDomains; preload'); + }); + + it('sets X-Content-Type-Options to nosniff', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff'); + }); + + it('sets X-Frame-Options to DENY', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('X-Frame-Options')).toBe('DENY'); + }); + + it('sets Referrer-Policy', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); + }); + + it('sets Permissions-Policy', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('Permissions-Policy')).toBe('camera=(), microphone=(), geolocation=self'); + }); + + it('sets Cross-Origin-Opener-Policy header', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('Cross-Origin-Opener-Policy')).toBe('same-origin'); + }); + + it('sets Cross-Origin-Embedder-Policy header', async () => { + const app = createApp(); + const res = await app.request('/test'); + + expect(res.headers.get('Cross-Origin-Embedder-Policy')).toBe('credentialless'); + }); + + it('sets Content-Security-Policy with correct directives', async () => { + const app = createApp(); + const res = await app.request('/test'); + + const csp = res.headers.get('Content-Security-Policy'); + expect(csp).toBeTruthy(); + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self' 'unsafe-inline' https://releases.transloadit.com https://js.stripe.com"); + expect(csp).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://releases.transloadit.com"); + expect(csp).toContain("img-src 'self' data: https:"); + expect(csp).toContain("font-src 'self' https://fonts.gstatic.com"); + expect(csp).toContain("connect-src 'self' https://api.stripe.com https://us.i.posthog.com"); + expect(csp).toContain('frame-src https://js.stripe.com'); + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("base-uri 'self'"); + }); +}); diff --git a/apps/project-sites/src/__tests__/prompt_eval.test.ts b/apps/project-sites/src/__tests__/prompt_eval.test.ts new file mode 100644 index 0000000000..d41b594f60 --- /dev/null +++ b/apps/project-sites/src/__tests__/prompt_eval.test.ts @@ -0,0 +1,458 @@ +/** + * Prompt evaluation suite — lightweight tests that validate prompt quality + * without calling an LLM. + * + * For each registered prompt: + * 1. Resolve the prompt from the registry + * 2. Validate fixture inputs pass Zod schema + * 3. Render with fixture inputs and verify: + * - System prompt is non-empty + * - User prompt contains the input values + * - No unresolved {{placeholders}} remain + * 4. Validate template placeholders match declared inputs + */ + +import { registerAllPrompts } from '../services/ai_workflows.js'; +import { clearRegistry, resolve, listAll } from '../prompts/registry.js'; +import { renderPrompt } from '../prompts/renderer.js'; +import { validateTemplatePlaceholders } from '../prompts/renderer.js'; +import { validatePromptInput } from '../prompts/schemas.js'; +import type { PromptSpec } from '../prompts/types.js'; + +// ─── Fixtures ──────────────────────────────────────────────────── + +const RESEARCH_BUSINESS_FIXTURES = [ + { business_name: "Mario's Ristorante" }, + { + business_name: 'Quick Fix Plumbing', + business_phone: '555-1234', + business_address: '100 Main St, Denver, CO', + }, + { + business_name: 'Grace Community Church', + additional_context: 'Non-denominational, Sunday services at 9am and 11am', + }, + { business_name: 'Bright Smile Dentistry', google_place_id: 'ChIJN1t_tDeuEmsRUsoyG83frY4' }, + { business_name: 'Serenity Yoga Studio', business_address: '42 Lotus Lane, Austin, TX' }, + { + business_name: 'Precision Auto Repair', + business_phone: '555-9876', + additional_context: 'Specializes in European imports', + }, + { business_name: 'Chen & Associates Law Firm', business_address: '500 Justice Blvd, Suite 200' }, + { + business_name: 'Sweet Crumbs Bakery', + additional_context: 'Gluten-free options available, custom cakes', + }, + { business_name: 'Glamour Nails & Spa', business_phone: '555-4567' }, + { business_name: 'Happy Tails Dog Grooming', business_address: '88 Bark Ave, Portland, OR' }, +]; + +const MOCK_RESEARCH_DATA = JSON.stringify({ + business_name: "Mario's Ristorante", + tagline: 'Authentic Italian dining since 1985', + description: + 'A family-owned Italian restaurant serving traditional recipes passed down through generations.', + services: ['Dine-in', 'Takeout', 'Catering', 'Private Events'], + hours: [ + { day: 'Monday-Thursday', hours: '11am-9pm' }, + { day: 'Friday-Saturday', hours: '11am-10pm' }, + { day: 'Sunday', hours: '12pm-8pm' }, + ], + faq: [ + { question: 'Do you accept reservations?', answer: 'Yes, call us or book online.' }, + { question: 'Is there parking available?', answer: 'Free parking lot behind the building.' }, + { + question: 'Do you offer gluten-free options?', + answer: 'Yes, ask your server for our GF menu.', + }, + ], + seo_title: "Mario's Ristorante - Authentic Italian Dining", + seo_description: + 'Family-owned Italian restaurant serving traditional recipes. Dine-in, takeout, catering available.', +}); + +const GENERATE_SITE_FIXTURES = [ + { research_data: MOCK_RESEARCH_DATA }, + { + research_data: JSON.stringify({ + business_name: 'Quick Fix Plumbing', + tagline: 'Fast, reliable plumbing solutions', + description: 'Professional plumbing services for residential and commercial customers.', + services: ['Emergency Repairs', 'Drain Cleaning', 'Water Heater Installation'], + hours: [ + { day: 'Monday-Friday', hours: '7am-6pm' }, + { day: 'Saturday', hours: '8am-2pm' }, + ], + faq: [ + { + question: 'Do you offer emergency service?', + answer: 'Yes, 24/7 emergency calls available.', + }, + { question: 'Are you licensed?', answer: 'Fully licensed and insured.' }, + { question: 'Do you give free estimates?', answer: 'Yes, for all non-emergency work.' }, + ], + seo_title: 'Quick Fix Plumbing - Fast Reliable Service', + seo_description: + 'Professional plumbing for homes and businesses. Emergency service available 24/7.', + }), + }, + { + research_data: JSON.stringify({ + business_name: 'Bright Smile Dentistry', + tagline: 'Your smile, our passion', + description: 'Comprehensive dental care for the whole family.', + services: ['Cleanings', 'Fillings', 'Whitening', 'Invisalign', 'Implants'], + hours: [{ day: 'Monday-Friday', hours: '8am-5pm' }], + faq: [ + { question: 'Do you accept insurance?', answer: 'We accept most major dental plans.' }, + { question: 'Is there a cancellation fee?', answer: 'Please provide 24 hours notice.' }, + { question: 'Do you see children?', answer: 'Yes, we welcome patients of all ages.' }, + ], + seo_title: 'Bright Smile Dentistry - Family Dental Care', + seo_description: + 'Comprehensive dental care for the whole family. Cleanings, whitening, Invisalign and more.', + }), + }, +]; + +const SCORE_QUALITY_FIXTURES = [ + { + html_content: + 'Test

Hello

', + }, + { + html_content: + 'Bakery

Sweet Crumbs

Fresh baked daily

', + }, + { + html_content: + '

Welcome

Our Services

  • Service A
', + }, + { + html_content: + 'Law Firm

Chen & Associates

', + }, + { + html_content: + 'Yoga Studio

Find Your Peace

Book Now
', + }, +]; + +const SITE_COPY_FIXTURES = [ + { + businessName: "Mario's Ristorante", + city: 'Boston', + services: ['Dine-in', 'Catering'], + tone: 'friendly' as const, + }, + { + businessName: 'Quick Fix Plumbing', + city: 'Denver', + services: ['Emergency Repairs', 'Drain Cleaning'], + tone: 'no-nonsense' as const, + }, + { + businessName: 'Bright Smile Dentistry', + city: 'Austin', + services: ['Cleanings', 'Whitening'], + tone: 'premium' as const, + }, + { + businessName: 'Serenity Yoga Studio', + city: 'Portland', + services: ['Vinyasa', 'Meditation', 'Prenatal Yoga'], + tone: 'friendly' as const, + }, + { + businessName: 'Chen & Associates', + city: 'Chicago', + services: ['Business Law', 'Estate Planning'], + tone: 'premium' as const, + }, + { + businessName: 'Happy Tails Grooming', + city: 'Seattle', + services: ['Baths', 'Haircuts', 'Nail Trimming'], + tone: 'friendly' as const, + }, + { + businessName: 'Precision Auto Repair', + city: 'Dallas', + services: ['Oil Change', 'Brake Service', 'Engine Diagnostics'], + tone: 'no-nonsense' as const, + }, + { + businessName: 'Sweet Crumbs Bakery', + city: 'San Francisco', + services: ['Custom Cakes', 'Pastries', 'Bread'], + tone: 'friendly' as const, + }, +]; + +// ─── Helpers ───────────────────────────────────────────────────── + +/** Check that a rendered string has no unresolved {{placeholder}} patterns. */ +function hasNoUnresolvedPlaceholders(text: string): boolean { + return !/\{\{\w+\}\}/.test(text); +} + +// ─── Test Suite ────────────────────────────────────────────────── + +beforeEach(() => { + clearRegistry(); + registerAllPrompts(); +}); + +describe('prompt eval: registry integrity', () => { + it('registers all expected prompt IDs', () => { + const allSpecs = listAll(); + const ids = new Set(allSpecs.map((s) => s.id)); + + expect(ids).toContain('research_business'); + expect(ids).toContain('generate_site'); + expect(ids).toContain('score_quality'); + expect(ids).toContain('site_copy'); + }); + + it('registers the correct number of prompt specs (including variants)', () => { + const allSpecs = listAll(); + // 5 legacy + 8 v2 workflow prompts = 13 total + expect(allSpecs.length).toBe(13); + }); + + it('every registered prompt has valid template placeholders', () => { + const allSpecs = listAll(); + + for (const spec of allSpecs) { + const undeclared = validateTemplatePlaceholders(spec); + expect(undeclared).toEqual([]); + } + }); +}); + +describe('prompt eval: research_business', () => { + let spec: PromptSpec; + + beforeEach(() => { + spec = resolve('research_business', 2)!; + }); + + it('resolves from registry at version 2', () => { + expect(spec).toBeDefined(); + expect(spec.id).toBe('research_business'); + expect(spec.version).toBe(2); + }); + + it('has non-empty system and user templates', () => { + expect(spec.system.length).toBeGreaterThan(0); + expect(spec.user.length).toBeGreaterThan(0); + }); + + it('system prompt mentions JSON output format', () => { + expect(spec.system).toContain('JSON'); + }); + + it.each(RESEARCH_BUSINESS_FIXTURES)('validates fixture input: $business_name', (fixture) => { + const validated = validatePromptInput('research_business', fixture); + expect(validated).toHaveProperty('business_name'); + }); + + it.each(RESEARCH_BUSINESS_FIXTURES)( + 'renders without unresolved placeholders: $business_name', + (fixture) => { + const validated = validatePromptInput('research_business', fixture) as Record< + string, + unknown + >; + const stringInputs: Record = {}; + for (const [k, v] of Object.entries(validated)) { + stringInputs[k] = String(v ?? ''); + } + + const rendered = renderPrompt(spec, stringInputs, { safeDelimit: false }); + + expect(rendered.system.length).toBeGreaterThan(0); + expect(rendered.user).toContain(String(fixture.business_name)); + expect(hasNoUnresolvedPlaceholders(rendered.system)).toBe(true); + expect(hasNoUnresolvedPlaceholders(rendered.user)).toBe(true); + }, + ); + + it('rejects empty business_name', () => { + expect(() => validatePromptInput('research_business', { business_name: '' })).toThrow(); + }); + + it('rejects missing business_name', () => { + expect(() => validatePromptInput('research_business', {})).toThrow(); + }); +}); + +describe('prompt eval: generate_site', () => { + let spec: PromptSpec; + + beforeEach(() => { + spec = resolve('generate_site', 2)!; + }); + + it('resolves from registry at version 2', () => { + expect(spec).toBeDefined(); + expect(spec.id).toBe('generate_site'); + expect(spec.version).toBe(2); + }); + + it('system prompt mentions HTML and DOCTYPE', () => { + expect(spec.system).toContain('HTML'); + expect(spec.system).toContain('DOCTYPE'); + }); + + it.each(GENERATE_SITE_FIXTURES)('validates and renders fixture input #%#', (fixture) => { + const validated = validatePromptInput('generate_site', fixture) as Record; + const stringInputs: Record = {}; + for (const [k, v] of Object.entries(validated)) { + stringInputs[k] = String(v ?? ''); + } + + const rendered = renderPrompt(spec, stringInputs, { safeDelimit: false }); + + expect(rendered.system.length).toBeGreaterThan(0); + expect(rendered.user).toContain('business_name'); + expect(hasNoUnresolvedPlaceholders(rendered.system)).toBe(true); + expect(hasNoUnresolvedPlaceholders(rendered.user)).toBe(true); + }); + + it('rejects empty research_data', () => { + expect(() => validatePromptInput('generate_site', { research_data: '' })).toThrow(); + }); +}); + +describe('prompt eval: score_quality', () => { + let spec: PromptSpec; + + beforeEach(() => { + spec = resolve('score_quality', 2)!; + }); + + it('resolves from registry at version 2', () => { + expect(spec).toBeDefined(); + expect(spec.id).toBe('score_quality'); + expect(spec.version).toBe(2); + }); + + it('system prompt mentions scoring dimensions', () => { + expect(spec.system).toContain('accuracy'); + expect(spec.system).toContain('completeness'); + expect(spec.system).toContain('seo'); + }); + + it.each(SCORE_QUALITY_FIXTURES)('validates and renders HTML fixture #%#', (fixture) => { + const validated = validatePromptInput('score_quality', fixture) as Record; + const stringInputs: Record = {}; + for (const [k, v] of Object.entries(validated)) { + stringInputs[k] = String(v ?? ''); + } + + const rendered = renderPrompt(spec, stringInputs, { safeDelimit: false }); + + expect(rendered.system.length).toBeGreaterThan(0); + expect(rendered.user).toContain(fixture.html_content); + expect(hasNoUnresolvedPlaceholders(rendered.system)).toBe(true); + expect(hasNoUnresolvedPlaceholders(rendered.user)).toBe(true); + }); + + it('rejects empty html_content', () => { + expect(() => validatePromptInput('score_quality', { html_content: '' })).toThrow(); + }); +}); + +describe('prompt eval: site_copy', () => { + let spec: PromptSpec; + + beforeEach(() => { + spec = resolve('site_copy', 3)!; + }); + + it('resolves from registry at version 3', () => { + expect(spec).toBeDefined(); + expect(spec.id).toBe('site_copy'); + expect(spec.version).toBe(3); + }); + + it('system prompt mentions tone guide', () => { + expect(spec.system).toContain('friendly'); + expect(spec.system).toContain('premium'); + expect(spec.system).toContain('no-nonsense'); + }); + + it.each(SITE_COPY_FIXTURES)( + 'validates and renders fixture: $businessName in $city ($tone)', + (fixture) => { + const validated = validatePromptInput('site_copy', fixture) as Record; + const stringInputs: Record = {}; + for (const [k, v] of Object.entries(validated)) { + stringInputs[k] = Array.isArray(v) ? v.join(', ') : String(v ?? ''); + } + + const rendered = renderPrompt(spec, stringInputs, { safeDelimit: false }); + + expect(rendered.system.length).toBeGreaterThan(0); + expect(rendered.user).toContain(fixture.businessName); + expect(rendered.user).toContain(fixture.city); + expect(rendered.user).toContain(fixture.tone); + expect(hasNoUnresolvedPlaceholders(rendered.system)).toBe(true); + expect(hasNoUnresolvedPlaceholders(rendered.user)).toBe(true); + }, + ); + + it('rejects missing required fields', () => { + expect(() => validatePromptInput('site_copy', { businessName: 'Test' })).toThrow(); + }); + + it('rejects invalid tone value', () => { + expect(() => + validatePromptInput('site_copy', { + businessName: 'Test', + city: 'NYC', + services: [], + tone: 'aggressive', + }), + ).toThrow(); + }); +}); + +describe('prompt eval: site_copy variant b', () => { + it('has a variant b registered with benefit-led instructions', () => { + const allSpecs = listAll(); + const variantB = allSpecs.find((s) => s.id === 'site_copy' && s.variant === 'b'); + + expect(variantB).toBeDefined(); + expect(variantB!.system).toContain('benefit'); + expect(variantB!.system).toContain('BENEFIT'); + }); + + it('variant b renders correctly with fixture inputs', () => { + const allSpecs = listAll(); + const variantB = allSpecs.find((s) => s.id === 'site_copy' && s.variant === 'b')!; + const fixture = SITE_COPY_FIXTURES[0]; + const validated = validatePromptInput('site_copy', fixture) as Record; + const stringInputs: Record = {}; + for (const [k, v] of Object.entries(validated)) { + stringInputs[k] = Array.isArray(v) ? v.join(', ') : String(v ?? ''); + } + + const rendered = renderPrompt(variantB, stringInputs, { safeDelimit: false }); + + expect(rendered.system.length).toBeGreaterThan(0); + expect(rendered.user).toContain(fixture.businessName); + expect(hasNoUnresolvedPlaceholders(rendered.system)).toBe(true); + expect(hasNoUnresolvedPlaceholders(rendered.user)).toBe(true); + }); + + it('variant b has valid template placeholders', () => { + const allSpecs = listAll(); + const variantB = allSpecs.find((s) => s.id === 'site_copy' && s.variant === 'b')!; + const undeclared = validateTemplatePlaceholders(variantB); + + expect(undeclared).toEqual([]); + }); +}); diff --git a/apps/project-sites/src/__tests__/prompt_observability.test.ts b/apps/project-sites/src/__tests__/prompt_observability.test.ts new file mode 100644 index 0000000000..b6ec43de17 --- /dev/null +++ b/apps/project-sites/src/__tests__/prompt_observability.test.ts @@ -0,0 +1,249 @@ +import { webcrypto } from 'node:crypto'; +if (!globalThis.crypto?.subtle) { + (globalThis as any).crypto = webcrypto; +} + +import { + sha256, + hashInputs, + buildCallLog, + emitCallLog, + estimateCost, + withObservability, +} from '../prompts/observability.js'; +import type { LlmCallLog } from '../prompts/types.js'; +import type { PromptSpec } from '../prompts/types.js'; + +/** Minimal PromptSpec fixture used across tests. */ +function makeSpec(overrides: Partial = {}): PromptSpec { + return { + id: 'test_prompt', + version: 1, + description: 'Test prompt', + models: ['gpt-4o'], + params: { temperature: 0.7, maxTokens: 1024 }, + inputs: { required: ['query'], optional: [] }, + outputs: { format: 'text' }, + notes: {}, + system: 'You are a test assistant.', + user: '{{query}}', + ...overrides, + }; +} + +// ── sha256 ─────────────────────────────────────────────────── + +describe('sha256', () => { + it('returns a 64-character hex string', async () => { + const hash = await sha256('hello'); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('returns the same hash for the same input', async () => { + const a = await sha256('deterministic'); + const b = await sha256('deterministic'); + expect(a).toBe(b); + }); + + it('returns different hashes for different inputs', async () => { + const a = await sha256('input-a'); + const b = await sha256('input-b'); + expect(a).not.toBe(b); + }); +}); + +// ── hashInputs ─────────────────────────────────────────────── + +describe('hashInputs', () => { + it('returns consistent hash regardless of key order', async () => { + const a = await hashInputs({ x: 1, y: 2 }); + const b = await hashInputs({ y: 2, x: 1 }); + expect(a).toBe(b); + }); + + it('returns different hashes for different inputs', async () => { + const a = await hashInputs({ name: 'alice' }); + const b = await hashInputs({ name: 'bob' }); + expect(a).not.toBe(b); + }); +}); + +// ── buildCallLog ───────────────────────────────────────────── + +describe('buildCallLog', () => { + it('returns a correct LlmCallLog structure with all fields', () => { + const spec = makeSpec({ variant: 'a' }); + const log = buildCallLog({ + spec, + model: 'gpt-4o', + inputHash: 'abc123', + latencyMs: 250, + tokenCount: 500, + cost: 0.005, + outcome: 'success', + retryCount: 0, + }); + + expect(log).toMatchObject({ + promptId: 'test_prompt', + promptVersion: 1, + promptVariant: 'a', + model: 'gpt-4o', + params: { temperature: 0.7, maxTokens: 1024 }, + inputHash: 'abc123', + latencyMs: 250, + tokenCount: 500, + cost: 0.005, + outcome: 'success', + retryCount: 0, + }); + expect(log.errorMessage).toBeUndefined(); + expect(log.timestamp).toBeDefined(); + expect(new Date(log.timestamp).toISOString()).toBe(log.timestamp); + }); + + it('includes errorMessage when provided', () => { + const log = buildCallLog({ + spec: makeSpec(), + model: 'gpt-4o', + inputHash: 'xyz', + latencyMs: 100, + tokenCount: 0, + outcome: 'error', + retryCount: 2, + errorMessage: 'rate limited', + }); + + expect(log.outcome).toBe('error'); + expect(log.errorMessage).toBe('rate limited'); + expect(log.retryCount).toBe(2); + }); +}); + +// ── emitCallLog ────────────────────────────────────────────── + +describe('emitCallLog', () => { + it('calls console.warn with JSON string containing all log fields', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const log: LlmCallLog = { + promptId: 'test_prompt', + promptVersion: 1, + model: 'gpt-4o', + params: { temperature: 0.7, maxTokens: 1024 }, + inputHash: 'abc123', + latencyMs: 200, + tokenCount: 100, + outcome: 'success', + retryCount: 0, + timestamp: '2025-01-01T00:00:00.000Z', + }; + + emitCallLog(log); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const emitted = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(emitted.level).toBe('info'); + expect(emitted.service).toBe('ai_workflow'); + expect(emitted.event).toBe('llm_call'); + expect(emitted.promptId).toBe('test_prompt'); + expect(emitted.model).toBe('gpt-4o'); + expect(emitted.latencyMs).toBe(200); + expect(emitted.outcome).toBe('success'); + + consoleSpy.mockRestore(); + }); +}); + +// ── estimateCost ───────────────────────────────────────────── + +describe('estimateCost', () => { + it('returns 0 for free Workers AI models', () => { + expect(estimateCost('@cf/meta/llama-3.1-8b-instruct', 1000, 500)).toBe(0); + expect(estimateCost('@cf/meta/llama-3.1-70b-instruct', 2000, 1000)).toBe(0); + }); + + it('returns correct cost for gpt-4o', () => { + // gpt-4o: input $0.0025/1k, output $0.01/1k + const cost = estimateCost('gpt-4o', 1000, 1000); + expect(cost).toBeCloseTo(0.0025 + 0.01, 6); + }); + + it('returns correct cost for gpt-4o-mini', () => { + // gpt-4o-mini: input $0.00015/1k, output $0.0006/1k + const cost = estimateCost('gpt-4o-mini', 2000, 500); + const expected = (2000 / 1000) * 0.00015 + (500 / 1000) * 0.0006; + expect(cost).toBeCloseTo(expected, 6); + }); + + it('returns 0 for unknown models', () => { + expect(estimateCost('some-unknown-model', 5000, 3000)).toBe(0); + }); +}); + +// ── withObservability ──────────────────────────────────────── + +describe('withObservability', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('returns result and log on success, and emits the log', async () => { + const spec = makeSpec(); + const inputs = { query: 'hello' }; + + const { result, log } = await withObservability(spec, 'gpt-4o', inputs, 0, async () => ({ + output: 'world', + tokenCount: 42, + })); + + expect(result).toBe('world'); + expect(log.outcome).toBe('success'); + expect(log.tokenCount).toBe(42); + expect(log.retryCount).toBe(0); + expect(log.promptId).toBe('test_prompt'); + expect(log.model).toBe('gpt-4o'); + + // Verify emitCallLog was called + expect(consoleSpy).toHaveBeenCalledTimes(1); + const emitted = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(emitted.outcome).toBe('success'); + }); + + it('throws and emits error log on failure', async () => { + const spec = makeSpec(); + const inputs = { query: 'fail' }; + + await expect( + withObservability(spec, 'gpt-4o', inputs, 1, async () => { + throw new Error('LLM timeout'); + }), + ).rejects.toThrow('LLM timeout'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const emitted = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(emitted.outcome).toBe('error'); + expect(emitted.errorMessage).toBe('LLM timeout'); + expect(emitted.retryCount).toBe(1); + expect(emitted.tokenCount).toBe(0); + }); + + it('records latencyMs > 0', async () => { + const spec = makeSpec(); + const inputs = { query: 'timing' }; + + const { log } = await withObservability(spec, 'gpt-4o', inputs, 0, async () => { + // Small delay to ensure measurable latency + await new Promise((resolve) => setTimeout(resolve, 10)); + return { output: 'done', tokenCount: 10 }; + }); + + expect(log.latencyMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/project-sites/src/__tests__/prompt_parser.test.ts b/apps/project-sites/src/__tests__/prompt_parser.test.ts new file mode 100644 index 0000000000..95f458a9e5 --- /dev/null +++ b/apps/project-sites/src/__tests__/prompt_parser.test.ts @@ -0,0 +1,411 @@ +import { + parsePromptMarkdown, + extractFrontmatter, + splitSections, + parseSimpleYaml, + parseYamlScalar, +} from '../prompts/parser.js'; + +// ─── parsePromptMarkdown ─────────────────────────────────────── + +describe('parsePromptMarkdown', () => { + it('parses a full valid prompt markdown into PromptSpec', () => { + const raw = `--- +id: research_business +version: 2 +description: Research a local business +models: + - "@cf/meta/llama-3.1-70b-instruct" + - "@cf/openai/gpt-4o" +params: + temperature: 0.3 + max_tokens: 4096 +inputs: + required: [business_name, city] + optional: [phone] +outputs: + format: json + schema: business_schema_v1 +notes: + pii: "Avoid customer personal data" + quality: "Cross-reference two sources" +--- + +# System +You are a business research assistant. + +# User +Research the following business: {{business_name}} in {{city}}. +`; + + const spec = parsePromptMarkdown(raw); + + expect(spec.id).toBe('research_business'); + expect(spec.version).toBe(2); + expect(spec.variant).toBeUndefined(); + expect(spec.description).toBe('Research a local business'); + expect(spec.models).toEqual(['@cf/meta/llama-3.1-70b-instruct', '@cf/openai/gpt-4o']); + expect(spec.params).toEqual({ temperature: 0.3, maxTokens: 4096 }); + expect(spec.inputs).toEqual({ + required: ['business_name', 'city'], + optional: ['phone'], + }); + expect(spec.outputs).toEqual({ format: 'json', schema: 'business_schema_v1' }); + expect(spec.notes).toEqual({ + pii: 'Avoid customer personal data', + quality: 'Cross-reference two sources', + }); + expect(spec.system).toBe('You are a business research assistant.'); + expect(spec.user).toBe('Research the following business: {{business_name}} in {{city}}.'); + }); + + it('parses a prompt with a variant field', () => { + const raw = `--- +id: summarize_article +version: 1 +variant: b +description: Summarize an article concisely +models: ["@cf/meta/llama-3.1-70b-instruct"] +params: + temperature: 0.5 + max_tokens: 2048 +inputs: + required: [article_text] +outputs: + format: markdown +--- + +# System +You are a summarization expert. + +# User +Summarize this article: {{article_text}} +`; + + const spec = parsePromptMarkdown(raw); + + expect(spec.id).toBe('summarize_article'); + expect(spec.version).toBe(1); + expect(spec.variant).toBe('b'); + expect(spec.description).toBe('Summarize an article concisely'); + expect(spec.models).toEqual(['@cf/meta/llama-3.1-70b-instruct']); + expect(spec.params).toEqual({ temperature: 0.5, maxTokens: 2048 }); + expect(spec.inputs.required).toEqual(['article_text']); + expect(spec.inputs.optional).toEqual([]); + expect(spec.outputs).toEqual({ format: 'markdown', schema: undefined }); + }); + + it('handles minimal frontmatter with defaults for optional fields', () => { + const raw = `--- +id: simple_prompt +version: 1 +--- + +# System +Do the thing. + +# User +Hello. +`; + + const spec = parsePromptMarkdown(raw); + + expect(spec.id).toBe('simple_prompt'); + expect(spec.version).toBe(1); + expect(spec.variant).toBeUndefined(); + expect(spec.description).toBe(''); + expect(spec.models).toEqual([]); + expect(spec.params).toEqual({ temperature: 0.3, maxTokens: 4096 }); + expect(spec.inputs).toEqual({ required: [], optional: [] }); + expect(spec.outputs).toEqual({ format: 'text', schema: undefined }); + expect(spec.notes).toEqual({}); + expect(spec.system).toBe('Do the thing.'); + expect(spec.user).toBe('Hello.'); + }); + + it('throws when id is missing from frontmatter', () => { + const raw = `--- +version: 1 +--- + +# System +Sys. + +# User +Usr. +`; + + expect(() => parsePromptMarkdown(raw)).toThrow('Prompt frontmatter missing required field: id'); + }); + + it('throws when version is missing from frontmatter', () => { + const raw = `--- +id: no_version +--- + +# System +Sys. + +# User +Usr. +`; + + expect(() => parsePromptMarkdown(raw)).toThrow( + 'Prompt frontmatter missing required field: version', + ); + }); +}); + +// ─── extractFrontmatter ──────────────────────────────────────── + +describe('extractFrontmatter', () => { + it('extracts frontmatter and body from valid input', () => { + const raw = `--- +id: test +version: 1 +--- + +# System +Hello world. +`; + + const result = extractFrontmatter(raw); + + expect(result.frontmatter).toBe('id: test\nversion: 1'); + expect(result.body).toBe('# System\nHello world.'); + }); + + it('throws when the opening --- is missing', () => { + const raw = `id: test +version: 1 +--- + +# System +Body here. +`; + + expect(() => extractFrontmatter(raw)).toThrow( + 'Prompt file must start with YAML frontmatter (---)', + ); + }); + + it('throws when the closing --- is missing', () => { + const raw = `--- +id: test +version: 1 + +# System +Body here. +`; + + expect(() => extractFrontmatter(raw)).toThrow('Prompt file has unclosed YAML frontmatter'); + }); + + it('returns an empty body when there is only frontmatter', () => { + const raw = `--- +id: test +version: 1 +---`; + + const result = extractFrontmatter(raw); + + expect(result.frontmatter).toBe('id: test\nversion: 1'); + expect(result.body).toBe(''); + }); +}); + +// ─── splitSections ───────────────────────────────────────────── + +describe('splitSections', () => { + it('splits body into system and user sections', () => { + const body = `# System +You are helpful. + +# User +What is 2+2?`; + + const result = splitSections(body); + + expect(result.system).toBe('You are helpful.'); + expect(result.user).toBe('What is 2+2?'); + }); + + it('throws when # System section is missing', () => { + const body = `# User +What is 2+2?`; + + expect(() => splitSections(body)).toThrow('Prompt body must contain a "# System" section'); + }); + + it('throws when # User section is missing', () => { + const body = `# System +You are helpful.`; + + expect(() => splitSections(body)).toThrow('Prompt body must contain a "# User" section'); + }); + + it('ignores extra sections after # User', () => { + const body = `# System +System content here. + +# User +User content here. + +# Notes +This section should be part of user content.`; + + const result = splitSections(body); + + expect(result.system).toBe('System content here.'); + expect(result.user).toBe( + 'User content here.\n\n# Notes\nThis section should be part of user content.', + ); + }); +}); + +// ─── parseSimpleYaml ─────────────────────────────────────────── + +describe('parseSimpleYaml', () => { + it('parses scalar values (string, number, boolean, null, quoted)', () => { + const yaml = `name: hello +count: 42 +pi: 3.14 +enabled: true +disabled: false +nothing: null +tilde: ~ +quoted_double: "hello world" +quoted_single: 'foo bar'`; + + const result = parseSimpleYaml(yaml); + + expect(result.name).toBe('hello'); + expect(result.count).toBe(42); + expect(result.pi).toBe(3.14); + expect(result.enabled).toBe(true); + expect(result.disabled).toBe(false); + expect(result.nothing).toBeNull(); + expect(result.tilde).toBeNull(); + expect(result.quoted_double).toBe('hello world'); + expect(result.quoted_single).toBe('foo bar'); + }); + + it('parses inline arrays', () => { + const yaml = `tags: [alpha, beta, gamma] +ids: [1, 2, 3] +mixed: [hello, 42, true, null] +empty: []`; + + const result = parseSimpleYaml(yaml); + + expect(result.tags).toEqual(['alpha', 'beta', 'gamma']); + expect(result.ids).toEqual([1, 2, 3]); + expect(result.mixed).toEqual(['hello', 42, true, null]); + expect(result.empty).toEqual([]); + }); + + it('parses block arrays', () => { + const yaml = `models: + - "@cf/meta/llama-3.1-70b-instruct" + - "@cf/openai/gpt-4o" + - plain_model`; + + const result = parseSimpleYaml(yaml); + + expect(result.models).toEqual([ + '@cf/meta/llama-3.1-70b-instruct', + '@cf/openai/gpt-4o', + 'plain_model', + ]); + }); + + it('parses nested objects (one level deep)', () => { + const yaml = `params: + temperature: 0.3 + max_tokens: 4096`; + + const result = parseSimpleYaml(yaml); + + expect(result.params).toEqual({ temperature: 0.3, max_tokens: 4096 }); + }); + + it('parses nested objects with inline arrays', () => { + const yaml = `inputs: + required: [business_name, city] + optional: [phone, email]`; + + const result = parseSimpleYaml(yaml); + + expect(result.inputs).toEqual({ + required: ['business_name', 'city'], + optional: ['phone', 'email'], + }); + }); + + it('treats a key with no value and no children as null', () => { + const yaml = `empty_key: +next_key: hello`; + + const result = parseSimpleYaml(yaml); + + expect(result.empty_key).toBeNull(); + expect(result.next_key).toBe('hello'); + }); + + it('skips comments and empty lines', () => { + const yaml = `# This is a comment +id: test + +# Another comment +version: 5`; + + const result = parseSimpleYaml(yaml); + + expect(result.id).toBe('test'); + expect(result.version).toBe(5); + expect(Object.keys(result)).toHaveLength(2); + }); +}); + +// ─── parseYamlScalar ─────────────────────────────────────────── + +describe('parseYamlScalar', () => { + it('parses integers', () => { + expect(parseYamlScalar('42')).toBe(42); + expect(parseYamlScalar('0')).toBe(0); + expect(parseYamlScalar('-7')).toBe(-7); + }); + + it('parses floating point numbers', () => { + expect(parseYamlScalar('3.14')).toBe(3.14); + expect(parseYamlScalar('0.001')).toBe(0.001); + }); + + it('parses boolean values', () => { + expect(parseYamlScalar('true')).toBe(true); + expect(parseYamlScalar('false')).toBe(false); + }); + + it('parses null values', () => { + expect(parseYamlScalar('null')).toBeNull(); + expect(parseYamlScalar('~')).toBeNull(); + }); + + it('parses double-quoted strings and strips quotes', () => { + expect(parseYamlScalar('"hello world"')).toBe('hello world'); + expect(parseYamlScalar('"contains 42 number"')).toBe('contains 42 number'); + expect(parseYamlScalar('""')).toBe(''); + }); + + it('parses single-quoted strings and strips quotes', () => { + expect(parseYamlScalar("'foo bar'")).toBe('foo bar'); + expect(parseYamlScalar("'true'")).toBe('true'); + expect(parseYamlScalar("''")).toBe(''); + }); + + it('returns unquoted non-numeric strings as-is', () => { + expect(parseYamlScalar('hello')).toBe('hello'); + expect(parseYamlScalar('some_identifier')).toBe('some_identifier'); + expect(parseYamlScalar('@cf/meta/llama')).toBe('@cf/meta/llama'); + }); +}); diff --git a/apps/project-sites/src/__tests__/prompt_registry.test.ts b/apps/project-sites/src/__tests__/prompt_registry.test.ts new file mode 100644 index 0000000000..58ae32c90c --- /dev/null +++ b/apps/project-sites/src/__tests__/prompt_registry.test.ts @@ -0,0 +1,559 @@ +import { + register, + registerAll, + resolve, + resolveExact, + resolveLatest, + listAll, + listVersions, + listVariants, + configureVariants, + selectVariant, + resolveVariant, + loadFromKv, + clearRegistry, + getStats, +} from '../prompts/registry.js'; +import type { PromptSpec } from '../prompts/types.js'; + +/** + * Comprehensive tests for the prompt registry module. + * + * Covers registration, resolution, versioning, A/B variant selection, + * KV hot-patching, and registry introspection. + */ + +// ── Test fixture helper ────────────────────────────────────────── + +function makeSpec(overrides?: Partial): PromptSpec { + return { + id: 'test_prompt', + version: 1, + description: 'A test prompt', + models: ['gpt-4'], + params: { temperature: 0.7, maxTokens: 1024 }, + inputs: { required: ['query'], optional: ['context'] }, + outputs: { format: 'json', schema: 'TestSchema' }, + notes: { quality: 'experimental' }, + system: 'You are a test assistant.', + user: 'Answer: {{query}}', + ...overrides, + }; +} + +// ── Mock KV helper ─────────────────────────────────────────────── + +function createMockKv( + promptEntries: Record = {}, + variantConfigEntries: Record = {}, +) { + const allEntries = { ...promptEntries, ...variantConfigEntries }; + + return { + list: jest.fn().mockImplementation(({ prefix }: { prefix: string }) => { + const keys = Object.keys(allEntries) + .filter((k) => k.startsWith(prefix)) + .map((name) => ({ name })); + return Promise.resolve({ keys }); + }), + get: jest.fn().mockImplementation((key: string) => { + return Promise.resolve(allEntries[key] ?? null); + }), + }; +} + +// ── Reset state between tests ──────────────────────────────────── + +beforeEach(() => { + clearRegistry(); +}); + +// ─── register / resolve ────────────────────────────────────────── + +describe('register and resolve', () => { + it('registers a prompt and resolves it by id and version', () => { + const spec = makeSpec(); + register(spec); + + const result = resolve('test_prompt', 1); + expect(result).toEqual(spec); + }); + + it('returns undefined when resolving a non-existent prompt', () => { + const result = resolve('nonexistent', 1); + expect(result).toBeUndefined(); + }); + + it('overwrites an existing entry with the same id and version', () => { + register(makeSpec({ description: 'original' })); + register(makeSpec({ description: 'updated' })); + + const result = resolve('test_prompt', 1); + expect(result?.description).toBe('updated'); + }); + + it('does not confuse different versions of the same id', () => { + register(makeSpec({ version: 1, description: 'v1' })); + register(makeSpec({ version: 2, description: 'v2' })); + + expect(resolve('test_prompt', 1)?.description).toBe('v1'); + expect(resolve('test_prompt', 2)?.description).toBe('v2'); + }); +}); + +// ─── registerAll ───────────────────────────────────────────────── + +describe('registerAll', () => { + it('registers multiple prompts at once', () => { + const specs = [ + makeSpec({ id: 'alpha', version: 1 }), + makeSpec({ id: 'beta', version: 1 }), + makeSpec({ id: 'alpha', version: 2 }), + ]; + + registerAll(specs); + + expect(resolve('alpha', 1)).toBeDefined(); + expect(resolve('alpha', 2)).toBeDefined(); + expect(resolve('beta', 1)).toBeDefined(); + }); + + it('handles an empty array without error', () => { + expect(() => registerAll([])).not.toThrow(); + expect(listAll()).toHaveLength(0); + }); +}); + +// ─── resolveExact ──────────────────────────────────────────────── + +describe('resolveExact', () => { + it('resolves a base prompt when no variant is specified', () => { + const spec = makeSpec(); + register(spec); + + const result = resolveExact('test_prompt', 1); + expect(result).toEqual(spec); + }); + + it('resolves a specific variant', () => { + const variantA = makeSpec({ variant: 'a', description: 'Variant A' }); + register(variantA); + + const result = resolveExact('test_prompt', 1, 'a'); + expect(result).toEqual(variantA); + }); + + it('returns undefined for a non-existent variant', () => { + register(makeSpec()); + const result = resolveExact('test_prompt', 1, 'nonexistent'); + expect(result).toBeUndefined(); + }); + + it('distinguishes between base and variant entries', () => { + const base = makeSpec({ description: 'base' }); + const variant = makeSpec({ variant: 'a', description: 'variant-a' }); + register(base); + register(variant); + + expect(resolveExact('test_prompt', 1)?.description).toBe('base'); + expect(resolveExact('test_prompt', 1, 'a')?.description).toBe('variant-a'); + }); +}); + +// ─── resolveLatest ─────────────────────────────────────────────── + +describe('resolveLatest', () => { + it('returns the highest version for a given id', () => { + register(makeSpec({ version: 1, description: 'v1' })); + register(makeSpec({ version: 3, description: 'v3' })); + register(makeSpec({ version: 2, description: 'v2' })); + + const result = resolveLatest('test_prompt'); + expect(result?.version).toBe(3); + expect(result?.description).toBe('v3'); + }); + + it('returns undefined for an unknown id', () => { + register(makeSpec()); + const result = resolveLatest('unknown_prompt'); + expect(result).toBeUndefined(); + }); + + it('ignores variant entries when determining latest version', () => { + register(makeSpec({ version: 2, description: 'base-v2' })); + register(makeSpec({ version: 3, variant: 'a', description: 'variant-v3' })); + + const result = resolveLatest('test_prompt'); + expect(result?.version).toBe(2); + expect(result?.description).toBe('base-v2'); + }); +}); + +// ─── listAll ───────────────────────────────────────────────────── + +describe('listAll', () => { + it('returns all registered specs including variants', () => { + register(makeSpec({ id: 'p1', version: 1 })); + register(makeSpec({ id: 'p2', version: 1 })); + register(makeSpec({ id: 'p1', version: 1, variant: 'a' })); + + const all = listAll(); + expect(all).toHaveLength(3); + }); + + it('returns an empty array when nothing is registered', () => { + expect(listAll()).toEqual([]); + }); +}); + +// ─── listVersions ──────────────────────────────────────────────── + +describe('listVersions', () => { + it('returns all versions for an id sorted ascending', () => { + register(makeSpec({ version: 3 })); + register(makeSpec({ version: 1 })); + register(makeSpec({ version: 2 })); + + const versions = listVersions('test_prompt'); + expect(versions).toHaveLength(3); + expect(versions.map((s) => s.version)).toEqual([1, 2, 3]); + }); + + it('excludes variant entries', () => { + register(makeSpec({ version: 1 })); + register(makeSpec({ version: 1, variant: 'a' })); + register(makeSpec({ version: 2 })); + + const versions = listVersions('test_prompt'); + expect(versions).toHaveLength(2); + expect(versions.every((s) => s.variant == null)).toBe(true); + }); + + it('returns empty array for unknown id', () => { + expect(listVersions('unknown')).toEqual([]); + }); +}); + +// ─── listVariants ──────────────────────────────────────────────── + +describe('listVariants', () => { + it('returns all variants for a specific id and version', () => { + register(makeSpec({ version: 1 })); + register(makeSpec({ version: 1, variant: 'a' })); + register(makeSpec({ version: 1, variant: 'b' })); + register(makeSpec({ version: 2, variant: 'a' })); + + const variants = listVariants('test_prompt', 1); + expect(variants).toHaveLength(2); + expect(variants.map((s) => s.variant).sort()).toEqual(['a', 'b']); + }); + + it('returns empty array when no variants exist for version', () => { + register(makeSpec({ version: 1 })); + expect(listVariants('test_prompt', 1)).toEqual([]); + }); + + it('does not include the base (non-variant) entry', () => { + register(makeSpec({ version: 1 })); + register(makeSpec({ version: 1, variant: 'a' })); + + const variants = listVariants('test_prompt', 1); + expect(variants).toHaveLength(1); + expect(variants[0].variant).toBe('a'); + }); +}); + +// ─── configureVariants ─────────────────────────────────────────── + +describe('configureVariants', () => { + it('accepts weights summing to 100', () => { + expect(() => { + configureVariants('test_prompt', 1, { a: 80, b: 20 }); + }).not.toThrow(); + }); + + it('throws when weights do not sum to 100', () => { + expect(() => { + configureVariants('test_prompt', 1, { a: 60, b: 30 }); + }).toThrow('must sum to 100, got 90'); + }); + + it('throws with descriptive error including id and version', () => { + expect(() => { + configureVariants('my_prompt', 5, { a: 50 }); + }).toThrow('my_prompt@5'); + }); + + it('allows reconfiguring weights for the same id and version', () => { + configureVariants('test_prompt', 1, { a: 50, b: 50 }); + expect(() => { + configureVariants('test_prompt', 1, { a: 70, b: 30 }); + }).not.toThrow(); + }); +}); + +// ─── selectVariant ─────────────────────────────────────────────── + +describe('selectVariant', () => { + it('returns undefined when no variant config exists', () => { + const result = selectVariant('test_prompt', 1, 'seed-abc'); + expect(result).toBeUndefined(); + }); + + it('returns a variant when config is present', () => { + configureVariants('test_prompt', 1, { a: 80, b: 20 }); + + const result = selectVariant('test_prompt', 1, 'some-seed'); + expect(result).toBeDefined(); + expect(['a', 'b']).toContain(result); + }); + + it('returns the same variant consistently for the same seed', () => { + configureVariants('test_prompt', 1, { a: 50, b: 50 }); + + const first = selectVariant('test_prompt', 1, 'stable-seed'); + const second = selectVariant('test_prompt', 1, 'stable-seed'); + const third = selectVariant('test_prompt', 1, 'stable-seed'); + + expect(first).toBe(second); + expect(second).toBe(third); + }); + + it('can produce different variants for different seeds', () => { + configureVariants('test_prompt', 1, { a: 50, b: 50 }); + + // With 50/50 split and enough seeds, at least two distinct results should appear + const results = new Set(); + for (let i = 0; i < 100; i++) { + const v = selectVariant('test_prompt', 1, `seed-${i}`); + if (v) results.add(v); + } + + expect(results.size).toBe(2); + }); +}); + +// ─── resolveVariant ────────────────────────────────────────────── + +describe('resolveVariant', () => { + it('returns the variant spec when variant config and spec exist', () => { + register(makeSpec({ version: 1, description: 'base' })); + register(makeSpec({ version: 1, variant: 'a', description: 'variant-a' })); + register(makeSpec({ version: 1, variant: 'b', description: 'variant-b' })); + configureVariants('test_prompt', 1, { a: 50, b: 50 }); + + const result = resolveVariant('test_prompt', 1, 'any-seed'); + expect(result).toBeDefined(); + // The result must be one of the registered specs + expect(['base', 'variant-a', 'variant-b']).toContain(result?.description); + }); + + it('falls back to base spec when selected variant spec is not registered', () => { + register(makeSpec({ version: 1, description: 'base' })); + // Configure variants but only register the base, not variant 'a' or 'b' + configureVariants('test_prompt', 1, { a: 50, b: 50 }); + + const result = resolveVariant('test_prompt', 1, 'some-seed'); + expect(result).toBeDefined(); + expect(result?.description).toBe('base'); + }); + + it('returns the base spec when no variant config is set', () => { + register(makeSpec({ version: 1, description: 'base' })); + + const result = resolveVariant('test_prompt', 1, 'some-seed'); + expect(result).toBeDefined(); + expect(result?.description).toBe('base'); + expect(result?.variant).toBeUndefined(); + }); + + it('returns undefined when neither variant nor base exist', () => { + configureVariants('test_prompt', 1, { a: 100 }); + + const result = resolveVariant('test_prompt', 1, 'seed'); + expect(result).toBeUndefined(); + }); +}); + +// ─── loadFromKv ────────────────────────────────────────────────── + +describe('loadFromKv', () => { + it('loads prompt specs from KV and registers them', async () => { + const spec = makeSpec({ id: 'kv_prompt', version: 1 }); + const kv = createMockKv({ + 'prompt:kv_prompt@1': JSON.stringify(spec), + }); + + const loaded = await loadFromKv(kv as unknown as KVNamespace); + + expect(loaded).toBe(1); + expect(resolve('kv_prompt', 1)).toEqual(spec); + }); + + it('loads multiple prompts from KV', async () => { + const spec1 = makeSpec({ id: 'p1', version: 1 }); + const spec2 = makeSpec({ id: 'p2', version: 2 }); + const kv = createMockKv({ + 'prompt:p1@1': JSON.stringify(spec1), + 'prompt:p2@2': JSON.stringify(spec2), + }); + + const loaded = await loadFromKv(kv as unknown as KVNamespace); + + expect(loaded).toBe(2); + expect(resolve('p1', 1)).toBeDefined(); + expect(resolve('p2', 2)).toBeDefined(); + }); + + it('handles JSON parse errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const kv = createMockKv({ + 'prompt:bad@1': '{invalid json!!!', + }); + + const loaded = await loadFromKv(kv as unknown as KVNamespace); + + expect(loaded).toBe(0); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse KV prompt')); + + consoleSpy.mockRestore(); + }); + + it('filters by promptIds when provided', async () => { + const spec1 = makeSpec({ id: 'wanted', version: 1 }); + const spec2 = makeSpec({ id: 'unwanted', version: 1 }); + const kv = createMockKv({ + 'prompt:wanted@1': JSON.stringify(spec1), + 'prompt:unwanted@1': JSON.stringify(spec2), + }); + + const loaded = await loadFromKv(kv as unknown as KVNamespace, ['wanted']); + + expect(loaded).toBe(1); + expect(resolve('wanted', 1)).toBeDefined(); + expect(resolve('unwanted', 1)).toBeUndefined(); + }); + + it('loads variant configs from KV', async () => { + const spec = makeSpec({ id: 'ab_test', version: 1 }); + const specA = makeSpec({ id: 'ab_test', version: 1, variant: 'a' }); + register(spec); + register(specA); + + const kv = createMockKv( + {}, + { + 'variant_config:ab_test@1': JSON.stringify({ + promptId: 'ab_test', + version: 1, + weights: { a: 70, b: 30 }, + }), + }, + ); + + await loadFromKv(kv as unknown as KVNamespace); + + const variant = selectVariant('ab_test', 1, 'some-seed'); + expect(variant).toBeDefined(); + expect(['a', 'b']).toContain(variant); + }); + + it('handles variant config parse errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const kv = createMockKv( + {}, + { + 'variant_config:test@1': 'not json', + }, + ); + + await loadFromKv(kv as unknown as KVNamespace); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse KV variant config'), + ); + + consoleSpy.mockRestore(); + }); + + it('skips keys that return null from kv.get', async () => { + const kv = { + list: jest.fn().mockImplementation(({ prefix }: { prefix: string }) => { + if (prefix === 'prompt:') { + return Promise.resolve({ keys: [{ name: 'prompt:ghost@1' }] }); + } + return Promise.resolve({ keys: [] }); + }), + get: jest.fn().mockResolvedValue(null), + }; + + const loaded = await loadFromKv(kv as unknown as KVNamespace); + expect(loaded).toBe(0); + }); +}); + +// ─── clearRegistry ─────────────────────────────────────────────── + +describe('clearRegistry', () => { + it('empties all registered prompts', () => { + register(makeSpec({ id: 'a', version: 1 })); + register(makeSpec({ id: 'b', version: 2 })); + expect(listAll()).toHaveLength(2); + + clearRegistry(); + expect(listAll()).toHaveLength(0); + }); + + it('also clears variant configurations', () => { + register(makeSpec({ version: 1 })); + configureVariants('test_prompt', 1, { a: 100 }); + + clearRegistry(); + + expect(selectVariant('test_prompt', 1, 'seed')).toBeUndefined(); + expect(getStats().variantConfigs).toBe(0); + }); +}); + +// ─── getStats ──────────────────────────────────────────────────── + +describe('getStats', () => { + it('returns zeros for an empty registry', () => { + const stats = getStats(); + expect(stats).toEqual({ + totalPrompts: 0, + uniqueIds: 0, + variantConfigs: 0, + }); + }); + + it('returns correct counts with prompts and variants', () => { + register(makeSpec({ id: 'alpha', version: 1 })); + register(makeSpec({ id: 'alpha', version: 2 })); + register(makeSpec({ id: 'alpha', version: 1, variant: 'a' })); + register(makeSpec({ id: 'beta', version: 1 })); + configureVariants('alpha', 1, { a: 100 }); + + const stats = getStats(); + expect(stats.totalPrompts).toBe(4); + expect(stats.uniqueIds).toBe(2); + expect(stats.variantConfigs).toBe(1); + }); + + it('counts variant specs as part of totalPrompts', () => { + register(makeSpec({ id: 'p', version: 1 })); + register(makeSpec({ id: 'p', version: 1, variant: 'x' })); + register(makeSpec({ id: 'p', version: 1, variant: 'y' })); + + expect(getStats().totalPrompts).toBe(3); + }); + + it('counts variant entries under the same uniqueId', () => { + register(makeSpec({ id: 'same', version: 1 })); + register(makeSpec({ id: 'same', version: 1, variant: 'a' })); + register(makeSpec({ id: 'same', version: 2 })); + + // All have id "same", so uniqueIds should be 1 + expect(getStats().uniqueIds).toBe(1); + }); +}); diff --git a/apps/project-sites/src/__tests__/prompt_renderer.test.ts b/apps/project-sites/src/__tests__/prompt_renderer.test.ts new file mode 100644 index 0000000000..3f8af1fd1e --- /dev/null +++ b/apps/project-sites/src/__tests__/prompt_renderer.test.ts @@ -0,0 +1,229 @@ +import { + renderPrompt, + renderTemplate, + extractPlaceholders, + validateTemplatePlaceholders, +} from '../prompts/renderer.js'; +import type { PromptSpec } from '../prompts/types.js'; + +/** + * Unit tests for the prompt renderer module. + * + * Covers renderPrompt, renderTemplate, extractPlaceholders, + * and validateTemplatePlaceholders with edge cases. + */ + +// ─── Helpers ─────────────────────────────────────────────────── + +function makeSpec(overrides: Partial = {}): PromptSpec { + return { + id: 'test_prompt', + version: 1, + description: 'A test prompt', + models: ['gpt-4', 'claude-3'], + params: { temperature: 0.7, maxTokens: 1024 }, + inputs: { required: ['topic'], optional: ['style'] }, + outputs: { format: 'text' }, + notes: { usage: 'testing only' }, + system: 'You are an expert on {{topic}}.', + user: 'Write about {{topic}} in {{style}} style.', + ...overrides, + }; +} + +// ─── renderPrompt ────────────────────────────────────────────── + +describe('renderPrompt', () => { + it('renders system and user templates with all required inputs', () => { + const spec = makeSpec(); + const result = renderPrompt(spec, { topic: 'biology', style: 'formal' }); + + expect(result.system).toContain('biology'); + expect(result.user).toContain('biology'); + expect(result.user).toContain('formal'); + }); + + it('throws when a required input is missing', () => { + const spec = makeSpec(); + + expect(() => renderPrompt(spec, {})).toThrow( + 'Missing required prompt inputs for "test_prompt@1": topic', + ); + }); + + it('throws when a required input is whitespace-only', () => { + const spec = makeSpec(); + + expect(() => renderPrompt(spec, { topic: ' ' })).toThrow( + 'Missing required prompt inputs for "test_prompt@1": topic', + ); + }); + + it('defaults optional inputs to empty string when not provided', () => { + const spec = makeSpec(); + const result = renderPrompt(spec, { topic: 'history' }); + + // style placeholder replaced with empty string (no delimiters for empty) + expect(result.user).toBe('Write about <<>>history<<>> in style.'); + }); + + it('wraps user values in safe delimiters by default', () => { + const spec = makeSpec(); + const result = renderPrompt(spec, { topic: 'math', style: 'casual' }); + + expect(result.system).toBe('You are an expert on <<>>math<<>>.'); + expect(result.user).toBe( + 'Write about <<>>math<<>> in <<>>casual<<>> style.', + ); + }); + + it('skips delimiter wrapping when safeDelimit is false', () => { + const spec = makeSpec(); + const result = renderPrompt(spec, { topic: 'art', style: 'brief' }, { safeDelimit: false }); + + expect(result.system).toBe('You are an expert on art.'); + expect(result.user).toBe('Write about art in brief style.'); + expect(result.system).not.toContain('<<>>'); + expect(result.user).not.toContain('<<>>'); + }); + + it('strips unresolved placeholders when stripUnresolved is true', () => { + const spec = makeSpec({ + system: 'System: {{topic}} and {{unknown}}', + user: 'User: {{topic}} with {{mystery}}', + }); + const result = renderPrompt(spec, { topic: 'science' }, { stripUnresolved: true }); + + expect(result.system).not.toContain('{{unknown}}'); + expect(result.user).not.toContain('{{mystery}}'); + expect(result.system).toContain('science'); + }); + + it('returns the first model from the spec', () => { + const spec = makeSpec({ models: ['claude-3-opus', 'gpt-4-turbo'] }); + const result = renderPrompt(spec, { topic: 'testing' }); + + expect(result.model).toBe('claude-3-opus'); + }); + + it('returns a copy of params from the spec', () => { + const spec = makeSpec({ params: { temperature: 0.3, maxTokens: 2048 } }); + const result = renderPrompt(spec, { topic: 'params' }); + + expect(result.params).toEqual({ temperature: 0.3, maxTokens: 2048 }); + // Ensure it is a copy, not the same reference + expect(result.params).not.toBe(spec.params); + }); + + it('lists all missing required inputs in the error message', () => { + const spec = makeSpec({ + inputs: { required: ['a', 'b', 'c'], optional: [] }, + system: '{{a}} {{b}} {{c}}', + user: '{{a}}', + }); + + expect(() => renderPrompt(spec, {})).toThrow('a, b, c'); + }); +}); + +// ─── renderTemplate ──────────────────────────────────────────── + +describe('renderTemplate', () => { + it('replaces {{key}} placeholders with provided values', () => { + const result = renderTemplate('Hello {{name}}, welcome to {{place}}!', { + name: 'Alice', + place: 'Wonderland', + }); + + expect(result).toBe('Hello Alice, welcome to Wonderland!'); + }); + + it('leaves unknown placeholders intact by default', () => { + const result = renderTemplate('{{known}} and {{unknown}}', { + known: 'resolved', + }); + + expect(result).toBe('resolved and {{unknown}}'); + }); + + it('strips unknown placeholders when stripUnresolved is true', () => { + const result = renderTemplate('Start {{a}} middle {{b}} end', { a: 'X' }, true); + + expect(result).toBe('Start X middle end'); + }); + + it('returns empty string for empty template', () => { + const result = renderTemplate('', { key: 'value' }); + + expect(result).toBe(''); + }); + + it('returns original string when template has no placeholders', () => { + const original = 'No placeholders here.'; + const result = renderTemplate(original, { key: 'value' }); + + expect(result).toBe(original); + }); +}); + +// ─── extractPlaceholders ─────────────────────────────────────── + +describe('extractPlaceholders', () => { + it('finds all unique placeholder names in a template', () => { + const result = extractPlaceholders('{{alpha}} and {{beta}} with {{gamma}}'); + + expect(result).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])); + expect(result).toHaveLength(3); + }); + + it('returns empty array when there are no placeholders', () => { + const result = extractPlaceholders('No variables here.'); + + expect(result).toEqual([]); + }); + + it('deduplicates repeated placeholder names', () => { + const result = extractPlaceholders('{{x}} then {{x}} and {{x}} again'); + + expect(result).toEqual(['x']); + expect(result).toHaveLength(1); + }); +}); + +// ─── validateTemplatePlaceholders ────────────────────────────── + +describe('validateTemplatePlaceholders', () => { + it('returns empty array when all placeholders are declared', () => { + const spec = makeSpec({ + inputs: { required: ['topic'], optional: ['style'] }, + system: 'About {{topic}}.', + user: '{{topic}} in {{style}}.', + }); + const result = validateTemplatePlaceholders(spec); + + expect(result).toEqual([]); + }); + + it('returns undeclared placeholder keys found in templates', () => { + const spec = makeSpec({ + inputs: { required: ['topic'], optional: [] }, + system: '{{topic}} and {{rogue}}', + user: '{{topic}} with {{extra}}', + }); + const result = validateTemplatePlaceholders(spec); + + expect(result).toEqual(expect.arrayContaining(['rogue', 'extra'])); + expect(result).toHaveLength(2); + }); + + it('does not flag keys that appear in optional inputs', () => { + const spec = makeSpec({ + inputs: { required: [], optional: ['flavor'] }, + system: '{{flavor}}', + user: '{{flavor}}', + }); + const result = validateTemplatePlaceholders(spec); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/project-sites/src/__tests__/prompt_schemas.test.ts b/apps/project-sites/src/__tests__/prompt_schemas.test.ts new file mode 100644 index 0000000000..5480377982 --- /dev/null +++ b/apps/project-sites/src/__tests__/prompt_schemas.test.ts @@ -0,0 +1,282 @@ +import { ZodError } from 'zod'; +import { + ResearchBusinessInput, + ResearchBusinessOutput, + GenerateSiteInput, + GenerateSiteOutput, + ScoreQualityInput, + ScoreQualityOutput, + SiteCopyInput, + SiteCopyOutput, + PROMPT_SCHEMAS, + validatePromptInput, + validatePromptOutput, +} from '../prompts/schemas.js'; + +// ── ResearchBusinessInput ──────────────────────────────────── + +describe('ResearchBusinessInput', () => { + it('accepts valid full input', () => { + const data = { + business_name: 'Acme Corp', + business_phone: '555-1234', + business_address: '123 Main St', + google_place_id: 'ChIJ...', + additional_context: 'Open since 2020', + }; + const result = ResearchBusinessInput.parse(data); + expect(result.business_name).toBe('Acme Corp'); + expect(result.business_phone).toBe('555-1234'); + }); + + it('accepts minimal input with just business_name', () => { + const result = ResearchBusinessInput.parse({ business_name: 'Solo Shop' }); + expect(result.business_name).toBe('Solo Shop'); + }); + + it('rejects missing business_name', () => { + expect(() => ResearchBusinessInput.parse({})).toThrow(ZodError); + }); + + it('defaults optional fields to empty string', () => { + const result = ResearchBusinessInput.parse({ business_name: 'Defaults Inc' }); + expect(result.business_phone).toBe(''); + expect(result.business_address).toBe(''); + expect(result.google_place_id).toBe(''); + expect(result.additional_context).toBe(''); + }); +}); + +// ── ResearchBusinessOutput ─────────────────────────────────── + +describe('ResearchBusinessOutput', () => { + const validOutput = { + business_name: 'Acme Corp', + tagline: 'We deliver excellence', + description: 'A full-service company.', + services: ['Service A', 'Service B', 'Service C'], + hours: [{ day: 'Monday', hours: '9-5' }], + faq: [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + { question: 'Q3', answer: 'A3' }, + ], + seo_title: 'Acme Corp - Excellence', + seo_description: 'Best company for your needs.', + }; + + it('accepts valid output', () => { + const result = ResearchBusinessOutput.parse(validOutput); + expect(result.business_name).toBe('Acme Corp'); + expect(result.services).toHaveLength(3); + }); + + it('rejects tagline over 60 characters', () => { + expect(() => + ResearchBusinessOutput.parse({ + ...validOutput, + tagline: 'A'.repeat(61), + }), + ).toThrow(ZodError); + }); + + it('rejects fewer than 3 services', () => { + expect(() => + ResearchBusinessOutput.parse({ + ...validOutput, + services: ['Only one', 'Only two'], + }), + ).toThrow(ZodError); + }); + + it('rejects more than 8 services', () => { + expect(() => + ResearchBusinessOutput.parse({ + ...validOutput, + services: Array.from({ length: 9 }, (_, i) => `Service ${i + 1}`), + }), + ).toThrow(ZodError); + }); +}); + +// ── GenerateSiteInput ──────────────────────────────────────── + +describe('GenerateSiteInput', () => { + it('accepts valid input', () => { + const result = GenerateSiteInput.parse({ research_data: '{"name":"Acme"}' }); + expect(result.research_data).toBe('{"name":"Acme"}'); + }); + + it('rejects empty research_data', () => { + expect(() => GenerateSiteInput.parse({ research_data: '' })).toThrow(ZodError); + }); +}); + +// ── GenerateSiteOutput ─────────────────────────────────────── + +describe('GenerateSiteOutput', () => { + it('accepts valid HTML with DOCTYPE', () => { + const html = 'Hello'; + const result = GenerateSiteOutput.parse(html); + expect(result).toBe(html); + }); + + it('accepts lowercase doctype', () => { + const html = 'Hello'; + const result = GenerateSiteOutput.parse(html); + expect(result).toBe(html); + }); + + it('rejects string without DOCTYPE', () => { + expect(() => GenerateSiteOutput.parse('No doctype')).toThrow( + ZodError, + ); + }); +}); + +// ── ScoreQualityInput ──────────────────────────────────────── + +describe('ScoreQualityInput', () => { + it('accepts valid input', () => { + const result = ScoreQualityInput.parse({ html_content: '
Content
' }); + expect(result.html_content).toBe('
Content
'); + }); + + it('rejects empty html_content', () => { + expect(() => ScoreQualityInput.parse({ html_content: '' })).toThrow(ZodError); + }); +}); + +// ── ScoreQualityOutput ─────────────────────────────────────── + +describe('ScoreQualityOutput', () => { + const validScores = { + scores: { + accuracy: 0.9, + completeness: 0.85, + professionalism: 0.95, + seo: 0.8, + accessibility: 0.7, + }, + overall: 0.84, + issues: ['Minor spacing issue'], + suggestions: ['Add alt text to images'], + }; + + it('accepts valid scores', () => { + const result = ScoreQualityOutput.parse(validScores); + expect(result.overall).toBe(0.84); + expect(result.scores.accuracy).toBe(0.9); + }); + + it('rejects scores greater than 1', () => { + expect(() => + ScoreQualityOutput.parse({ + ...validScores, + scores: { ...validScores.scores, accuracy: 1.1 }, + }), + ).toThrow(ZodError); + }); + + it('rejects scores less than 0', () => { + expect(() => + ScoreQualityOutput.parse({ + ...validScores, + scores: { ...validScores.scores, seo: -0.1 }, + }), + ).toThrow(ZodError); + }); +}); + +// ── SiteCopyInput ──────────────────────────────────────────── + +describe('SiteCopyInput', () => { + it('accepts valid input', () => { + const result = SiteCopyInput.parse({ + businessName: 'Cool Biz', + city: 'Springfield', + services: ['Consulting'], + tone: 'premium', + }); + expect(result.businessName).toBe('Cool Biz'); + expect(result.tone).toBe('premium'); + }); + + it('applies default tone of friendly', () => { + const result = SiteCopyInput.parse({ + businessName: 'Defaults Co', + city: 'Townsville', + }); + expect(result.tone).toBe('friendly'); + expect(result.services).toEqual([]); + }); + + it('rejects invalid tone value', () => { + expect(() => + SiteCopyInput.parse({ + businessName: 'Bad Tone', + city: 'Errorville', + tone: 'aggressive', + }), + ).toThrow(ZodError); + }); + + it('rejects missing businessName', () => { + expect(() => + SiteCopyInput.parse({ + city: 'NoName City', + }), + ).toThrow(ZodError); + }); +}); + +// ── validatePromptInput ────────────────────────────────────── + +describe('validatePromptInput', () => { + it('validates against the correct schema and returns parsed input', () => { + const result = validatePromptInput('research_business', { + business_name: 'Valid Biz', + }); + expect(result).toMatchObject({ + business_name: 'Valid Biz', + business_phone: '', + business_address: '', + }); + }); + + it('throws ZodError for invalid input', () => { + expect(() => validatePromptInput('research_business', {})).toThrow(ZodError); + }); + + it('throws Error for unknown promptId', () => { + expect(() => validatePromptInput('nonexistent_prompt', { foo: 'bar' })).toThrow( + 'No schema registered for prompt: nonexistent_prompt', + ); + }); +}); + +// ── validatePromptOutput ───────────────────────────────────── + +describe('validatePromptOutput', () => { + it('validates output against the correct schema', () => { + const html = 'Site'; + const result = validatePromptOutput('generate_site', html); + expect(result).toBe(html); + }); + + it('throws ZodError for invalid output', () => { + expect(() => validatePromptOutput('generate_site', 'no doctype here')).toThrow(ZodError); + }); + + it('passes through raw output when no output schema is registered', () => { + // Temporarily add a prompt with no output schema to verify pass-through + PROMPT_SCHEMAS['_test_no_output'] = { input: ResearchBusinessInput }; + try { + const raw = { anything: 'goes' }; + const result = validatePromptOutput('_test_no_output', raw); + expect(result).toBe(raw); + } finally { + delete PROMPT_SCHEMAS['_test_no_output']; + } + }); +}); diff --git a/apps/project-sites/src/__tests__/search_routes.test.ts b/apps/project-sites/src/__tests__/search_routes.test.ts new file mode 100644 index 0000000000..0c2d3f9681 --- /dev/null +++ b/apps/project-sites/src/__tests__/search_routes.test.ts @@ -0,0 +1,463 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), + dbExecute: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +jest.mock('../services/audit.js', () => ({ + writeAuditLog: jest.fn().mockResolvedValue(undefined), +})); + +import { Hono } from 'hono'; +import type { Env, Variables } from '../types/env.js'; +import { errorHandler } from '../middleware/error_handler.js'; +import { search } from '../routes/search.js'; +import { dbQuery, dbQueryOne, dbInsert } from '../services/db.js'; +import { writeAuditLog } from '../services/audit.js'; + +const mockDbQuery = dbQuery as jest.MockedFunction; +const mockDbQueryOne = dbQueryOne as jest.MockedFunction; +const mockDbInsert = dbInsert as jest.MockedFunction; + +const mockQueueSend = jest.fn().mockResolvedValue(undefined); + +const mockDb = {} as D1Database; + +const mockSitesBucket = { + get: jest.fn().mockResolvedValue(null), + put: jest.fn().mockResolvedValue({}), +} as unknown as R2Bucket; + +const mockEnv = { + GOOGLE_PLACES_API_KEY: 'test-google-key', + ENVIRONMENT: 'test', + QUEUE: { send: mockQueueSend }, + DB: mockDb, + SITES_BUCKET: mockSitesBucket, +} as unknown as Env; + +// ─── App setup ────────────────────────────────────────────────────────────── + +const app = new Hono<{ Bindings: Env; Variables: Variables }>(); +app.onError(errorHandler); +app.route('/', search); + +function makeRequest(path: string, options?: RequestInit) { + return app.request(path, options, mockEnv); +} + +function makeAuthenticatedApp(vars: Partial = {}) { + const authedApp = new Hono<{ Bindings: Env; Variables: Variables }>(); + authedApp.onError(errorHandler); + authedApp.use('*', async (c, next) => { + if (vars.orgId) c.set('orgId', vars.orgId); + if (vars.userId) c.set('userId', vars.userId); + if (vars.requestId) c.set('requestId', vars.requestId); + await next(); + }); + authedApp.route('/', search); + return authedApp; +} + +// ─── Fetch interception (for Google Places only) ───────────────────────────── + +const originalFetch = global.fetch; +let mockFetch: jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetch = jest.fn(); + global.fetch = mockFetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +// ─── Google Places response helpers ───────────────────────────────────────── + +function makePlacesResponse( + places: Array<{ id: string; name: string; address: string; types?: string[]; lat?: number; lng?: number }>, +) { + return { + places: places.map((p) => ({ + id: p.id, + displayName: { text: p.name, languageCode: 'en' }, + formattedAddress: p.address, + types: p.types ?? ['establishment'], + ...(p.lat != null && p.lng != null ? { location: { latitude: p.lat, longitude: p.lng } } : {}), + })), + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GET /api/search/businesses +// ═══════════════════════════════════════════════════════════════════════════ + +describe('GET /api/search/businesses', () => { + it('returns 400 when q parameter is missing', async () => { + const res = await makeRequest('/api/search/businesses'); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe('BAD_REQUEST'); + expect(body.error.message).toContain('Missing required query parameter: q'); + }); + + it('returns 400 when q parameter is empty', async () => { + const res = await makeRequest('/api/search/businesses?q='); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe('BAD_REQUEST'); + }); + + it('returns business results from Google Places API', async () => { + const placesPayload = makePlacesResponse([ + { id: 'place_1', name: 'Coffee House', address: '123 Main St' }, + { id: 'place_2', name: 'Tea Room', address: '456 Oak Ave', types: ['cafe', 'food'] }, + { id: 'place_3', name: 'Bakery', address: '789 Elm Blvd' }, + ]); + + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(placesPayload), { status: 200 })); + + const res = await makeRequest('/api/search/businesses?q=coffee+shops'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toHaveLength(3); + + expect(body.data[0]).toEqual({ + place_id: 'place_1', + name: 'Coffee House', + address: '123 Main St', + types: ['establishment'], + lat: null, + lng: null, + }); + expect(body.data[1]).toEqual({ + place_id: 'place_2', + name: 'Tea Room', + address: '456 Oak Ave', + types: ['cafe', 'food'], + lat: null, + lng: null, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(calledUrl).toBe('https://places.googleapis.com/v1/places:searchText'); + expect(calledInit.method).toBe('POST'); + expect(calledInit.headers).toMatchObject({ 'X-Goog-Api-Key': 'test-google-key' }); + expect(JSON.parse(calledInit.body as string)).toEqual({ textQuery: 'coffee shops' }); + }); + + it('returns max 10 results even if API returns more', async () => { + const fifteenPlaces = Array.from({ length: 15 }, (_, i) => ({ + id: `place_${i}`, name: `Business ${i}`, address: `${i} Test St`, + })); + const placesPayload = makePlacesResponse(fifteenPlaces); + + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(placesPayload), { status: 200 })); + + const res = await makeRequest('/api/search/businesses?q=lots+of+results'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toHaveLength(10); + expect(body.data[0].place_id).toBe('place_0'); + expect(body.data[9].place_id).toBe('place_9'); + }); + + it('returns empty array when Google API returns no places', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })); + + const res = await makeRequest('/api/search/businesses?q=nonexistent+place'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + }); + + it('handles Google API errors gracefully and returns empty results', async () => { + mockFetch.mockResolvedValueOnce(new Response('API key invalid', { status: 403 })); + + const res = await makeRequest('/api/search/businesses?q=test'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// GET /api/sites/lookup +// ═══════════════════════════════════════════════════════════════════════════ + +describe('GET /api/sites/lookup', () => { + it('returns 400 when neither place_id nor slug is provided', async () => { + const res = await makeRequest('/api/sites/lookup'); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe('BAD_REQUEST'); + expect(body.error.message).toContain('Missing required query parameter: place_id or slug'); + }); + + it('returns exists: false when no site is found by place_id', async () => { + mockDbQueryOne.mockResolvedValueOnce(null); + + const res = await makeRequest('/api/sites/lookup?place_id=ChIJ_unknown'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ exists: false }); + }); + + it('returns exists: true with site details when found by place_id', async () => { + mockDbQueryOne.mockResolvedValueOnce({ + id: 'site-uuid-1', + slug: 'joes-pizza', + status: 'active', + current_build_version: 'v3', + }); + + const res = await makeRequest('/api/sites/lookup?place_id=ChIJ_abc123'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + exists: true, + site_id: 'site-uuid-1', + slug: 'joes-pizza', + status: 'active', + has_build: true, + }); + }); + + it('returns exists: true when found by slug', async () => { + mockDbQueryOne.mockResolvedValueOnce({ + id: 'site-uuid-2', + slug: 'bobs-bakery', + status: 'active', + current_build_version: 'v1', + }); + + const res = await makeRequest('/api/sites/lookup?slug=bobs-bakery'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + exists: true, + site_id: 'site-uuid-2', + slug: 'bobs-bakery', + status: 'active', + has_build: true, + }); + }); + + it('correctly reports has_build: false when current_build_version is null', async () => { + mockDbQueryOne.mockResolvedValueOnce({ + id: 'site-uuid-4', + slug: 'pending-site', + status: 'queued', + current_build_version: null, + }); + + const res = await makeRequest('/api/sites/lookup?place_id=ChIJ_pending'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.has_build).toBe(false); + expect(body.data.exists).toBe(true); + expect(body.data.status).toBe('queued'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// POST /api/sites/create-from-search +// ═══════════════════════════════════════════════════════════════════════════ + +describe('POST /api/sites/create-from-search', () => { + it('returns 401 when not authenticated (no orgId)', async () => { + const res = await makeRequest('/api/sites/create-from-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ business_name: 'Test Biz' }), + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 400 when business_name is missing', async () => { + const authedApp = makeAuthenticatedApp({ orgId: 'org-123', userId: 'user-456' }); + + const res = await authedApp.request( + '/api/sites/create-from-search', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + mockEnv, + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe('BAD_REQUEST'); + expect(body.error.message).toContain('Missing required field: business_name (or business.name)'); + }); + + it('creates site, enqueues workflow, and returns 201', async () => { + mockDbInsert.mockResolvedValueOnce({ error: null }); + + const authedApp = makeAuthenticatedApp({ + orgId: '00000000-0000-4000-8000-000000000001', + userId: '00000000-0000-4000-8000-000000000002', + requestId: 'req-789', + }); + + const requestBody = { + business_name: "Joe's Pizza Palace", + business_address: '100 Broadway, New York', + google_place_id: 'ChIJ_joes_pizza', + additional_context: 'Italian restaurant, family owned', + }; + + const res = await authedApp.request( + '/api/sites/create-from-search', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }, + mockEnv, + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data).toHaveProperty('site_id'); + expect(body.data).toHaveProperty('slug'); + expect(body.data.status).toBe('building'); + expect(body.data.slug).toBe('joes-pizza-palace'); + + // Verify workflow was queued + expect(mockQueueSend).toHaveBeenCalledTimes(1); + expect(mockQueueSend).toHaveBeenCalledWith( + expect.objectContaining({ + job_name: 'generate_site', + site_id: body.data.site_id, + business_name: "Joe's Pizza Palace", + google_place_id: 'ChIJ_joes_pizza', + additional_context: 'Italian restaurant, family owned', + }), + ); + + // Verify DB insert was called + expect(mockDbInsert).toHaveBeenCalledTimes(1); + expect(mockDbInsert).toHaveBeenCalledWith( + mockDb, + 'sites', + expect.objectContaining({ + business_name: "Joe's Pizza Palace", + org_id: '00000000-0000-4000-8000-000000000001', + status: 'building', + google_place_id: 'ChIJ_joes_pizza', + business_address: '100 Broadway, New York', + }), + ); + + // Verify audit log was written + expect(writeAuditLog).toHaveBeenCalled(); + }); + + it('creates site from nested v2 payload format (business object)', async () => { + mockDbInsert.mockResolvedValueOnce({ error: null }); + + const authedApp = makeAuthenticatedApp({ + orgId: '00000000-0000-4000-8000-000000000001', + userId: '00000000-0000-4000-8000-000000000002', + requestId: 'req-v2-001', + }); + + const requestBody = { + mode: 'business', + additional_context: 'We specialize in wood-fired pizza', + business: { + name: 'Napoli Pizza', + address: '200 Market St, San Francisco', + place_id: 'ChIJ_napoli', + phone: '+1-415-555-0100', + website: 'https://napolipizza.example.com', + types: ['restaurant', 'food'], + }, + }; + + const res = await authedApp.request( + '/api/sites/create-from-search', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }, + mockEnv, + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data).toHaveProperty('site_id'); + expect(body.data.status).toBe('building'); + expect(body.data.slug).toBe('napoli-pizza'); + + // Verify DB insert was called with fields extracted from nested business object + expect(mockDbInsert).toHaveBeenCalledTimes(1); + expect(mockDbInsert).toHaveBeenCalledWith( + mockDb, + 'sites', + expect.objectContaining({ + business_name: 'Napoli Pizza', + business_address: '200 Market St, San Francisco', + google_place_id: 'ChIJ_napoli', + business_phone: '+1-415-555-0100', + org_id: '00000000-0000-4000-8000-000000000001', + status: 'building', + }), + ); + + // Verify workflow was queued with correct data + expect(mockQueueSend).toHaveBeenCalledTimes(1); + expect(mockQueueSend).toHaveBeenCalledWith( + expect.objectContaining({ + job_name: 'generate_site', + business_name: 'Napoli Pizza', + google_place_id: 'ChIJ_napoli', + additional_context: 'We specialize in wood-fired pizza', + }), + ); + + // Verify audit log includes mode + expect(writeAuditLog).toHaveBeenCalledWith( + mockDb, + expect.objectContaining({ + action: 'site.created_from_search', + metadata_json: expect.objectContaining({ + business_name: 'Napoli Pizza', + google_place_id: 'ChIJ_napoli', + mode: 'business', + }), + }), + ); + }); + + it('returns 400 when nested business.name is also empty', async () => { + const authedApp = makeAuthenticatedApp({ orgId: 'org-123', userId: 'user-456' }); + + const res = await authedApp.request( + '/api/sites/create-from-search', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'business', business: { name: '' } }), + }, + mockEnv, + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe('BAD_REQUEST'); + expect(body.error.message).toContain('Missing required field'); + }); +}); diff --git a/apps/project-sites/src/__tests__/sentry.test.ts b/apps/project-sites/src/__tests__/sentry.test.ts new file mode 100644 index 0000000000..e782f963da --- /dev/null +++ b/apps/project-sites/src/__tests__/sentry.test.ts @@ -0,0 +1,217 @@ +import type { Env } from '../types/env.js'; +import { captureException, captureMessage } from '../services/sentry.js'; + +const mockFetch = jest.fn().mockResolvedValue({ ok: true }); +(global as any).fetch = mockFetch; + +function makeEnv(overrides?: Partial): Env { + return { + SENTRY_DSN: 'https://abc123@sentry.io/456789', + ENVIRONMENT: 'test', + ...overrides, + } as unknown as Env; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── captureException ───────────────────────────────────────── + +describe('captureException', () => { + it('sends POST to https://sentry.io/api/456789/store/', async () => { + const env = makeEnv(); + + await captureException(env, new Error('boom')); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://sentry.io/api/456789/store/'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('application/json'); + }); + + it('includes X-Sentry-Auth header with publickey', async () => { + const env = makeEnv(); + + await captureException(env, new Error('test')); + + const [, options] = mockFetch.mock.calls[0]; + const authHeader = options.headers['X-Sentry-Auth']; + expect(authHeader).toContain('sentry_key=abc123'); + expect(authHeader).toContain('sentry_version=7'); + expect(authHeader).toContain('sentry_client=project-sites/0.1.0'); + }); + + it('includes exception type, value, and stacktrace', async () => { + const env = makeEnv(); + const error = new TypeError('Cannot read property'); + + await captureException(env, error); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.exception.values).toHaveLength(1); + expect(body.exception.values[0].type).toBe('TypeError'); + expect(body.exception.values[0].value).toBe('Cannot read property'); + expect(body.exception.values[0].stacktrace).toBeDefined(); + }); + + it('includes context tags (requestId, userId, orgId)', async () => { + const env = makeEnv(); + + await captureException(env, new Error('fail'), { + requestId: 'req-001', + userId: 'usr-002', + orgId: 'org-003', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.tags.request_id).toBe('req-001'); + expect(body.tags.user_id).toBe('usr-002'); + expect(body.tags.org_id).toBe('org-003'); + }); + + it('includes environment and service tags', async () => { + const env = makeEnv(); + + await captureException(env, new Error('fail')); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.tags.environment).toBe('test'); + expect(body.tags.service).toBe('project-sites-worker'); + }); + + it('skips if SENTRY_DSN is empty', async () => { + const env = makeEnv({ SENTRY_DSN: '' }); + + await captureException(env, new Error('fail')); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips if DSN is invalid URL', async () => { + const env = makeEnv({ SENTRY_DSN: 'not-a-valid-dsn' }); + + await captureException(env, new Error('fail')); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not throw on fetch error (silently fails)', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + const env = makeEnv(); + + await expect(captureException(env, new Error('fail'))).resolves.not.toThrow(); + }); + + it('parses stack trace frames correctly', async () => { + const env = makeEnv(); + const error = new Error('stack test'); + error.stack = `Error: stack test + at myFunction (/src/index.ts:42:10) + at handleRequest (/src/handler.ts:15:5)`; + + await captureException(env, error); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const frames = body.exception.values[0].stacktrace.frames; + expect(frames).toHaveLength(2); + expect(frames[0].function).toBe('myFunction'); + expect(frames[0].filename).toBe('/src/index.ts'); + expect(frames[0].lineno).toBe(42); + expect(frames[1].function).toBe('handleRequest'); + expect(frames[1].filename).toBe('/src/handler.ts'); + expect(frames[1].lineno).toBe(15); + }); + + it('handles error without stack trace', async () => { + const env = makeEnv(); + const error = new Error('no stack'); + error.stack = undefined; + + await captureException(env, error); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.exception.values[0].stacktrace).toBeUndefined(); + }); + + it('includes platform and server_name', async () => { + const env = makeEnv(); + + await captureException(env, new Error('test')); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.platform).toBe('javascript'); + expect(body.server_name).toBe('cloudflare-worker'); + }); +}); + +// ─── captureMessage ─────────────────────────────────────────── + +describe('captureMessage', () => { + it('sends message with specified level', async () => { + const env = makeEnv(); + + await captureMessage(env, 'Deployment completed', 'warning'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.message).toBe('Deployment completed'); + expect(body.level).toBe('warning'); + }); + + it('defaults to info level', async () => { + const env = makeEnv(); + + await captureMessage(env, 'Hello world'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.level).toBe('info'); + }); + + it('includes extra data', async () => { + const env = makeEnv(); + + await captureMessage(env, 'event happened', 'info', { count: 5, source: 'cron' }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.extra.count).toBe(5); + expect(body.extra.source).toBe('cron'); + }); + + it('skips if no SENTRY_DSN', async () => { + const env = makeEnv({ SENTRY_DSN: '' }); + + await captureMessage(env, 'test message'); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips if invalid DSN (unparseable URL)', async () => { + const env = makeEnv({ SENTRY_DSN: ':::not-a-url' }); + + await captureMessage(env, 'test message'); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('includes platform and server_name', async () => { + const env = makeEnv(); + + await captureMessage(env, 'test'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.platform).toBe('javascript'); + expect(body.server_name).toBe('cloudflare-worker'); + }); + + it('includes environment and service tags', async () => { + const env = makeEnv(); + + await captureMessage(env, 'test'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.tags.environment).toBe('test'); + expect(body.tags.service).toBe('project-sites-worker'); + }); +}); diff --git a/apps/project-sites/src/__tests__/service_error_paths.test.ts b/apps/project-sites/src/__tests__/service_error_paths.test.ts new file mode 100644 index 0000000000..295a6a69d8 --- /dev/null +++ b/apps/project-sites/src/__tests__/service_error_paths.test.ts @@ -0,0 +1,536 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), + dbExecute: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +import { dbQuery, dbQueryOne, dbInsert, dbUpdate, dbExecute } from '../services/db.js'; +import { + createMagicLink, + verifyMagicLink, + createGoogleOAuthState, + handleGoogleOAuthCallback, + createSession, + getSession, + revokeSession, + getUserSessions, +} from '../services/auth.js'; +import { + getOrCreateStripeCustomer, + createCheckoutSession, + handleCheckoutCompleted, + handleSubscriptionUpdated, + handleSubscriptionDeleted, + handlePaymentFailed, + getOrgEntitlements, + getOrgSubscription, + createBillingPortalSession, +} from '../services/billing.js'; +import { + createCustomHostname, + checkHostnameStatus, + deleteCustomHostname, + provisionFreeDomain, + provisionCustomDomain, + getSiteHostnames, + getHostnameByDomain, + verifyPendingHostnames, +} from '../services/domains.js'; +import { AppError } from '@project-sites/shared'; + +const mockQuery = dbQuery as jest.MockedFunction; +const mockQueryOne = dbQueryOne as jest.MockedFunction; +const mockInsert = dbInsert as jest.MockedFunction; +const mockUpdate = dbUpdate as jest.MockedFunction; + +const mockFetch = jest.fn() as jest.MockedFunction; +const originalFetch = globalThis.fetch; + +const mockEnv = { + ENVIRONMENT: 'test', + STRIPE_SECRET_KEY: 'sk_test_123', + STRIPE_WEBHOOK_SECRET: 'whsec_test', + CF_API_TOKEN: 'cf-token', + CF_ZONE_ID: 'zone-123', + GOOGLE_CLIENT_ID: 'google-id', + GOOGLE_CLIENT_SECRET: 'google-secret', + SENDGRID_API_KEY: 'sg-key', + GOOGLE_PLACES_API_KEY: 'places-key', + SENTRY_DSN: 'https://sentry.example.com', +} as any; + +const mockDb = {} as D1Database; + +beforeEach(() => { + jest.clearAllMocks(); + globalThis.fetch = mockFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// =========================================================================== +// Auth Service Error Paths +// =========================================================================== +describe('Auth Service Error Paths', () => { + describe('verifyMagicLink', () => { + const token = 'a'.repeat(64); + + it('throws unauthorized when no matching token found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const err = await verifyMagicLink(mockDb, { token }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(401); + expect((err as AppError).message).toBe('Invalid or expired magic link'); + }); + + it('throws unauthorized when magic link is expired', async () => { + const pastDate = new Date(Date.now() - 3_600_000).toISOString(); + mockQueryOne.mockResolvedValueOnce({ + id: 'link-expired', + email: 'expired@example.com', + redirect_url: null, + used: 0, + expires_at: pastDate, + }); + + const err = await verifyMagicLink(mockDb, { token }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(401); + expect((err as AppError).message).toBe('Magic link has expired'); + }); + + it('marks link as used on successful verification', async () => { + const futureDate = new Date(Date.now() + 3_600_000).toISOString(); + mockQueryOne.mockResolvedValueOnce({ + id: 'link-valid', + email: 'valid@example.com', + redirect_url: null, + used: 0, + expires_at: futureDate, + }); + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + + const result = await verifyMagicLink(mockDb, { token }); + + expect(result.email).toBe('valid@example.com'); + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'magic_links', + expect.objectContaining({ used: 1 }), + 'id = ?', + ['link-valid'], + ); + }); + }); + + describe('handleGoogleOAuthCallback', () => { + it('throws unauthorized when state not found in DB', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const err = await handleGoogleOAuthCallback(mockDb, mockEnv, 'some-code', 'bad-state').catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(401); + expect((err as AppError).message).toBe('Invalid OAuth state'); + }); + + it('throws unauthorized when state is expired', async () => { + const pastDate = new Date(Date.now() - 600_000).toISOString(); + mockQueryOne.mockResolvedValueOnce({ + id: 'state-old', + state: 'expired-state', + expires_at: pastDate, + }); + + const err = await handleGoogleOAuthCallback( + mockDb, + mockEnv, + 'some-code', + 'expired-state', + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(401); + expect((err as AppError).message).toBe('OAuth state expired'); + }); + }); + + describe('getSession', () => { + const token = 'b'.repeat(64); + + it('returns null when no session found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getSession(mockDb, token); + + expect(result).toBeNull(); + }); + + it('returns null when session is expired', async () => { + const pastDate = new Date(Date.now() - 86_400_000).toISOString(); + mockQueryOne.mockResolvedValueOnce({ + id: 'sess-expired', + user_id: 'user-1', + expires_at: pastDate, + }); + + const result = await getSession(mockDb, token); + + expect(result).toBeNull(); + }); + }); +}); + +// =========================================================================== +// Billing Service Error Paths +// =========================================================================== +describe('Billing Service Error Paths', () => { + describe('getOrCreateStripeCustomer', () => { + it('returns existing customer ID when subscription already exists', async () => { + mockQueryOne.mockResolvedValueOnce({ + id: 'sub-1', + stripe_customer_id: 'cus_existing', + }); + + const result = await getOrCreateStripeCustomer(mockDb, mockEnv, 'org-1', 'a@b.com'); + + expect(result).toEqual({ stripe_customer_id: 'cus_existing' }); + expect(mockQueryOne).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws when Stripe API returns non-OK response', async () => { + mockQueryOne.mockResolvedValueOnce(null); + mockFetch.mockResolvedValueOnce( + new Response('Stripe error: invalid API key', { status: 401 }), + ); + + const err = await getOrCreateStripeCustomer(mockDb, mockEnv, 'org-1', 'a@b.com').catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(400); + expect((err as AppError).message).toMatch(/Failed to create Stripe customer/); + }); + }); + + describe('createCheckoutSession', () => { + it('throws when Stripe checkout API returns non-OK response', async () => { + mockQueryOne.mockResolvedValueOnce({ + id: 'sub-1', + stripe_customer_id: 'cus_existing', + }); + mockFetch.mockResolvedValueOnce(new Response('Checkout creation failed', { status: 400 })); + + const err = await createCheckoutSession(mockDb, mockEnv, { + orgId: 'org-1', + customerEmail: 'a@b.com', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(400); + expect((err as AppError).message).toMatch(/Failed to create Stripe checkout/); + }); + }); + + describe('handleCheckoutCompleted', () => { + it('throws badRequest when org_id missing from metadata', async () => { + const err = await handleCheckoutCompleted(mockDb, mockEnv, { + customer: 'cus_1', + subscription: 'sub_1', + metadata: {}, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(400); + expect((err as AppError).message).toBe('Missing org_id in checkout metadata'); + }); + + it('calls sale webhook when SALE_WEBHOOK_URL and SALE_WEBHOOK_SECRET are set', async () => { + const envWithWebhook = { + ...mockEnv, + SALE_WEBHOOK_URL: 'https://hooks.example.com/sale', + SALE_WEBHOOK_SECRET: 'webhook-secret-123', + }; + + mockUpdate.mockResolvedValueOnce({ error: null, changes: 1 }); + mockFetch.mockResolvedValueOnce(new Response('OK', { status: 200 })); + + await handleCheckoutCompleted(mockDb, envWithWebhook, { + customer: 'cus_1', + subscription: 'sub_1', + metadata: { org_id: 'org-1', site_id: 'site-1' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.example.com/sale', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Webhook-Signature': expect.any(String), + }), + }), + ); + + const webhookCall = mockFetch.mock.calls[0]; + const body = JSON.parse(webhookCall[1]!.body as string); + expect(body.org_id).toBe('org-1'); + expect(body.site_id).toBe('site-1'); + expect(body.stripe_customer_id).toBe('cus_1'); + expect(body.stripe_subscription_id).toBe('sub_1'); + expect(body.plan).toBe('paid'); + }); + }); + + describe('handleSubscriptionUpdated', () => { + it('returns early with no DB update when org_id missing from metadata', async () => { + const result = await handleSubscriptionUpdated(mockDb, { + id: 'sub_1', + status: 'active', + cancel_at_period_end: false, + current_period_start: 1700000000, + current_period_end: 1702592000, + metadata: {}, + }); + + expect(result).toBeUndefined(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('handleSubscriptionDeleted', () => { + it('returns early when org_id missing from metadata', async () => { + const result = await handleSubscriptionDeleted(mockDb, { + id: 'sub_1', + metadata: {}, + }); + + expect(result).toBeUndefined(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('getOrgEntitlements', () => { + it('returns FREE entitlements when no subscription found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getOrgEntitlements(mockDb, 'org-no-sub'); + + expect(result).toEqual( + expect.objectContaining({ + plan: 'free', + topBarHidden: false, + maxCustomDomains: 0, + analyticsEnabled: false, + }), + ); + }); + + it('returns FREE entitlements when subscription status is past_due', async () => { + mockQueryOne.mockResolvedValueOnce({ plan: 'paid', status: 'past_due' }); + + const result = await getOrgEntitlements(mockDb, 'org-past-due'); + + expect(result).toEqual( + expect.objectContaining({ + plan: 'free', + topBarHidden: false, + maxCustomDomains: 0, + analyticsEnabled: false, + }), + ); + }); + }); + + describe('getOrgSubscription', () => { + it('returns null when no subscription exists', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await getOrgSubscription(mockDb, 'org-none'); + + expect(result).toBeNull(); + }); + }); +}); + +// =========================================================================== +// Domains Service Error Paths +// =========================================================================== +describe('Domains Service Error Paths', () => { + describe('createCustomHostname', () => { + it('throws when CF API returns non-OK response', async () => { + mockFetch.mockResolvedValueOnce(new Response('Zone not found', { status: 403 })); + + const err = await createCustomHostname(mockEnv, 'bad.example.com').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(400); + expect((err as AppError).message).toMatch(/Failed to create custom hostname/); + }); + + it('returns cf_id, status, and ssl_status on success', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { + id: 'cf-host-abc', + status: 'pending', + ssl: { status: 'pending_validation' }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await createCustomHostname(mockEnv, 'app.example.com'); + + expect(result).toEqual({ + cf_id: 'cf-host-abc', + status: 'pending', + ssl_status: 'pending_validation', + }); + }); + }); + + describe('checkHostnameStatus', () => { + it('throws notFound when CF API returns non-OK (e.g. 404)', async () => { + mockFetch.mockResolvedValueOnce(new Response('Not found', { status: 404 })); + + const err = await checkHostnameStatus(mockEnv, 'cf-nonexistent').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(404); + expect((err as AppError).message).toBe('Custom hostname not found'); + }); + }); + + describe('deleteCustomHostname', () => { + it('succeeds without throwing when CF returns 404', async () => { + mockFetch.mockResolvedValueOnce(new Response('Not found', { status: 404 })); + + await expect(deleteCustomHostname(mockEnv, 'cf-already-gone')).resolves.toBeUndefined(); + }); + + it('throws when CF returns non-OK and not 404', async () => { + mockFetch.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })); + + const err = await deleteCustomHostname(mockEnv, 'cf-host-err').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(400); + expect((err as AppError).message).toMatch(/Failed to delete custom hostname/); + }); + }); + + describe('provisionFreeDomain', () => { + it('returns existing hostname when already provisioned', async () => { + mockQueryOne.mockResolvedValueOnce({ id: 'existing-id', status: 'active' }); + + const result = await provisionFreeDomain(mockDb, mockEnv, { + org_id: 'org-1', + site_id: 'site-1', + slug: 'existing-app', + }); + + expect(result).toEqual({ + hostname: 'existing-app-sites.megabyte.space', + status: 'active', + }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockQueryOne).toHaveBeenCalledTimes(1); + }); + }); + + describe('provisionCustomDomain', () => { + it('throws conflict when max custom domains reached (5 existing)', async () => { + const fiveDomains = Array.from({ length: 5 }, (_, i) => ({ id: `dom-${i}` })); + mockQuery.mockResolvedValueOnce({ data: fiveDomains, error: null }); + + const err = await provisionCustomDomain(mockDb, mockEnv, { + org_id: 'org-full', + site_id: 'site-1', + hostname: 'sixth.example.com', + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).statusCode).toBe(409); + expect((err as AppError).message).toMatch(/Maximum custom domains.*5/); + }); + }); + + describe('verifyPendingHostnames', () => { + it('returns correct verified/failed counts after checking multiple hostnames', async () => { + // Return 3 pending hostnames from DB + mockQuery.mockResolvedValueOnce({ + data: [ + { id: 'h1', cf_custom_hostname_id: 'cf-1', hostname: 'a.example.com' }, + { id: 'h2', cf_custom_hostname_id: 'cf-2', hostname: 'b.example.com' }, + { id: 'h3', cf_custom_hostname_id: 'cf-3', hostname: 'c.example.com' }, + ], + error: null, + }); + + // dbUpdate for h1, h2, h3 + mockUpdate + .mockResolvedValueOnce({ error: null, changes: 1 }) + .mockResolvedValueOnce({ error: null, changes: 1 }) + .mockResolvedValueOnce({ error: null, changes: 1 }); + + // h1: becomes active + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { status: 'active', ssl: { status: 'active' } }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + // h2: has verification errors -> verification_failed + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { + status: 'pending', + ssl: { status: 'pending_validation' }, + verification_errors: ['CNAME not found'], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + // h3: still pending, no errors -> stays pending + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { + status: 'pending', + ssl: { status: 'pending_validation' }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await verifyPendingHostnames(mockDb, mockEnv); + + expect(result).toEqual({ verified: 1, failed: 1 }); + expect(mockFetch).toHaveBeenCalledTimes(3); + // 1 initial query (dbQuery) + 3 updates (dbUpdate) + expect(mockQuery).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/apps/project-sites/src/__tests__/site_serving.test.ts b/apps/project-sites/src/__tests__/site_serving.test.ts new file mode 100644 index 0000000000..71596469d3 --- /dev/null +++ b/apps/project-sites/src/__tests__/site_serving.test.ts @@ -0,0 +1,63 @@ +import { generateTopBar } from '../services/site_serving'; +import { DOMAINS, BRAND } from '@project-sites/shared'; + +describe('generateTopBar', () => { + it('generates valid HTML with CTA', () => { + const html = generateTopBar('my-biz'); + expect(html).toContain('ps-topbar'); + expect(html).toContain(BRAND.PRIMARY_CTA); + expect(html).toContain('Project Sites'); + }); + + it('includes upgrade link with slug', () => { + const html = generateTopBar('joe-pizza'); + expect(html).toContain('upgrade=joe-pizza'); + }); + + it('includes close button', () => { + const html = generateTopBar('test'); + expect(html).toContain('×'); + expect(html).toContain("display='none'"); + }); + + it('sets body padding', () => { + const html = generateTopBar('test'); + expect(html).toContain('padding-top:44px'); + }); + + it('links to the main domain', () => { + const html = generateTopBar('test'); + expect(html).toContain(`https://${DOMAINS.SITES_BASE}`); + }); + + it('escapes slug in URL to prevent XSS', () => { + const html = generateTopBar('a"onmouseover="alert(1)'); + expect(html).not.toContain('"onmouseover="'); + expect(html).toContain(encodeURIComponent('a"onmouseover="alert(1)')); + }); + + it('has correct z-index for overlay', () => { + const html = generateTopBar('test'); + expect(html).toContain('z-index:99999'); + }); + + it('is wrapped in HTML comments for identification', () => { + const html = generateTopBar('test'); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('generates non-empty HTML for various slugs', () => { + const slugs = ['a-b-c', 'my-business-123', 'test']; + for (const slug of slugs) { + const html = generateTopBar(slug); + expect(html.length).toBeGreaterThan(100); + } + }); + + it('uses fixed positioning', () => { + const html = generateTopBar('test'); + expect(html).toContain('position:fixed'); + expect(html).toContain('top:0'); + }); +}); diff --git a/apps/project-sites/src/__tests__/site_serving_full.test.ts b/apps/project-sites/src/__tests__/site_serving_full.test.ts new file mode 100644 index 0000000000..5e8486cf02 --- /dev/null +++ b/apps/project-sites/src/__tests__/site_serving_full.test.ts @@ -0,0 +1,443 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), +})); + +import { dbQuery, dbQueryOne } from '../services/db.js'; +import { resolveSite, serveSiteFromR2 } from '../services/site_serving.js'; +import { DOMAINS } from '@project-sites/shared'; + +const mockQueryOne = dbQueryOne as jest.MockedFunction; + +// --------------------------------------------------------------------------- +// Mock factories +// --------------------------------------------------------------------------- + +const createMockKV = () => ({ + get: jest.fn().mockResolvedValue(null), + put: jest.fn().mockResolvedValue(undefined), +}); + +const createMockR2Object = (content: string) => ({ + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)); + controller.close(); + }, + }), + text: jest.fn().mockResolvedValue(content), + arrayBuffer: jest.fn().mockResolvedValue(new TextEncoder().encode(content).buffer), +}); + +const createMockR2 = () => ({ + get: jest.fn().mockResolvedValue(null), + put: jest.fn(), +}); + +const createMockEnv = () => ({ + CACHE_KV: createMockKV(), + SITES_BUCKET: createMockR2(), + DB: {} as D1Database, +}); + +const createMockDb = () => ({} as D1Database); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SAMPLE_HTML = '

Hello

'; + +const makeSite = (overrides: Record = {}) => ({ + site_id: 'site-001', + slug: 'my-site', + org_id: 'org-001', + current_build_version: 'v1', + plan: 'free', + ...overrides, +}); + +// --------------------------------------------------------------------------- +// resolveSite +// --------------------------------------------------------------------------- + +describe('resolveSite', () => { + let env: ReturnType; + let db: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + env = createMockEnv(); + db = createMockDb(); + }); + + it('returns cached result from KV when available', async () => { + const cached = makeSite(); + (env.CACHE_KV.get as jest.Mock).mockResolvedValue(cached); + + const result = await resolveSite(env as any, db, 'my-site-sites.megabyte.space'); + + expect(result).toEqual(cached); + expect(env.CACHE_KV.get).toHaveBeenCalledWith('host:my-site-sites.megabyte.space', 'json'); + // Should NOT have queried the database + expect(mockQueryOne).not.toHaveBeenCalled(); + }); + + it('extracts slug from dash-based hostname and looks up site in DB', async () => { + mockQueryOne + // sites table query + .mockResolvedValueOnce({ + id: 'site-001', slug: 'cool-biz', org_id: 'org-001', current_build_version: 'v2', + }) + // subscriptions query + .mockResolvedValueOnce({ plan: 'paid', status: 'active' }); + + const result = await resolveSite(env as any, db, `cool-biz${DOMAINS.SITES_SUFFIX}`); + + expect(result).toEqual({ + site_id: 'site-001', + slug: 'cool-biz', + org_id: 'org-001', + current_build_version: 'v2', + plan: 'paid', + }); + }); + + it('looks up site by slug in DB', async () => { + mockQueryOne + .mockResolvedValueOnce({ + id: 'site-abc', slug: 'test-slug', org_id: 'org-abc', current_build_version: 'v5', + }) + .mockResolvedValueOnce(null); // no subscription + + const result = await resolveSite(env as any, db, `test-slug${DOMAINS.SITES_SUFFIX}`); + + expect(result).not.toBeNull(); + expect(result!.slug).toBe('test-slug'); + expect(result!.site_id).toBe('site-abc'); + }); + + it('looks up custom domain in hostnames table', async () => { + mockQueryOne + // hostnames table + .mockResolvedValueOnce({ site_id: 'site-custom', org_id: 'org-custom' }) + // sites table + .mockResolvedValueOnce({ slug: 'custom-slug', current_build_version: 'v3' }) + // subscriptions + .mockResolvedValueOnce({ plan: 'paid', status: 'active' }); + + const result = await resolveSite(env as any, db, 'www.custom-domain.com'); + + expect(result).toEqual({ + site_id: 'site-custom', + slug: 'custom-slug', + org_id: 'org-custom', + current_build_version: 'v3', + plan: 'paid', + }); + }); + + it('returns plan=paid when subscription is paid and active', async () => { + mockQueryOne + .mockResolvedValueOnce({ id: 'site-p', slug: 'paid-site', org_id: 'org-p', current_build_version: 'v1' }) + .mockResolvedValueOnce({ plan: 'paid', status: 'active' }); + + const result = await resolveSite(env as any, db, `paid-site${DOMAINS.SITES_SUFFIX}`); + + expect(result!.plan).toBe('paid'); + }); + + it('returns plan=free when no subscription exists', async () => { + mockQueryOne + .mockResolvedValueOnce({ id: 'site-f', slug: 'free-site', org_id: 'org-f', current_build_version: 'v1' }) + .mockResolvedValueOnce(null); + + const result = await resolveSite(env as any, db, `free-site${DOMAINS.SITES_SUFFIX}`); + + expect(result!.plan).toBe('free'); + }); + + it('returns plan=free when subscription exists but is not active', async () => { + mockQueryOne + .mockResolvedValueOnce({ + id: 'site-i', slug: 'inactive-site', org_id: 'org-i', current_build_version: 'v1', + }) + .mockResolvedValueOnce({ plan: 'paid', status: 'canceled' }); + + const result = await resolveSite(env as any, db, `inactive-site${DOMAINS.SITES_SUFFIX}`); + + expect(result!.plan).toBe('free'); + }); + + it('returns null when site not found', async () => { + mockQueryOne.mockResolvedValueOnce(null); + + const result = await resolveSite(env as any, db, `nonexistent${DOMAINS.SITES_SUFFIX}`); + + expect(result).toBeNull(); + }); + + it('caches resolved site in KV with 60-second TTL', async () => { + mockQueryOne + .mockResolvedValueOnce({ id: 'site-c', slug: 'cached-site', org_id: 'org-c', current_build_version: 'v1' }) + .mockResolvedValueOnce({ plan: 'paid', status: 'active' }); + + await resolveSite(env as any, db, `cached-site${DOMAINS.SITES_SUFFIX}`); + + expect(env.CACHE_KV.put).toHaveBeenCalledWith( + `host:cached-site${DOMAINS.SITES_SUFFIX}`, + expect.any(String), + { expirationTtl: 60 }, + ); + + // Verify the cached value is correct JSON + const cachedJson = JSON.parse((env.CACHE_KV.put as jest.Mock).mock.calls[0][1]); + expect(cachedJson.slug).toBe('cached-site'); + expect(cachedJson.plan).toBe('paid'); + }); + + it('returns null for unknown custom domain', async () => { + // hostnames lookup returns null + mockQueryOne.mockResolvedValueOnce(null); + + const result = await resolveSite(env as any, db, 'unknown.example.com'); + + expect(result).toBeNull(); + expect(env.CACHE_KV.put).not.toHaveBeenCalled(); + }); + + it('handles DB query errors gracefully', async () => { + // dbQueryOne returns null on error (it catches internally) + mockQueryOne.mockResolvedValueOnce(null); + + const result = await resolveSite(env as any, db, `broken${DOMAINS.SITES_SUFFIX}`); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// serveSiteFromR2 +// --------------------------------------------------------------------------- + +describe('serveSiteFromR2', () => { + let env: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + env = createMockEnv(); + }); + + it('returns file from R2 with correct content type for .html', async () => { + const r2Obj = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/page.html'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/html'); + }); + + it('returns file from R2 with correct content type for .css', async () => { + const cssContent = 'body { color: red; }'; + const r2Obj = createMockR2Object(cssContent); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/styles.css'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/css'); + }); + + it('returns file from R2 with correct content type for .js', async () => { + const jsContent = 'console.log("hello");'; + const r2Obj = createMockR2Object(jsContent); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/app.js'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/javascript'); + }); + + it('returns file from R2 with correct content type for .png', async () => { + const r2Obj = createMockR2Object('PNG_BINARY_DATA'); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/logo.png'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('image/png'); + }); + + it('falls back to index.html for paths without extensions (SPA)', async () => { + const indexHtml = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(indexHtml); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/about/team'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/html'); + expect(env.SITES_BUCKET.get).toHaveBeenCalledTimes(2); + expect(env.SITES_BUCKET.get).toHaveBeenNthCalledWith(2, `sites/my-site/v1/index.html`); + }); + + it('returns 404 when file not found', async () => { + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(null); + + const site = makeSite(); + const response = await serveSiteFromR2(env as any, site, '/missing.css'); + + expect(response.status).toBe(404); + const body = await response.text(); + expect(body).toBe('Not Found'); + }); + + it('returns 404 when SPA fallback also not found', async () => { + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null); + + const site = makeSite(); + const response = await serveSiteFromR2(env as any, site, '/dashboard'); + + expect(response.status).toBe(404); + }); + + it('injects top bar for unpaid site HTML responses', async () => { + const r2Obj = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'free' }); + const response = await serveSiteFromR2(env as any, site, '/index.html'); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('ps-topbar'); + expect(html).toContain('Project Sites'); + expect(html).toContain('

Hello

'); + const bodyIndex = html.indexOf(''); + const topBarIndex = html.indexOf('ps-topbar'); + expect(topBarIndex).toBeGreaterThan(bodyIndex); + }); + + it('does NOT inject top bar for paid site HTML responses', async () => { + const r2Obj = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/index.html'); + + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).not.toContain('ps-topbar'); + expect(body).not.toContain('Project Sites Top Bar'); + }); + + it('does NOT inject top bar for non-HTML responses (CSS, JS)', async () => { + const cssContent = 'body { margin: 0; }'; + const r2Obj = createMockR2Object(cssContent); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'free' }); + const response = await serveSiteFromR2(env as any, site, '/styles.css'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/css'); + }); + + it('serves index.html for root path /', async () => { + const r2Obj = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/'); + + expect(response.status).toBe(200); + expect(env.SITES_BUCKET.get).toHaveBeenCalledWith('sites/my-site/v1/index.html'); + }); + + it('constructs correct R2 path with slug and version', async () => { + const r2Obj = createMockR2Object('data'); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ slug: 'acme-corp', current_build_version: 'v42' }); + await serveSiteFromR2(env as any, site, '/assets/logo.svg'); + + expect(env.SITES_BUCKET.get).toHaveBeenCalledWith('sites/acme-corp/v42/assets/logo.svg'); + }); + + it('uses "latest" as version when current_build_version is null', async () => { + const r2Obj = createMockR2Object('data'); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ current_build_version: null }); + await serveSiteFromR2(env as any, site, '/file.txt'); + + expect(env.SITES_BUCKET.get).toHaveBeenCalledWith('sites/my-site/latest/file.txt'); + }); + + it('sets cache-control and X-Site-Slug headers', async () => { + const r2Obj = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ slug: 'header-test', plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/page.html'); + + expect(response.headers.get('Cache-Control')).toBe('public, max-age=300, s-maxage=3600'); + expect(response.headers.get('X-Site-Slug')).toBe('header-test'); + }); + + it('returns correct content type for .json files', async () => { + const r2Obj = createMockR2Object('{"key":"value"}'); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/data.json'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); + + it('returns application/octet-stream for unknown file extensions', async () => { + const r2Obj = createMockR2Object('binary data'); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ plan: 'paid' }); + const response = await serveSiteFromR2(env as any, site, '/archive.xyz'); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/octet-stream'); + }); + + it('injects top bar with correct slug in upgrade link for unpaid HTML', async () => { + const r2Obj = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock).mockResolvedValue(r2Obj); + + const site = makeSite({ slug: 'joe-pizza', plan: 'free' }); + const response = await serveSiteFromR2(env as any, site, '/index.html'); + + const html = await response.text(); + expect(html).toContain('upgrade=joe-pizza'); + expect(html).toContain(`https://${DOMAINS.SITES_BASE}`); + }); + + it('injects top bar for SPA fallback on unpaid sites', async () => { + const indexHtml = createMockR2Object(SAMPLE_HTML); + (env.SITES_BUCKET.get as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(indexHtml); + + const site = makeSite({ plan: 'free' }); + const response = await serveSiteFromR2(env as any, site, '/some/route'); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('ps-topbar'); + }); +}); diff --git a/apps/project-sites/src/__tests__/webhook.test.ts b/apps/project-sites/src/__tests__/webhook.test.ts new file mode 100644 index 0000000000..c82c487fec --- /dev/null +++ b/apps/project-sites/src/__tests__/webhook.test.ts @@ -0,0 +1,134 @@ +import { verifyStripeSignature, verifyHmacSignature } from '../services/webhook'; +import { hmacSha256 } from '@project-sites/shared'; + +describe('verifyStripeSignature', () => { + const secret = 'whsec_test_secret_key_12345'; + + async function makeSignedRequest(body: string, secret: string) { + const timestamp = Math.floor(Date.now() / 1000); + const payload = `${timestamp}.${body}`; + const signature = await hmacSha256(secret, payload); + return { signatureHeader: `t=${timestamp},v1=${signature}`, timestamp }; + } + + it('accepts valid signature', async () => { + const body = '{"type":"test"}'; + const { signatureHeader } = await makeSignedRequest(body, secret); + const result = await verifyStripeSignature(body, signatureHeader, secret); + expect(result.valid).toBe(true); + }); + + it('rejects missing signature header', async () => { + const result = await verifyStripeSignature('{}', '', secret); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Missing'); + }); + + it('rejects missing secret', async () => { + const result = await verifyStripeSignature('{}', 't=123,v1=abc', ''); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Missing'); + }); + + it('rejects invalid signature format', async () => { + const result = await verifyStripeSignature('{}', 'invalid-format', secret); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Invalid signature format'); + }); + + it('rejects expired timestamp', async () => { + const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + const payload = `${oldTimestamp}.{}`; + const signature = await hmacSha256(secret, payload); + const result = await verifyStripeSignature( + '{}', + `t=${oldTimestamp},v1=${signature}`, + secret, + 300, + ); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Timestamp'); + }); + + it('rejects wrong signature', async () => { + const timestamp = Math.floor(Date.now() / 1000); + const result = await verifyStripeSignature( + '{}', + `t=${timestamp},v1=deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678`, + secret, + ); + expect(result.valid).toBe(false); + expect(result.reason).toContain('mismatch'); + }); + + it('rejects tampered body', async () => { + const originalBody = '{"amount":100}'; + const { signatureHeader } = await makeSignedRequest(originalBody, secret); + const tamperedBody = '{"amount":999}'; + const result = await verifyStripeSignature(tamperedBody, signatureHeader, secret); + expect(result.valid).toBe(false); + }); + + it('rejects signature with wrong secret', async () => { + const body = '{"test":true}'; + const { signatureHeader } = await makeSignedRequest(body, 'wrong-secret'); + const result = await verifyStripeSignature(body, signatureHeader, secret); + expect(result.valid).toBe(false); + }); + + it('handles non-numeric timestamp', async () => { + const result = await verifyStripeSignature('{}', 't=abc,v1=def', secret); + expect(result.valid).toBe(false); + expect(result.reason).toContain('Timestamp'); + }); + + it('accepts signature within tolerance window', async () => { + const body = '{}'; + const timestamp = Math.floor(Date.now() / 1000) - 100; // 100 seconds ago + const payload = `${timestamp}.${body}`; + const signature = await hmacSha256(secret, payload); + const result = await verifyStripeSignature(body, `t=${timestamp},v1=${signature}`, secret, 300); + expect(result.valid).toBe(true); + }); + + it('handles future timestamp within tolerance', async () => { + const body = '{}'; + const timestamp = Math.floor(Date.now() / 1000) + 100; // 100 seconds in future + const payload = `${timestamp}.${body}`; + const signature = await hmacSha256(secret, payload); + const result = await verifyStripeSignature(body, `t=${timestamp},v1=${signature}`, secret, 300); + expect(result.valid).toBe(true); + }); +}); + +describe('verifyHmacSignature', () => { + const secret = 'test-hmac-secret-key'; + + it('accepts valid signature', async () => { + const body = '{"data":"test"}'; + const signature = await hmacSha256(secret, body); + const result = await verifyHmacSignature(body, signature, secret); + expect(result.valid).toBe(true); + }); + + it('rejects missing signature', async () => { + const result = await verifyHmacSignature('{}', '', secret); + expect(result.valid).toBe(false); + }); + + it('rejects missing secret', async () => { + const result = await verifyHmacSignature('{}', 'sig', ''); + expect(result.valid).toBe(false); + }); + + it('rejects wrong signature', async () => { + const result = await verifyHmacSignature('{}', 'wrong-sig', secret); + expect(result.valid).toBe(false); + }); + + it('rejects tampered body', async () => { + const signature = await hmacSha256(secret, '{"amount":100}'); + const result = await verifyHmacSignature('{"amount":999}', signature, secret); + expect(result.valid).toBe(false); + }); +}); diff --git a/apps/project-sites/src/__tests__/webhook_route.test.ts b/apps/project-sites/src/__tests__/webhook_route.test.ts new file mode 100644 index 0000000000..15ec6f9720 --- /dev/null +++ b/apps/project-sites/src/__tests__/webhook_route.test.ts @@ -0,0 +1,446 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), + dbExecute: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +jest.mock('../services/webhook.js', () => ({ + verifyStripeSignature: jest.fn(), + checkWebhookIdempotency: jest.fn(), + storeWebhookEvent: jest.fn(), + markWebhookProcessed: jest.fn(), +})); + +jest.mock('../services/billing.js', () => ({ + handleCheckoutCompleted: jest.fn(), + handleSubscriptionUpdated: jest.fn(), + handleSubscriptionDeleted: jest.fn(), + handlePaymentFailed: jest.fn(), +})); + +jest.mock('../services/audit.js', () => ({ + writeAuditLog: jest.fn(), +})); + +jest.mock('@project-sites/shared', () => { + const actual = jest.requireActual('@project-sites/shared'); + return { ...actual, sha256Hex: jest.fn().mockResolvedValue('mockhash') }; +}); + +import { Hono } from 'hono'; +import { webhooks } from '../routes/webhooks.js'; +import { + verifyStripeSignature, + checkWebhookIdempotency, + storeWebhookEvent, + markWebhookProcessed, +} from '../services/webhook.js'; +import * as billingService from '../services/billing.js'; +import * as auditService from '../services/audit.js'; + +/** + * Integration tests for the POST /webhooks/stripe route. + * All external services are mocked; tests verify the route's + * orchestration logic (verify -> idempotency -> store -> process -> mark). + */ + +const mockVerify = verifyStripeSignature as jest.MockedFunction; +const mockIdempotency = checkWebhookIdempotency as jest.MockedFunction< + typeof checkWebhookIdempotency +>; +const mockStore = storeWebhookEvent as jest.MockedFunction; +const mockMark = markWebhookProcessed as jest.MockedFunction; +const mockCheckoutCompleted = billingService.handleCheckoutCompleted as jest.MockedFunction< + typeof billingService.handleCheckoutCompleted +>; +const mockSubscriptionUpdated = billingService.handleSubscriptionUpdated as jest.MockedFunction< + typeof billingService.handleSubscriptionUpdated +>; +const mockSubscriptionDeleted = billingService.handleSubscriptionDeleted as jest.MockedFunction< + typeof billingService.handleSubscriptionDeleted +>; +const mockPaymentFailed = billingService.handlePaymentFailed as jest.MockedFunction< + typeof billingService.handlePaymentFailed +>; +const mockAuditLog = auditService.writeAuditLog as jest.MockedFunction< + typeof auditService.writeAuditLog +>; + +const mockDb = {} as D1Database; + +const mockEnv = { + STRIPE_WEBHOOK_SECRET: 'whsec_test', + DB: mockDb, +}; + +const createApp = () => { + const app = new Hono<{ Bindings: any; Variables: any }>(); + app.use('*', async (c, next) => { + c.set('requestId', 'test-req-id'); + await next(); + }); + app.route('/', webhooks); + return app; +}; + +/** + * Helper to build a Stripe event object. + */ +function makeStripeEvent( + type: string, + object: Record = {}, + eventId = 'evt_test_123', +) { + return { + id: eventId, + type, + data: { object }, + }; +} + +/** + * Helper to send a POST to /webhooks/stripe. + */ +function postWebhook(app: ReturnType, event: object) { + return app.request( + '/webhooks/stripe', + { + method: 'POST', + body: JSON.stringify(event), + headers: { + 'Content-Type': 'application/json', + 'stripe-signature': 'test-sig', + }, + }, + mockEnv, + ); +} + +/** + * Sets up the default mock chain so the route proceeds past + * verification, idempotency, and storage. + */ +function setupDefaultMocks() { + mockVerify.mockResolvedValue({ valid: true }); + mockIdempotency.mockResolvedValue({ isDuplicate: false }); + mockStore.mockResolvedValue({ id: 'wh-evt-001', error: null }); + mockMark.mockResolvedValue(undefined); + mockAuditLog.mockResolvedValue(undefined); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + setupDefaultMocks(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── Signature verification ──────────────────────────────────── + +describe('POST /webhooks/stripe - signature verification', () => { + it('returns 401 when signature verification fails', async () => { + mockVerify.mockResolvedValue({ valid: false, reason: 'Signature mismatch' }); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed'); + const res = await postWebhook(app, event); + + expect(res.status).toBe(401); + }); + + it('response body includes WEBHOOK_SIGNATURE_INVALID code', async () => { + mockVerify.mockResolvedValue({ valid: false, reason: 'Signature mismatch' }); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed'); + const res = await postWebhook(app, event); + const body = await res.json(); + + expect(body.error).toBeDefined(); + expect(body.error.code).toBe('WEBHOOK_SIGNATURE_INVALID'); + expect(body.error.message).toBe('Signature mismatch'); + }); + + it('logs warning when signature fails', async () => { + const consoleSpy = jest.spyOn(console, 'error'); + mockVerify.mockResolvedValue({ valid: false, reason: 'Timestamp expired' }); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed'); + await postWebhook(app, event); + + const logCall = consoleSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.level === 'warn' && parsed.service === 'webhook'; + } catch { + return false; + } + }); + expect(logCall).toBeDefined(); + const parsed = JSON.parse(logCall![0] as string); + expect(parsed.message).toContain('Timestamp expired'); + }); +}); + +// ─── Idempotency ─────────────────────────────────────────────── + +describe('POST /webhooks/stripe - idempotency', () => { + it('returns 200 with duplicate:true for duplicate events', async () => { + mockIdempotency.mockResolvedValue({ isDuplicate: true, existingId: 'existing-id' }); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed'); + const res = await postWebhook(app, event); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.duplicate).toBe(true); + expect(body.received).toBe(true); + }); + + it('does NOT process duplicate events', async () => { + mockIdempotency.mockResolvedValue({ isDuplicate: true, existingId: 'existing-id' }); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed', { + customer: 'cus_test', + subscription: 'sub_test', + metadata: { org_id: 'org-1' }, + }); + await postWebhook(app, event); + + expect(mockCheckoutCompleted).not.toHaveBeenCalled(); + expect(mockStore).not.toHaveBeenCalled(); + }); +}); + +// ─── Event processing ────────────────────────────────────────── + +describe('POST /webhooks/stripe - event processing', () => { + it('checkout.session.completed calls handleCheckoutCompleted', async () => { + mockCheckoutCompleted.mockResolvedValue(undefined); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed', { + customer: 'cus_test', + subscription: 'sub_test', + metadata: { org_id: 'org-1', site_id: 'site-1' }, + }); + const res = await postWebhook(app, event); + + expect(res.status).toBe(200); + expect(mockCheckoutCompleted).toHaveBeenCalledTimes(1); + expect(mockCheckoutCompleted).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + customer: 'cus_test', + subscription: 'sub_test', + metadata: { org_id: 'org-1', site_id: 'site-1' }, + }), + ); + }); + + it('customer.subscription.updated calls handleSubscriptionUpdated', async () => { + mockSubscriptionUpdated.mockResolvedValue(undefined); + const app = createApp(); + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_123', + status: 'active', + cancel_at_period_end: false, + current_period_start: 1700000000, + current_period_end: 1702592000, + metadata: { org_id: 'org-2' }, + }); + const res = await postWebhook(app, event); + + expect(res.status).toBe(200); + expect(mockSubscriptionUpdated).toHaveBeenCalledTimes(1); + expect(mockSubscriptionUpdated).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'sub_123', + status: 'active', + metadata: { org_id: 'org-2' }, + }), + ); + }); + + it('customer.subscription.deleted calls handleSubscriptionDeleted', async () => { + mockSubscriptionDeleted.mockResolvedValue(undefined); + const app = createApp(); + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_del_456', + metadata: { org_id: 'org-3' }, + }); + const res = await postWebhook(app, event); + + expect(res.status).toBe(200); + expect(mockSubscriptionDeleted).toHaveBeenCalledTimes(1); + expect(mockSubscriptionDeleted).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'sub_del_456', + metadata: { org_id: 'org-3' }, + }), + ); + }); + + it('invoice.payment_failed calls handlePaymentFailed', async () => { + mockPaymentFailed.mockResolvedValue(undefined); + const app = createApp(); + const event = makeStripeEvent('invoice.payment_failed', { + subscription: 'sub_fail_789', + metadata: { org_id: 'org-4' }, + }); + const res = await postWebhook(app, event); + + expect(res.status).toBe(200); + expect(mockPaymentFailed).toHaveBeenCalledTimes(1); + expect(mockPaymentFailed).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + subscription: 'sub_fail_789', + metadata: { org_id: 'org-4' }, + }), + ); + }); + + it('invoice.paid is a no-op and returns 200', async () => { + const app = createApp(); + const event = makeStripeEvent('invoice.paid', { id: 'inv_123' }); + const res = await postWebhook(app, event); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.received).toBe(true); + expect(mockCheckoutCompleted).not.toHaveBeenCalled(); + expect(mockSubscriptionUpdated).not.toHaveBeenCalled(); + expect(mockSubscriptionDeleted).not.toHaveBeenCalled(); + expect(mockPaymentFailed).not.toHaveBeenCalled(); + }); +}); + +// ─── Event marking ───────────────────────────────────────────── + +describe('POST /webhooks/stripe - event marking', () => { + it('marks event as processed on success', async () => { + const app = createApp(); + const event = makeStripeEvent('invoice.paid', { id: 'inv_ok' }); + await postWebhook(app, event); + + expect(mockMark).toHaveBeenCalledWith(expect.anything(), 'wh-evt-001', 'processed'); + }); + + it('marks event as failed on processing error', async () => { + mockCheckoutCompleted.mockRejectedValue(new Error('DB write failed')); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed', { + customer: 'cus_test', + subscription: 'sub_test', + metadata: {}, + }); + await postWebhook(app, event); + + expect(mockMark).toHaveBeenCalledWith( + expect.anything(), + 'wh-evt-001', + 'failed', + 'DB write failed', + ); + }); +}); + +// ─── Error handling ──────────────────────────────────────────── + +describe('POST /webhooks/stripe - error handling', () => { + it('returns 200 with error message on processing failure to prevent retries', async () => { + mockSubscriptionUpdated.mockRejectedValue(new Error('Unexpected failure')); + const app = createApp(); + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_err', + status: 'active', + cancel_at_period_end: false, + current_period_start: 1700000000, + current_period_end: 1702592000, + metadata: {}, + }); + const res = await postWebhook(app, event); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.received).toBe(true); + expect(body.error).toBe('Processing failed'); + }); + + it('writes audit log when org_id exists in metadata', async () => { + mockCheckoutCompleted.mockResolvedValue(undefined); + mockAuditLog.mockResolvedValue(undefined); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed', { + customer: 'cus_audit', + subscription: 'sub_audit', + metadata: { org_id: 'org-audit-1' }, + }); + await postWebhook(app, event); + + expect(mockAuditLog).toHaveBeenCalledTimes(1); + expect(mockAuditLog).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + org_id: 'org-audit-1', + action: 'webhook.stripe.checkout.session.completed', + target_type: 'webhook', + target_id: 'evt_test_123', + request_id: 'test-req-id', + }), + ); + }); + + it('does not write audit log when org_id is absent from metadata', async () => { + mockCheckoutCompleted.mockResolvedValue(undefined); + const app = createApp(); + const event = makeStripeEvent('checkout.session.completed', { + customer: 'cus_no_org', + subscription: 'sub_no_org', + metadata: {}, + }); + await postWebhook(app, event); + + expect(mockAuditLog).not.toHaveBeenCalled(); + }); +}); + +// ─── Unknown events ──────────────────────────────────────────── + +describe('POST /webhooks/stripe - unknown events', () => { + it('unknown event type returns 200 (acknowledged)', async () => { + const app = createApp(); + const event = makeStripeEvent('some.unknown.event', { id: 'obj_unknown' }); + const res = await postWebhook(app, event); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.received).toBe(true); + }); + + it('logs info message for unhandled event type', async () => { + const consoleSpy = jest.spyOn(console, 'warn'); + const app = createApp(); + const event = makeStripeEvent('some.future.event', { id: 'obj_future' }); + await postWebhook(app, event); + + const logCall = consoleSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.message?.includes('Unhandled Stripe event type'); + } catch { + return false; + } + }); + expect(logCall).toBeDefined(); + const parsed = JSON.parse(logCall![0] as string); + expect(parsed.message).toContain('some.future.event'); + }); +}); diff --git a/apps/project-sites/src/__tests__/webhook_storage.test.ts b/apps/project-sites/src/__tests__/webhook_storage.test.ts new file mode 100644 index 0000000000..d239f3eef9 --- /dev/null +++ b/apps/project-sites/src/__tests__/webhook_storage.test.ts @@ -0,0 +1,177 @@ +jest.mock('../services/db.js', () => ({ + dbQuery: jest.fn().mockResolvedValue({ data: [], error: null }), + dbQueryOne: jest.fn().mockResolvedValue(null), + dbInsert: jest.fn().mockResolvedValue({ error: null }), + dbUpdate: jest.fn().mockResolvedValue({ error: null, changes: 1 }), +})); + +import { dbQueryOne, dbInsert, dbUpdate } from '../services/db.js'; +import { + checkWebhookIdempotency, + storeWebhookEvent, + markWebhookProcessed, +} from '../services/webhook.js'; + +const mockQueryOne = dbQueryOne as jest.MockedFunction; +const mockInsert = dbInsert as jest.MockedFunction; +const mockUpdate = dbUpdate as jest.MockedFunction; + +const mockDb = {} as D1Database; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── checkWebhookIdempotency ───────────────────────────────── + +describe('checkWebhookIdempotency', () => { + it('returns isDuplicate:false when no existing event', async () => { + mockQueryOne.mockResolvedValue(null); + + const result = await checkWebhookIdempotency(mockDb, 'stripe', 'evt_123'); + + expect(result.isDuplicate).toBe(false); + expect(result.existingId).toBeUndefined(); + }); + + it('returns isDuplicate:true with existingId when event exists', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid', status: 'processed' }); + + const result = await checkWebhookIdempotency(mockDb, 'stripe', 'evt_123'); + + expect(result.isDuplicate).toBe(true); + expect(result.existingId).toBe('existing-uuid'); + }); + + it('queries webhook_events with provider and event_id', async () => { + mockQueryOne.mockResolvedValue(null); + + await checkWebhookIdempotency(mockDb, 'dub', 'evt_abc'); + + expect(mockQueryOne).toHaveBeenCalledWith( + mockDb, + expect.stringContaining('provider = ?'), + expect.arrayContaining(['dub', 'evt_abc']), + ); + }); +}); + +// ─── storeWebhookEvent ─────────────────────────────────────── + +describe('storeWebhookEvent', () => { + it('returns id on successful insert', async () => { + mockInsert.mockResolvedValue({ error: null }); + + const result = await storeWebhookEvent(mockDb, { + provider: 'stripe', + event_id: 'evt_456', + event_type: 'checkout.session.completed', + }); + + expect(result.id).toBeTruthy(); + expect(result.error).toBeNull(); + }); + + it('returns error when DB fails', async () => { + mockInsert.mockResolvedValue({ error: 'Insert failed' }); + + const result = await storeWebhookEvent(mockDb, { + provider: 'stripe', + event_id: 'evt_789', + event_type: 'payment_intent.succeeded', + }); + + expect(result.id).toBeNull(); + expect(result.error).toBe('Insert failed'); + }); + + it('sets default status to received', async () => { + mockInsert.mockResolvedValue({ error: null }); + + await storeWebhookEvent(mockDb, { + provider: 'stripe', + event_id: 'evt_100', + event_type: 'invoice.paid', + }); + + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'webhook_events', + expect.objectContaining({ + status: 'received', + }), + ); + }); + + it('sets attempts to 0', async () => { + mockInsert.mockResolvedValue({ error: null }); + + await storeWebhookEvent(mockDb, { + provider: 'lago', + event_id: 'evt_200', + event_type: 'subscription.created', + }); + + expect(mockInsert).toHaveBeenCalledWith( + mockDb, + 'webhook_events', + expect.objectContaining({ + attempts: 0, + }), + ); + }); +}); + +// ─── markWebhookProcessed ──────────────────────────────────── + +describe('markWebhookProcessed', () => { + it('sets status to processed and processed_at', async () => { + mockUpdate.mockResolvedValue({ error: null, changes: 1 }); + + await markWebhookProcessed(mockDb, 'event-uuid-1', 'processed'); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'webhook_events', + expect.objectContaining({ + status: 'processed', + processed_at: expect.any(String), + }), + 'id = ?', + ['event-uuid-1'], + ); + }); + + it('sets status to failed with error_message', async () => { + mockUpdate.mockResolvedValue({ error: null, changes: 1 }); + + await markWebhookProcessed(mockDb, 'event-uuid-2', 'failed', 'Something broke'); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'webhook_events', + expect.objectContaining({ + status: 'failed', + error_message: 'Something broke', + }), + 'id = ?', + ['event-uuid-2'], + ); + }); + + it('defaults status to processed when not specified', async () => { + mockUpdate.mockResolvedValue({ error: null, changes: 1 }); + + await markWebhookProcessed(mockDb, 'event-uuid-3'); + + expect(mockUpdate).toHaveBeenCalledWith( + mockDb, + 'webhook_events', + expect.objectContaining({ + status: 'processed', + }), + 'id = ?', + ['event-uuid-3'], + ); + }); +}); diff --git a/apps/project-sites/src/index.ts b/apps/project-sites/src/index.ts new file mode 100644 index 0000000000..65ec097008 --- /dev/null +++ b/apps/project-sites/src/index.ts @@ -0,0 +1,345 @@ +/** + * @module index + * @description Main entry point for the Project Sites Cloudflare Worker. + * + * Configures global middleware, mounts route modules, handles the + * catch-all site-serving logic, and exports the Workers fetch/queue/scheduled + * handlers. + * + * ## Middleware Stack (applied to every request) + * + * | Order | Middleware | Purpose | + * | ----- | ------------------- | ------------------------------------ | + * | 1 | `requestId` | Generate `X-Request-ID` header | + * | 2 | `payloadLimit` | Reject oversized request bodies | + * | 3 | `securityHeaders` | Set CSP, HSTS, X-Frame-Options | + * | 4 | `cors` (API only) | CORS for `/api/*` endpoints | + * | 5 | `errorHandler` | Catch + format errors as JSON | + * + * ## Routing Priority + * + * 1. Health check (`/health`) + * 2. Search routes (`/api/search/*`, `/api/sites/lookup`, `/api/sites/search`) + * 3. API routes (`/api/*`) — includes `/api/sites/:id` param routes + * 4. Webhook routes (`/webhooks/*`) + * 5. Catch-all: marketing site or subdomain site serving + * + * @packageDocumentation + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Env, Variables } from './types/env.js'; +import { requestIdMiddleware } from './middleware/request_id.js'; +import { errorHandler } from './middleware/error_handler.js'; +import { payloadLimitMiddleware } from './middleware/payload_limit.js'; +import { securityHeadersMiddleware } from './middleware/security_headers.js'; +import { authMiddleware } from './middleware/auth.js'; +import { health } from './routes/health.js'; +import { api } from './routes/api.js'; +import { search } from './routes/search.js'; +import { webhooks } from './routes/webhooks.js'; +import { resolveSite, serveSiteFromR2 } from './services/site_serving.js'; +import { dbUpdate } from './services/db.js'; +import { registerAllPrompts } from './services/ai_workflows.js'; +import { DOMAINS } from '@project-sites/shared'; +export { SiteGenerationWorkflow } from './workflows/site-generation.js'; + +// Register all prompt definitions at module load +registerAllPrompts(); + +const app = new Hono<{ Bindings: Env; Variables: Variables }>(); + +// ─── Global Middleware ─────────────────────────────────────── + +// Request ID on every request +app.use('*', requestIdMiddleware); + +// Payload size limit +app.use('*', payloadLimitMiddleware); + +// Security headers +app.use('*', securityHeadersMiddleware); + +// CORS for API routes +app.use( + '/api/*', + cors({ + origin: (origin) => { + // Allow requests from our domains + const allowed = [ + `https://${DOMAINS.SITES_BASE}`, + `https://${DOMAINS.SITES_STAGING}`, + `https://${DOMAINS.BOLT_BASE}`, + 'http://localhost:3000', + 'http://localhost:5173', + ]; + if (origin && allowed.includes(origin)) { + return origin; + } + return ''; + }, + allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], + credentials: true, + maxAge: 86400, + }), +); + +// Auth middleware for API routes (sets userId/orgId if valid session) +app.use('/api/*', authMiddleware); + +// Global error handler +app.onError(errorHandler); + +// ─── Mount Routes ──────────────────────────────────────────── + +app.route('/', health); +app.route('/', search); // Must come before api so /api/sites/search wins over /api/sites/:id +app.route('/', api); +app.route('/', webhooks); + +// ─── Site Serving (catch-all for subdomain routing) ────────── + +app.all('*', async (c) => { + const hostname = c.req.header('host') ?? ''; + const url = new URL(c.req.url); + const path = url.pathname; + + // Serve the marketing site homepage for the base domain + if ( + hostname === DOMAINS.SITES_BASE || + hostname === DOMAINS.SITES_STAGING || + hostname === `www.${DOMAINS.SITES_BASE}` || // legacy support + hostname.startsWith('localhost') + ) { + // Try to serve from R2 first (for production) + const marketingPath = `marketing${path === '/' ? '/index.html' : path}`; + let marketingAsset = await c.env.SITES_BUCKET.get(marketingPath); + + // Removed pages (/privacy, /terms, /content) redirect to homepage. + // /contact scrolls to contact section on homepage. + if (!marketingAsset && !path.includes('.') && path !== '/') { + const redirectPaths = ['/privacy', '/terms', '/content', '/contact']; + if (redirectPaths.includes(path)) { + const baseUrl = + hostname === DOMAINS.SITES_STAGING + ? `https://${DOMAINS.SITES_STAGING}` + : `https://${DOMAINS.SITES_BASE}`; + const target = path === '/contact' ? `${baseUrl}/#contact-section` : `${baseUrl}/`; + return Response.redirect(target, 301); + } + } + + if (marketingAsset) { + const resolvedPath = marketingAsset.key; + const ext = resolvedPath.split('.').pop()?.toLowerCase() ?? 'html'; + const mimeTypes: Record = { + html: 'text/html', + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + png: 'image/png', + jpg: 'image/jpeg', + svg: 'image/svg+xml', + ico: 'image/x-icon', + xml: 'application/xml', + webmanifest: 'application/manifest+json', + txt: 'text/plain', + }; + + // For HTML, inject runtime env vars (PostHog key, Stripe publishable key) + if (ext === 'html') { + let html = await marketingAsset.text(); + const phKey = c.env.POSTHOG_API_KEY ?? 'none'; + html = html.replace('', `\n`); + return new Response(html, { + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=60', + }, + }); + } + + return new Response(marketingAsset.body, { + headers: { + 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream', + 'Cache-Control': 'public, max-age=3600', + }, + }); + } + + // Final fallback: return JSON info when no static assets deployed at all + return c.json( + { + name: 'Project Sites', + tagline: 'Your website\u2014handled. Finally.', + version: '0.1.0', + homepage: 'Deploy the marketing site to R2 at marketing/index.html', + }, + 200, + ); + } + + // Resolve the site from hostname using D1 + const site = await resolveSite(c.env, c.env.DB, hostname); + + if (!site) { + return c.json( + { + error: { + code: 'NOT_FOUND', + message: 'Site not found', + request_id: c.get('requestId'), + }, + }, + 404, + ); + } + + // Check for ?chat query param (requires auth gate) + if (url.searchParams.has('chat')) { + // TODO: Implement chat overlay auth gate + return c.json( + { + message: 'Chat overlay - authentication required', + auth_url: `/api/auth/magic-link`, + }, + 200, + ); + } + + // Serve static site from R2 + return serveSiteFromR2(c.env, site, path); +}); + +// ─── Queue Consumer ────────────────────────────────────────── + +export default { + fetch: app.fetch, + + /** + * Queue consumer handler for workflow jobs. + * + * Processes queued `generate_site` jobs by running the v2 AI workflow, + * uploading results to R2, and updating the site record in D1. + */ + async queue(batch: MessageBatch, env: Env): Promise { + for (const message of batch.messages) { + try { + const payload = message.body as Record; + console.warn( + JSON.stringify({ + level: 'info', + service: 'queue', + message: `Processing job: ${payload.job_name}`, + site_id: payload.site_id, + }), + ); + + if (payload.job_name === 'generate_site') { + const { runSiteGenerationWorkflowV2 } = await import('./services/ai_workflows.js'); + + const result = await runSiteGenerationWorkflowV2(env, { + businessName: String(payload.business_name ?? ''), + businessAddress: payload.business_address + ? String(payload.business_address) + : undefined, + businessPhone: payload.business_phone + ? String(payload.business_phone) + : undefined, + googlePlaceId: payload.google_place_id ? String(payload.google_place_id) : undefined, + additionalContext: payload.additional_context + ? String(payload.additional_context) + : undefined, + }); + + // Upload generated files to R2 + const siteId = String(payload.site_id); + const slug = String(payload.slug ?? payload.business_name ?? 'site') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const version = new Date().toISOString().replace(/[:.]/g, '-'); + + // Upload main page, privacy page, and terms page in parallel + await Promise.all([ + env.SITES_BUCKET.put(`sites/${slug}/${version}/index.html`, result.html, { + httpMetadata: { contentType: 'text/html' }, + }), + env.SITES_BUCKET.put(`sites/${slug}/${version}/privacy.html`, result.privacyHtml, { + httpMetadata: { contentType: 'text/html' }, + }), + env.SITES_BUCKET.put(`sites/${slug}/${version}/terms.html`, result.termsHtml, { + httpMetadata: { contentType: 'text/html' }, + }), + // Store research data as JSON for future rebuilds + env.SITES_BUCKET.put( + `sites/${slug}/${version}/research.json`, + JSON.stringify(result.research, null, 2), + { httpMetadata: { contentType: 'application/json' } }, + ), + ]); + + // Update site record in D1 + await dbUpdate( + env.DB, + 'sites', + { + status: 'published', + current_build_version: version, + }, + 'id = ?', + [siteId], + ); + + console.warn( + JSON.stringify({ + level: 'info', + service: 'queue', + message: `Site generated and published`, + site_id: siteId, + slug, + version, + quality_score: result.quality.overall, + pages_uploaded: ['index.html', 'privacy.html', 'terms.html', 'research.json'], + }), + ); + } + + message.ack(); + } catch (err) { + console.error( + JSON.stringify({ + level: 'error', + service: 'queue', + message: err instanceof Error ? err.message : 'Job processing failed', + }), + ); + message.retry(); + } + } + }, + + /** + * Scheduled handler for periodic tasks (cron triggers). + * + * Planned tasks: + * - Verify pending custom hostnames via Cloudflare API + * - Dunning checks for past-due subscriptions + * - Analytics rollup + */ + async scheduled(_event: ScheduledEvent, _env: Env, _ctx: ExecutionContext): Promise { + // TODO: Implement scheduled tasks + // - verifyPendingHostnames + // - dunning check + // - analytics rollup + console.warn( + JSON.stringify({ + level: 'info', + service: 'cron', + message: 'Scheduled task triggered', + }), + ); + }, +}; diff --git a/apps/project-sites/src/lib/posthog.ts b/apps/project-sites/src/lib/posthog.ts new file mode 100644 index 0000000000..629247d751 --- /dev/null +++ b/apps/project-sites/src/lib/posthog.ts @@ -0,0 +1,122 @@ +/** + * PostHog server-side event capture for Cloudflare Workers. + * + * Uses the PostHog HTTP API directly (no SDK needed) to track + * server-side events like auth flows, site creation, and errors. + * + * @module lib/posthog + */ + +import type { Env } from '../types/env.js'; + +interface PostHogEvent { + event: string; + distinctId: string; + properties?: Record; +} + +const POSTHOG_API_URL = 'https://us.i.posthog.com/capture/'; + +/** + * Capture a server-side event in PostHog. + * + * Fire-and-forget: uses waitUntil to avoid blocking the response. + * Safe to call even if POSTHOG_API_KEY is not configured. + */ +export function capture( + env: Env, + ctx: ExecutionContext, + event: PostHogEvent, +): void { + if (!env.POSTHOG_API_KEY) return; + + const host = env.POSTHOG_HOST ?? POSTHOG_API_URL; + const url = host.endsWith('/capture/') ? host : `${host}/capture/`; + + const body = JSON.stringify({ + api_key: env.POSTHOG_API_KEY, + event: event.event, + distinct_id: event.distinctId, + properties: { + ...event.properties, + $lib: 'project-sites-worker', + environment: env.ENVIRONMENT, + }, + timestamp: new Date().toISOString(), + }); + + const promise = fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }).catch((err) => { + console.warn(JSON.stringify({ + level: 'warn', + service: 'posthog', + message: 'Failed to capture event', + error: err instanceof Error ? err.message : String(err), + })); + }); + + ctx.waitUntil(promise); +} + +/** + * Track an authentication event. + */ +export function trackAuth( + env: Env, + ctx: ExecutionContext, + method: 'magic_link' | 'google_oauth', + step: 'requested' | 'verified' | 'failed', + distinctId: string, + extra?: Record, +): void { + capture(env, ctx, { + event: `auth_${method}_${step}`, + distinctId, + properties: { + auth_method: method, + auth_step: step, + ...extra, + }, + }); +} + +/** + * Track a site lifecycle event. + */ +export function trackSite( + env: Env, + ctx: ExecutionContext, + action: string, + distinctId: string, + extra?: Record, +): void { + capture(env, ctx, { + event: `site_${action}`, + distinctId, + properties: extra, + }); +} + +/** + * Track an error event. + */ +export function trackError( + env: Env, + ctx: ExecutionContext, + errorType: string, + message: string, + extra?: Record, +): void { + capture(env, ctx, { + event: 'server_error', + distinctId: 'system', + properties: { + error_type: errorType, + error_message: message, + ...extra, + }, + }); +} diff --git a/apps/project-sites/src/lib/sentry.ts b/apps/project-sites/src/lib/sentry.ts new file mode 100644 index 0000000000..9f52384a21 --- /dev/null +++ b/apps/project-sites/src/lib/sentry.ts @@ -0,0 +1,85 @@ +/** + * Sentry integration for Cloudflare Workers using toucan-js. + * + * Provides a lightweight Sentry client that works within the Workers runtime. + * Initializes per-request to capture errors, breadcrumbs, and context. + * + * @module lib/sentry + */ + +import { Toucan } from 'toucan-js'; +import type { Context } from 'hono'; +import type { Env, Variables } from '../types/env.js'; + +/** + * Create a Sentry client scoped to a single request. + * + * @param c - Hono context (provides env bindings and request). + * @returns Toucan instance ready for `captureException` / `captureMessage`. + */ +export function createSentry(c: Context<{ Bindings: Env; Variables: Variables }>): Toucan { + const sentry = new Toucan({ + dsn: c.env.SENTRY_DSN, + context: c.executionCtx, + request: c.req.raw, + environment: c.env.ENVIRONMENT ?? 'development', + release: 'project-sites@0.1.0', + sampleRate: c.env.ENVIRONMENT === 'production' ? 0.5 : 1.0, + }); + + sentry.setTag('service', 'project-sites-worker'); + sentry.setTag('request_id', c.get('requestId') ?? 'unknown'); + + const userId = c.get('userId'); + if (userId) { + sentry.setUser({ id: userId }); + } + + return sentry; +} + +/** + * Report an error to Sentry with additional context. + * + * Safe to call even if SENTRY_DSN is not configured (no-ops). + */ +export function captureError( + c: Context<{ Bindings: Env; Variables: Variables }>, + error: unknown, + extra?: Record, +): void { + if (!c.env.SENTRY_DSN) return; + + try { + const sentry = createSentry(c); + if (extra) { + sentry.setExtras(extra); + } + sentry.captureException(error); + } catch { + // Don't let Sentry errors break the app + console.warn(JSON.stringify({ + level: 'warn', + service: 'sentry', + message: 'Failed to report to Sentry', + })); + } +} + +/** + * Send a message-level event to Sentry. + */ +export function captureMessage( + c: Context<{ Bindings: Env; Variables: Variables }>, + message: string, + level: 'info' | 'warning' | 'error' = 'info', +): void { + if (!c.env.SENTRY_DSN) return; + + try { + const sentry = createSentry(c); + sentry.captureMessage(message, level); + } catch { + // Swallow + } +} diff --git a/apps/project-sites/src/middleware/auth.ts b/apps/project-sites/src/middleware/auth.ts new file mode 100644 index 0000000000..68d902ed55 --- /dev/null +++ b/apps/project-sites/src/middleware/auth.ts @@ -0,0 +1,61 @@ +/** + * @module middleware/auth + * @description Bearer-token authentication middleware for Hono. + * + * Extracts a session token from the `Authorization: Bearer ` header, + * validates it against D1, and populates `c.set('userId')` and `c.set('orgId')` + * on the Hono context. If no token is present or the session is invalid, the + * request continues without auth context -- individual routes decide whether + * authentication is required. + * + * @packageDocumentation + */ + +import type { MiddlewareHandler } from 'hono'; +import type { Env, Variables } from '../types/env.js'; +import { getSession } from '../services/auth.js'; +import { dbQueryOne } from '../services/db.js'; + +/** + * Auth middleware that optionally populates userId and orgId on the Hono context. + * + * Does **not** reject unauthenticated requests -- routes that require auth + * should check `c.get('userId')` and throw `unauthorized()` themselves. + * + * @example + * ```ts + * import { authMiddleware } from './middleware/auth.js'; + * app.use('/api/*', authMiddleware); + * ``` + */ +export const authMiddleware: MiddlewareHandler<{ + Bindings: Env; + Variables: Variables; +}> = async (c, next) => { + const authHeader = c.req.header('Authorization'); + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.slice(7); + + if (token) { + const session = await getSession(c.env.DB, token); + + if (session) { + c.set('userId', session.user_id); + + // Look up the user's primary org + const membership = await dbQueryOne<{ org_id: string }>( + c.env.DB, + 'SELECT m.org_id FROM memberships m WHERE m.user_id = ? AND m.deleted_at IS NULL LIMIT 1', + [session.user_id], + ); + + if (membership) { + c.set('orgId', membership.org_id); + } + } + } + } + + await next(); +}; diff --git a/apps/project-sites/src/middleware/error_handler.ts b/apps/project-sites/src/middleware/error_handler.ts new file mode 100644 index 0000000000..c3f795b70c --- /dev/null +++ b/apps/project-sites/src/middleware/error_handler.ts @@ -0,0 +1,128 @@ +import type { ErrorHandler } from 'hono'; +import { AppError } from '@project-sites/shared'; +import { ZodError } from 'zod'; +import type { Env, Variables } from '../types/env.js'; +import { captureError } from '../lib/sentry.js'; +import * as posthog from '../lib/posthog.js'; + +/** + * Global error handler. + * Converts known errors to typed JSON responses. + * Reports errors to Sentry and PostHog for observability. + */ +export const errorHandler: ErrorHandler<{ + Bindings: Env; + Variables: Variables; +}> = (err, c) => { + const requestId = c.get('requestId') ?? 'unknown'; + const url = c.req.url; + const method = c.req.method; + + // Safely access executionCtx (not available in test environments) + let ctx: ExecutionContext | undefined; + try { + ctx = c.executionCtx; + } catch { + // executionCtx not available outside Workers runtime + } + + // AppError: known typed errors + if (err instanceof AppError) { + console.warn( + JSON.stringify({ + level: err.statusCode >= 500 ? 'error' : 'warn', + code: err.code, + message: err.message, + request_id: requestId, + status: err.statusCode, + url, + method, + }), + ); + + // Report 5xx to Sentry + if (err.statusCode >= 500) { + captureError(c, err, { code: err.code, url, method }); + if (ctx) { + posthog.trackError(c.env, ctx, err.code, err.message, { + request_id: requestId, + status: err.statusCode, + url, + }); + } + } + + return c.json(err.toJSON(), err.statusCode as 400); + } + + // ZodError: validation failures (duck-type check handles multiple zod instances in monorepo) + const isZodError = err instanceof ZodError || + (err && typeof err === 'object' && 'issues' in err && Array.isArray((err as ZodError).issues)); + if (isZodError) { + const zodErr = err as ZodError; + const issues = zodErr.issues.map((i) => ({ + path: i.path.join('.'), + message: i.message, + })); + + console.warn( + JSON.stringify({ + level: 'warn', + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + request_id: requestId, + url, + method, + issues, + }), + ); + + return c.json( + { + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + request_id: requestId, + details: { issues }, + }, + }, + 400, + ); + } + + // Unknown errors: log full details, report to Sentry, return generic message + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + const errorStack = err instanceof Error ? err.stack : undefined; + + console.warn( + JSON.stringify({ + level: 'error', + code: 'INTERNAL_ERROR', + message: errorMessage, + request_id: requestId, + url, + method, + stack: errorStack, + }), + ); + + // Always report unknown errors to Sentry + captureError(c, err, { url, method, request_id: requestId }); + if (ctx) { + posthog.trackError(c.env, ctx, 'INTERNAL_ERROR', errorMessage, { + request_id: requestId, + url, + }); + } + + return c.json( + { + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error', + request_id: requestId, + }, + }, + 500, + ); +}; diff --git a/apps/project-sites/src/middleware/payload_limit.ts b/apps/project-sites/src/middleware/payload_limit.ts new file mode 100644 index 0000000000..82aa32ebc5 --- /dev/null +++ b/apps/project-sites/src/middleware/payload_limit.ts @@ -0,0 +1,34 @@ +import type { MiddlewareHandler } from 'hono'; +import { DEFAULT_CAPS, payloadTooLarge } from '@project-sites/shared'; +import type { Env, Variables } from '../types/env.js'; + +/** 25 MB limit for the bolt publish endpoint (dist/ uploads). */ +const PUBLISH_MAX_BYTES = 25 * 1024 * 1024; + +/** + * Enforce max request payload size. + * The `/api/publish/bolt` endpoint gets a larger limit (25 MB) + * since it uploads entire site bundles. + */ +export const payloadLimitMiddleware: MiddlewareHandler<{ + Bindings: Env; + Variables: Variables; +}> = async (c, next) => { + const contentLength = c.req.header('content-length'); + + if (contentLength) { + const size = Number(contentLength); + const url = new URL(c.req.url); + const maxBytes = url.pathname === '/api/publish/bolt' + ? PUBLISH_MAX_BYTES + : DEFAULT_CAPS.MAX_REQUEST_BODY_BYTES; + + if (!Number.isNaN(size) && size > maxBytes) { + throw payloadTooLarge( + `Request body exceeds maximum size of ${maxBytes} bytes`, + ); + } + } + + await next(); +}; diff --git a/apps/project-sites/src/middleware/request_id.ts b/apps/project-sites/src/middleware/request_id.ts new file mode 100644 index 0000000000..b024b5db7e --- /dev/null +++ b/apps/project-sites/src/middleware/request_id.ts @@ -0,0 +1,16 @@ +import type { MiddlewareHandler } from 'hono'; +import type { Env, Variables } from '../types/env.js'; + +/** + * Assigns a unique request ID to every request for tracing. + * Propagates through all downstream services. + */ +export const requestIdMiddleware: MiddlewareHandler<{ + Bindings: Env; + Variables: Variables; +}> = async (c, next) => { + const requestId = c.req.header('x-request-id') ?? crypto.randomUUID(); + c.set('requestId', requestId); + c.header('x-request-id', requestId); + await next(); +}; diff --git a/apps/project-sites/src/middleware/security_headers.ts b/apps/project-sites/src/middleware/security_headers.ts new file mode 100644 index 0000000000..f84275c23d --- /dev/null +++ b/apps/project-sites/src/middleware/security_headers.ts @@ -0,0 +1,81 @@ +/** + * Security-headers middleware for the Project Sites Cloudflare Worker. + * + * Attaches a hardened set of HTTP response headers to **every** response that + * passes through the Hono middleware chain. The headers enforce HTTPS via HSTS, + * prevent MIME-sniffing, deny framing, tighten the referrer, disable + * unnecessary browser APIs, and apply a Content-Security-Policy that allowlists + * only the third-party origins required by the homepage SPA (Stripe, Uppy / + * Transloadit, Lottie, Google Fonts). + * + * | Export | Description | + * | ---------------------------- | ----------------------------------------------- | + * | `securityHeadersMiddleware` | Hono `MiddlewareHandler` that sets all headers | + * + * @example + * ```ts + * import { Hono } from 'hono'; + * import { securityHeadersMiddleware } from './middleware/security_headers.js'; + * + * const app = new Hono(); + * app.use('*', securityHeadersMiddleware); + * ``` + * + * @module security_headers + * @packageDocumentation + */ + +import type { MiddlewareHandler } from 'hono'; +import type { Env, Variables } from '../types/env.js'; + +/** + * Hono middleware that appends security headers to every response. + * + * Headers set: + * + * | Header | Value / Purpose | + * | --------------------------- | ---------------------------------------------------------- | + * | `Strict-Transport-Security` | 2-year HSTS with `includeSubDomains` and `preload` | + * | `X-Content-Type-Options` | `nosniff` -- prevents MIME-type sniffing | + * | `X-Frame-Options` | `DENY` -- blocks all framing (clickjacking defence) | + * | `Referrer-Policy` | `strict-origin-when-cross-origin` | + * | `Permissions-Policy` | Disables camera, microphone, and geolocation | + * | `Content-Security-Policy` | Allowlists `'self'`, inline scripts/styles, and required | + * | | CDN origins (Stripe, Transloadit, Google Fonts, Lottie) | + * + * The middleware calls `await next()` first, then mutates the response headers + * so that **all** downstream handlers benefit from the same policy. + * + * @remarks + * `'unsafe-inline'` is included in `script-src` and `style-src` because the + * marketing homepage (`public/index.html`) relies on inline `', + }), + ).toThrow(); + }); + + // ─── Email validation ────────────────────────────────────── + it('rejects missing email', () => { + expect(() => + contactFormSchema.parse({ name: 'Bob', message: 'Long enough message' }), + ).toThrow(); + }); + + it('rejects invalid email', () => { + expect(() => + contactFormSchema.parse({ + ...validInput, + email: 'not-an-email', + }), + ).toThrow(); + }); + + // ─── Phone validation ───────────────────────────────────── + it('rejects phone over 20 chars', () => { + expect(() => + contactFormSchema.parse({ + ...validInput, + phone: '1'.repeat(21), + }), + ).toThrow(); + }); + + // ─── Message validation ──────────────────────────────────── + it('rejects missing message', () => { + expect(() => + contactFormSchema.parse({ name: 'Bob', email: 'a@b.com' }), + ).toThrow(); + }); + + it('rejects short message (< 10 chars)', () => { + expect(() => + contactFormSchema.parse({ + ...validInput, + message: 'Short', + }), + ).toThrow(); + }); + + it('rejects message over 5000 chars', () => { + expect(() => + contactFormSchema.parse({ + ...validInput, + message: 'A'.repeat(5001), + }), + ).toThrow(); + }); + + it('rejects script tags in message', () => { + expect(() => + contactFormSchema.parse({ + ...validInput, + message: 'Hello world', + }), + ).toThrow(); + }); + + it('rejects javascript: in message', () => { + expect(() => + contactFormSchema.parse({ + ...validInput, + message: 'Check this link: javascript:alert(1)', + }), + ).toThrow(); + }); + + it('allows HTML entities (not script tags) in name', () => { + const result = contactFormSchema.parse({ + ...validInput, + name: 'Test User', + }); + expect(result.name).toBe('Test User'); + }); + + it('allows angle brackets in message when not script tags', () => { + const result = contactFormSchema.parse({ + ...validInput, + message: 'I need a feature like x < 100 and y > 50 in the app', + }); + expect(result.message).toContain('x < 100'); + }); +}); diff --git a/packages/shared/src/__tests__/crypto-extended.test.ts b/packages/shared/src/__tests__/crypto-extended.test.ts new file mode 100644 index 0000000000..56752ebd06 --- /dev/null +++ b/packages/shared/src/__tests__/crypto-extended.test.ts @@ -0,0 +1,132 @@ +import { sha256Hex, hmacSha256, randomHex, randomUUID, generateOtp, timingSafeEqual } from '../utils/crypto.js'; + +describe('sha256Hex extended', () => { + it('produces correct hash for empty string', async () => { + const hash = await sha256Hex(''); + expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + + it('produces correct hash for "hello"', async () => { + const hash = await sha256Hex('hello'); + expect(hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'); + }); + + it('is deterministic', async () => { + const a = await sha256Hex('deterministic-test'); + const b = await sha256Hex('deterministic-test'); + expect(a).toBe(b); + }); + + it('produces different hashes for different inputs', async () => { + const a = await sha256Hex('input-one'); + const b = await sha256Hex('input-two'); + expect(a).not.toBe(b); + }); + + it('handles unicode input', async () => { + const hash = await sha256Hex('\u{1F600}\u{1F4A9}'); + expect(hash).toHaveLength(64); + expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true); + }); + + it('handles long input (1MB)', async () => { + const longStr = 'x'.repeat(1024 * 1024); + const hash = await sha256Hex(longStr); + expect(hash).toHaveLength(64); + expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true); + }); +}); + +describe('hmacSha256 extended', () => { + it('produces correct signature for known test vector', async () => { + const sig = await hmacSha256('key', 'The quick brown fox jumps over the lazy dog'); + expect(sig).toBe('f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'); + }); + + it('produces different signatures for same key, different messages', async () => { + const a = await hmacSha256('shared-key', 'message-alpha'); + const b = await hmacSha256('shared-key', 'message-beta'); + expect(a).not.toBe(b); + }); + + it('produces different signatures for different keys, same message', async () => { + const a = await hmacSha256('key-one', 'same-message'); + const b = await hmacSha256('key-two', 'same-message'); + expect(a).not.toBe(b); + }); + + it('handles empty message', async () => { + const sig = await hmacSha256('some-key', ''); + expect(sig).toHaveLength(64); + expect(/^[0-9a-f]{64}$/.test(sig)).toBe(true); + }); + + it('handles unicode key and message', async () => { + const sig = await hmacSha256('\u{1F511}', '\u{1F4AC}'); + expect(sig).toHaveLength(64); + expect(/^[0-9a-f]{64}$/.test(sig)).toBe(true); + }); +}); + +describe('randomHex extended', () => { + it('returns correct length for various byte counts', () => { + expect(randomHex(1)).toHaveLength(2); + expect(randomHex(8)).toHaveLength(16); + expect(randomHex(32)).toHaveLength(64); + }); + + it('only contains hex characters', () => { + const hex = randomHex(64); + expect(/^[0-9a-f]+$/.test(hex)).toBe(true); + }); + + it('returns empty string for zero bytes', () => { + expect(randomHex(0)).toBe(''); + }); +}); + +describe('randomUUID extended', () => { + it('matches UUID v4 format', () => { + const uuid = randomUUID(); + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it('generates unique values across multiple calls', () => { + const uuids = new Set(Array.from({ length: 50 }, () => randomUUID())); + expect(uuids.size).toBe(50); + }); +}); + +describe('generateOtp extended', () => { + it('generates only digit characters', () => { + const otp = generateOtp(); + expect(/^\d+$/.test(otp)).toBe(true); + }); + + it('respects custom length of 4', () => { + const otp = generateOtp(4); + expect(otp).toHaveLength(4); + expect(/^\d{4}$/.test(otp)).toBe(true); + }); + + it('respects custom length of 8', () => { + const otp = generateOtp(8); + expect(otp).toHaveLength(8); + expect(/^\d{8}$/.test(otp)).toBe(true); + }); +}); + +describe('timingSafeEqual extended', () => { + it('detects single character difference', () => { + expect(timingSafeEqual('abcdef', 'abcdeg')).toBe(false); + }); + + it('detects difference at start of string', () => { + expect(timingSafeEqual('xbcdef', 'abcdef')).toBe(false); + }); + + it('handles long equal strings', () => { + const s = 'a'.repeat(10000); + expect(timingSafeEqual(s, s)).toBe(true); + }); +}); diff --git a/packages/shared/src/__tests__/edge-cases.test.ts b/packages/shared/src/__tests__/edge-cases.test.ts new file mode 100644 index 0000000000..096c224d59 --- /dev/null +++ b/packages/shared/src/__tests__/edge-cases.test.ts @@ -0,0 +1,334 @@ +import { ZodError } from 'zod'; +import { redact, redactObject } from '../utils/redact.js'; +import { sanitizeHtml } from '../utils/sanitize.js'; +import { environmentSchema, stripeModeSchema, envConfigSchema, validateEnvConfig } from '../schemas/config.js'; +import { requireRole, checkPermission } from '../middleware/rbac.js'; +import { slugSchema, emailSchema, metadataSchema } from '../schemas/base.js'; +import { createCheckoutSessionSchema } from '../schemas/billing.js'; +import { PRICING, AUTH, DUNNING, ROLES } from '../constants/index.js'; + +// ─── Helpers ──────────────────────────────────────────────── + +const makeValidConfig = (overrides = {}) => ({ + ENVIRONMENT: 'test', + STRIPE_SECRET_KEY: 'sk_test_1234567890', + STRIPE_PUBLISHABLE_KEY: 'pk_test_1234567890', + STRIPE_WEBHOOK_SECRET: 'whsec_test123', + CF_API_TOKEN: 'cf-token', + CF_ZONE_ID: 'zone-id', + SENDGRID_API_KEY: 'sg-key', + GOOGLE_CLIENT_ID: 'google-id', + GOOGLE_CLIENT_SECRET: 'google-secret', + GOOGLE_PLACES_API_KEY: 'places-key', + SENTRY_DSN: 'https://sentry.example.com/1', + ...overrides, +}); + +// ─── 1. redact() and redactObject() edge cases ───────────── + +describe('redact() edge cases', () => { + it('redacts password=value as SECRET_KV pattern', () => { + const result = redact('password=mySecret123'); + expect(result).toContain('[REDACTED_SECRET]'); + }); + + it('redacts otp: value as SECRET_KV pattern', () => { + const result = redact('otp: 12345678'); + expect(result).toContain('[REDACTED_SECRET]'); + }); + + it('redacts code="value" as SECRET_KV pattern', () => { + const result = redact('code="verification_abc123"'); + expect(result).toContain('[REDACTED_SECRET]'); + }); + + it('redacts both a token and an email in the same string', () => { + const result = redact('key: sk_test_abc123xyz and email: user@example.com'); + expect(result).toContain('[REDACTED_TOKEN]'); + expect(result).toContain('[REDACTED_EMAIL]'); + expect(result).not.toContain('sk_test_'); + expect(result).not.toContain('user@example.com'); + }); +}); + +describe('redactObject() edge cases', () => { + it('passes through null values unchanged', () => { + const result = redactObject({ key: null } as Record); + expect(result.key).toBeNull(); + }); + + it('passes through array values unchanged (arrays are not recursed into)', () => { + const arr = [1, 'secret_value', { password: 'abc' }]; + const result = redactObject({ items: arr } as Record); + expect(result.items).toBe(arr); + }); + + it('redacts deeply nested objects (3 levels)', () => { + const result = redactObject({ + level1: { + level2: { + level3: { + api_key: 'deep-secret', + safe: 'visible', + }, + }, + }, + }); + const l1 = result.level1 as Record; + const l2 = l1.level2 as Record; + const l3 = l2.level3 as Record; + expect(l3.api_key).toBe('[REDACTED]'); + expect(l3.safe).toBe('visible'); + }); + + it('redacts sensitive key "authorization" to [REDACTED]', () => { + const result = redactObject({ authorization: 'Bearer xyz123456' }); + expect(result.authorization).toBe('[REDACTED]'); + }); + + it('redacts sensitive key "cookie" to [REDACTED]', () => { + const result = redactObject({ cookie: 'session=abc123' }); + expect(result.cookie).toBe('[REDACTED]'); + }); + + it('passes through numeric values unchanged', () => { + const result = redactObject({ count: 42, ratio: 3.14 }); + expect(result.count).toBe(42); + expect(result.ratio).toBe(3.14); + }); +}); + +// ─── 2. sanitizeHtml() edge cases ────────────────────────── + +describe('sanitizeHtml() edge cases', () => { + it('strips uppercase SCRIPT tags (case-insensitive)', () => { + const result = sanitizeHtml(''); + expect(result.toLowerCase()).not.toContain(' { + const result = sanitizeHtml(''); + expect(result).not.toContain('onerror'); + }); + + it('strips multiple dangerous elements in one string', () => { + const result = sanitizeHtml(''); + expect(result).not.toContain(' { + const result = sanitizeHtml('<script>'); + expect(result).toBe('<script>'); + }); + + it('returns empty string for empty input', () => { + expect(sanitizeHtml('')).toBe(''); + }); +}); + +// ─── 3. Config schema edge cases ─────────────────────────── + +describe('environmentSchema', () => { + it('accepts "development"', () => { + expect(environmentSchema.parse('development')).toBe('development'); + }); + + it('accepts "staging"', () => { + expect(environmentSchema.parse('staging')).toBe('staging'); + }); + + it('rejects invalid environment string', () => { + expect(() => environmentSchema.parse('invalid')).toThrow(ZodError); + }); +}); + +describe('stripeModeSchema', () => { + it('accepts "test"', () => { + expect(stripeModeSchema.parse('test')).toBe('test'); + }); + + it('accepts "live"', () => { + expect(stripeModeSchema.parse('live')).toBe('live'); + }); + + it('rejects invalid mode string', () => { + expect(() => stripeModeSchema.parse('invalid')).toThrow(ZodError); + }); +}); + +describe('envConfigSchema', () => { + it('rejects production env with mixed keys (sk_live_ + pk_test_)', () => { + const config = makeValidConfig({ + ENVIRONMENT: 'production', + STRIPE_SECRET_KEY: 'sk_live_1234567890', + STRIPE_PUBLISHABLE_KEY: 'pk_test_1234567890', + }); + expect(() => envConfigSchema.parse(config)).toThrow(); + }); + + it('rejects development env with live keys', () => { + const config = makeValidConfig({ + ENVIRONMENT: 'development', + STRIPE_SECRET_KEY: 'sk_live_1234567890', + STRIPE_PUBLISHABLE_KEY: 'pk_live_1234567890', + }); + expect(() => envConfigSchema.parse(config)).toThrow(); + }); + + it('validateEnvConfig succeeds with complete valid config', () => { + const config = makeValidConfig(); + const result = validateEnvConfig(config); + expect(result.ENVIRONMENT).toBe('test'); + }); + + it('allows optional fields (OPENAI_API_KEY, etc.) to be omitted', () => { + const config = makeValidConfig(); + // Ensure no optional keys are present + delete (config as Record).OPENAI_API_KEY; + delete (config as Record).OPEN_ROUTER_API_KEY; + delete (config as Record).GROQ_API_KEY; + const result = envConfigSchema.parse(config); + expect(result.OPENAI_API_KEY).toBeUndefined(); + expect(result.OPEN_ROUTER_API_KEY).toBeUndefined(); + expect(result.GROQ_API_KEY).toBeUndefined(); + }); +}); + +// ─── 4. RBAC edge cases ──────────────────────────────────── + +describe('requireRole() edge cases', () => { + it('returns true when user role equals the minimum (member === member)', () => { + expect(requireRole('member', 'member')).toBe(true); + }); + + it('returns true when user role equals the minimum (viewer === viewer)', () => { + expect(requireRole('viewer', 'viewer')).toBe(true); + }); +}); + +describe('checkPermission() edge cases', () => { + it('admin has admin:read permission', () => { + expect(checkPermission('admin', 'admin:read')).toBe(true); + }); + + it('admin does NOT have admin:write (only owner does)', () => { + expect(checkPermission('admin', 'admin:write')).toBe(false); + }); + + it('member does NOT have member:delete (only owner does)', () => { + expect(checkPermission('member', 'member:delete')).toBe(false); + }); + + it('member has site:write permission', () => { + expect(checkPermission('member', 'site:write')).toBe(true); + }); + + it('viewer with billing_admin flag gets billing:write', () => { + expect(checkPermission('viewer', 'billing:write', true)).toBe(true); + }); + + it('viewer does NOT have site:write', () => { + expect(checkPermission('viewer', 'site:write')).toBe(false); + }); +}); + +// ─── 5. Schema boundary value tests ──────────────────────── + +describe('slugSchema boundary values', () => { + it('accepts exactly 3 characters', () => { + expect(slugSchema.parse('abc')).toBe('abc'); + }); + + it('accepts exactly 63 characters', () => { + const slug = 'a' + 'b'.repeat(61) + 'c'; + expect(slug).toHaveLength(63); + expect(slugSchema.parse(slug)).toBe(slug); + }); + + it('rejects 2 characters', () => { + expect(() => slugSchema.parse('ab')).toThrow(ZodError); + }); + + it('rejects 64 characters', () => { + const slug = 'a' + 'b'.repeat(62) + 'c'; + expect(slug).toHaveLength(64); + expect(() => slugSchema.parse(slug)).toThrow(ZodError); + }); +}); + +describe('emailSchema boundary values', () => { + it('accepts email with + addressing (user+tag@example.com)', () => { + const result = emailSchema.parse('user+tag@example.com'); + expect(result).toBe('user+tag@example.com'); + }); +}); + +describe('metadataSchema boundary values', () => { + it('accepts object whose JSON.stringify is exactly 65536 bytes', () => { + // {"k":"..."} overhead is 8 chars, so value length = 65536 - 8 = 65528 + const value = 'x'.repeat(65528); + const obj = { k: value }; + expect(JSON.stringify(obj)).toHaveLength(65536); + expect(() => metadataSchema.parse(obj)).not.toThrow(); + }); + + it('rejects object whose JSON.stringify exceeds 65536 bytes', () => { + const value = 'x'.repeat(65529); + const obj = { k: value }; + expect(JSON.stringify(obj).length).toBeGreaterThan(65536); + expect(() => metadataSchema.parse(obj)).toThrow(); + }); +}); + +describe('createCheckoutSessionSchema boundary values', () => { + const validUuid = '550e8400-e29b-41d4-a716-446655440000'; + + it('succeeds with optional site_id omitted', () => { + const result = createCheckoutSessionSchema.parse({ + org_id: validUuid, + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel', + }); + expect(result.org_id).toBe(validUuid); + expect(result.site_id).toBeUndefined(); + }); + + it('succeeds with site_id included', () => { + const siteId = '660e8400-e29b-41d4-a716-446655440001'; + const result = createCheckoutSessionSchema.parse({ + org_id: validUuid, + site_id: siteId, + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel', + }); + expect(result.site_id).toBe(siteId); + }); +}); + +// ─── 6. Constants validation ──────────────────────────────── + +describe('constants validation', () => { + it('PRICING.MONTHLY_CENTS equals 5000 ($50)', () => { + expect(PRICING.MONTHLY_CENTS).toBe(5000); + }); + + it('PRICING.CURRENCY equals "usd"', () => { + expect(PRICING.CURRENCY).toBe('usd'); + }); + + it('AUTH.OTP_LENGTH equals 6', () => { + expect(AUTH.OTP_LENGTH).toBe(6); + }); + + it('DUNNING.DOWNGRADE_DAY is greater than all REMINDER_DAYS values', () => { + for (const day of DUNNING.REMINDER_DAYS) { + expect(DUNNING.DOWNGRADE_DAY).toBeGreaterThan(day); + } + }); + + it('ROLES has exactly 4 elements: owner, admin, member, viewer', () => { + expect(ROLES).toHaveLength(4); + expect([...ROLES]).toEqual(['owner', 'admin', 'member', 'viewer']); + }); +}); diff --git a/packages/shared/src/__tests__/middleware.test.ts b/packages/shared/src/__tests__/middleware.test.ts new file mode 100644 index 0000000000..95c102f935 --- /dev/null +++ b/packages/shared/src/__tests__/middleware.test.ts @@ -0,0 +1,158 @@ +import { requireRole, checkPermission } from '../middleware/rbac'; +import { getEntitlements, requireEntitlement } from '../middleware/entitlements'; +import type { Role } from '../constants/index'; + +// ─── RBAC ──────────────────────────────────────────────────── + +describe('requireRole', () => { + it('owner meets owner requirement', () => { + expect(requireRole('owner', 'owner')).toBe(true); + }); + + it('owner meets admin requirement', () => { + expect(requireRole('owner', 'admin')).toBe(true); + }); + + it('owner meets member requirement', () => { + expect(requireRole('owner', 'member')).toBe(true); + }); + + it('owner meets viewer requirement', () => { + expect(requireRole('owner', 'viewer')).toBe(true); + }); + + it('admin does not meet owner requirement', () => { + expect(requireRole('admin', 'owner')).toBe(false); + }); + + it('admin meets admin requirement', () => { + expect(requireRole('admin', 'admin')).toBe(true); + }); + + it('member does not meet admin requirement', () => { + expect(requireRole('member', 'admin')).toBe(false); + }); + + it('viewer meets only viewer requirement', () => { + expect(requireRole('viewer', 'viewer')).toBe(true); + expect(requireRole('viewer', 'member')).toBe(false); + expect(requireRole('viewer', 'admin')).toBe(false); + expect(requireRole('viewer', 'owner')).toBe(false); + }); +}); + +describe('checkPermission', () => { + it('owner has all permissions', () => { + expect(checkPermission('owner', 'org:read')).toBe(true); + expect(checkPermission('owner', 'org:write')).toBe(true); + expect(checkPermission('owner', 'org:delete')).toBe(true); + expect(checkPermission('owner', 'site:read')).toBe(true); + expect(checkPermission('owner', 'site:write')).toBe(true); + expect(checkPermission('owner', 'site:delete')).toBe(true); + expect(checkPermission('owner', 'site:publish')).toBe(true); + expect(checkPermission('owner', 'billing:read')).toBe(true); + expect(checkPermission('owner', 'billing:write')).toBe(true); + expect(checkPermission('owner', 'member:read')).toBe(true); + expect(checkPermission('owner', 'member:write')).toBe(true); + expect(checkPermission('owner', 'member:delete')).toBe(true); + expect(checkPermission('owner', 'admin:read')).toBe(true); + expect(checkPermission('owner', 'admin:write')).toBe(true); + }); + + it('admin cannot delete org', () => { + expect(checkPermission('admin', 'org:delete')).toBe(false); + }); + + it('admin cannot write billing without billing_admin flag', () => { + expect(checkPermission('admin', 'billing:write')).toBe(false); + }); + + it('admin can write billing with billing_admin flag', () => { + expect(checkPermission('admin', 'billing:write', true)).toBe(true); + }); + + it('member cannot delete sites', () => { + expect(checkPermission('member', 'site:delete')).toBe(false); + }); + + it('member can publish sites', () => { + expect(checkPermission('member', 'site:publish')).toBe(true); + }); + + it('viewer has only read permissions', () => { + expect(checkPermission('viewer', 'org:read')).toBe(true); + expect(checkPermission('viewer', 'site:read')).toBe(true); + expect(checkPermission('viewer', 'billing:read')).toBe(true); + expect(checkPermission('viewer', 'member:read')).toBe(true); + expect(checkPermission('viewer', 'org:write')).toBe(false); + expect(checkPermission('viewer', 'site:write')).toBe(false); + expect(checkPermission('viewer', 'site:publish')).toBe(false); + }); + + it('billing_admin flag grants billing:write to any role', () => { + const roles: Role[] = ['owner', 'admin', 'member', 'viewer']; + for (const role of roles) { + expect(checkPermission(role, 'billing:write', true)).toBe(true); + } + }); + + it('billing_admin flag does not grant other permissions', () => { + expect(checkPermission('viewer', 'org:write', true)).toBe(false); + expect(checkPermission('viewer', 'site:write', true)).toBe(false); + }); +}); + +// ─── Entitlements ──────────────────────────────────────────── + +describe('getEntitlements', () => { + const orgId = '00000000-0000-4000-8000-000000000001'; + + it('returns free entitlements', () => { + const ent = getEntitlements(orgId, 'free'); + expect(ent.topBarHidden).toBe(false); + expect(ent.maxCustomDomains).toBe(0); + expect(ent.chatEnabled).toBe(true); + expect(ent.analyticsEnabled).toBe(false); + expect(ent.plan).toBe('free'); + expect(ent.org_id).toBe(orgId); + }); + + it('returns paid entitlements', () => { + const ent = getEntitlements(orgId, 'paid'); + expect(ent.topBarHidden).toBe(true); + expect(ent.maxCustomDomains).toBe(5); + expect(ent.chatEnabled).toBe(true); + expect(ent.analyticsEnabled).toBe(true); + expect(ent.plan).toBe('paid'); + }); +}); + +describe('requireEntitlement', () => { + it('free plan does not have topBarHidden', () => { + expect(requireEntitlement('free', 'topBarHidden')).toBe(false); + }); + + it('paid plan has topBarHidden', () => { + expect(requireEntitlement('paid', 'topBarHidden')).toBe(true); + }); + + it('free plan has chatEnabled', () => { + expect(requireEntitlement('free', 'chatEnabled')).toBe(true); + }); + + it('free plan does not have analyticsEnabled', () => { + expect(requireEntitlement('free', 'analyticsEnabled')).toBe(false); + }); + + it('paid plan has analyticsEnabled', () => { + expect(requireEntitlement('paid', 'analyticsEnabled')).toBe(true); + }); + + it('free plan maxCustomDomains is falsy (0)', () => { + expect(requireEntitlement('free', 'maxCustomDomains')).toBe(false); + }); + + it('paid plan maxCustomDomains is truthy (5)', () => { + expect(requireEntitlement('paid', 'maxCustomDomains')).toBe(true); + }); +}); diff --git a/packages/shared/src/__tests__/schemas-extended.test.ts b/packages/shared/src/__tests__/schemas-extended.test.ts new file mode 100644 index 0000000000..ec9340a7b0 --- /dev/null +++ b/packages/shared/src/__tests__/schemas-extended.test.ts @@ -0,0 +1,1253 @@ +/** + * Additional schema tests for all untested/under-tested schemas. + * Covers: nameSchema, uuidSchema, errorEnvelopeSchema, successEnvelopeSchema, + * orgSchema, membershipSchema, updateMembershipSchema, siteSchema, updateSiteSchema, + * confidenceAttributeSchema, subscriptionSchema, saleWebhookPayloadSchema, + * auditLogSchema, createAuditLogSchema, webhookEventSchema, workflowJobSchema, + * jobEnvelopeSchema, hostnameRecordSchema, hostnameStatusSchema, + * analyticsDailySchema, funnelEventSchema, usageEventSchema, + * apiErrorSchema, userSchema, sessionSchema, verifyMagicLinkSchema, + * loginResponseSchema + */ + +import { z } from 'zod'; +import { uuidSchema, nameSchema, errorEnvelopeSchema, successEnvelopeSchema, slugSchema } from '../schemas/base'; +import { orgSchema, membershipSchema, createMembershipSchema, updateMembershipSchema } from '../schemas/org'; +import { siteSchema, updateSiteSchema, confidenceAttributeSchema, researchDataSchema } from '../schemas/site'; +import { subscriptionSchema, saleWebhookPayloadSchema } from '../schemas/billing'; +import { + userSchema, + sessionSchema, + verifyMagicLinkSchema, + createGoogleOAuthSchema, + loginResponseSchema, +} from '../schemas/auth'; +import { auditLogSchema, createAuditLogSchema } from '../schemas/audit'; +import { webhookEventSchema } from '../schemas/webhook'; +import { workflowJobSchema, jobEnvelopeSchema } from '../schemas/workflow'; +import { hostnameRecordSchema, hostnameStatusSchema } from '../schemas/hostname'; +import { analyticsDailySchema, funnelEventSchema, usageEventSchema } from '../schemas/analytics'; +import { apiErrorSchema } from '../schemas/api'; + +const UUID = '00000000-0000-4000-8000-000000000001'; +const NOW = '2024-01-15T10:30:00.000Z'; + +// ─── uuidSchema ────────────────────────────────────────────── + +describe('uuidSchema', () => { + it('accepts valid UUID v4', () => { + expect(uuidSchema.parse(UUID)).toBe(UUID); + }); + + it('accepts lowercase UUIDs', () => { + expect(uuidSchema.parse('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d')).toBeTruthy(); + }); + + it('rejects non-UUID strings', () => { + expect(() => uuidSchema.parse('not-a-uuid')).toThrow(); + }); + + it('rejects empty string', () => { + expect(() => uuidSchema.parse('')).toThrow(); + }); + + it('rejects null', () => { + expect(() => uuidSchema.parse(null)).toThrow(); + }); + + it('rejects numbers', () => { + expect(() => uuidSchema.parse(12345)).toThrow(); + }); +}); + +// ─── nameSchema ────────────────────────────────────────────── + +describe('nameSchema', () => { + it('accepts normal names', () => { + expect(nameSchema.parse('My Business')).toBe('My Business'); + }); + + it('accepts single character name', () => { + expect(nameSchema.parse('A')).toBe('A'); + }); + + it('accepts name at max length (200)', () => { + const name = 'a'.repeat(200); + expect(nameSchema.parse(name)).toBe(name); + }); + + it('rejects name over 200 chars', () => { + expect(() => nameSchema.parse('a'.repeat(201))).toThrow(); + }); + + it('rejects empty string', () => { + expect(() => nameSchema.parse('')).toThrow(); + }); + + it('rejects script injection', () => { + expect(() => nameSchema.parse('')).toThrow(); + }); + + it('accepts names with special chars (no script tags)', () => { + expect(nameSchema.parse("O'Brien & Sons")).toBe("O'Brien & Sons"); + }); + + it('accepts names with unicode', () => { + expect(nameSchema.parse('Café München')).toBe('Café München'); + }); +}); + +// ─── errorEnvelopeSchema ───────────────────────────────────── + +describe('errorEnvelopeSchema', () => { + it('accepts valid error envelope', () => { + const result = errorEnvelopeSchema.parse({ + error: { code: 'ERR', message: 'Something went wrong' }, + }); + expect(result.error.code).toBe('ERR'); + }); + + it('accepts error with request_id', () => { + const result = errorEnvelopeSchema.parse({ + error: { code: 'ERR', message: 'test', request_id: 'req-123' }, + }); + expect(result.error.request_id).toBe('req-123'); + }); + + it('accepts error with details', () => { + const result = errorEnvelopeSchema.parse({ + error: { code: 'ERR', message: 'test', details: { field: 'name' } }, + }); + expect(result.error.details).toEqual({ field: 'name' }); + }); + + it('rejects missing code', () => { + expect(() => errorEnvelopeSchema.parse({ error: { message: 'test' } })).toThrow(); + }); + + it('rejects missing message', () => { + expect(() => errorEnvelopeSchema.parse({ error: { code: 'ERR' } })).toThrow(); + }); +}); + +// ─── successEnvelopeSchema ─────────────────────────────────── + +describe('successEnvelopeSchema', () => { + it('wraps data with correct type', () => { + const schema = successEnvelopeSchema(z.object({ id: z.string() })); + const result = schema.parse({ data: { id: 'abc' } }); + expect(result.data.id).toBe('abc'); + }); + + it('accepts optional meta', () => { + const schema = successEnvelopeSchema(z.string()); + const result = schema.parse({ + data: 'hello', + meta: { request_id: 'req-1', total: 100, limit: 20, offset: 0 }, + }); + expect(result.meta?.total).toBe(100); + }); + + it('allows missing meta', () => { + const schema = successEnvelopeSchema(z.number()); + const result = schema.parse({ data: 42 }); + expect(result.data).toBe(42); + expect(result.meta).toBeUndefined(); + }); + + it('rejects when data type is wrong', () => { + const schema = successEnvelopeSchema(z.string()); + expect(() => schema.parse({ data: 42 })).toThrow(); + }); +}); + +// ─── orgSchema ─────────────────────────────────────────────── + +describe('orgSchema', () => { + it('accepts valid org', () => { + const result = orgSchema.parse({ + id: UUID, + name: 'My Org', + slug: 'my-org', + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }); + expect(result.name).toBe('My Org'); + }); + + it('accepts org with deleted_at', () => { + const result = orgSchema.parse({ + id: UUID, + name: 'Deleted Org', + slug: 'deleted-org', + created_at: NOW, + updated_at: NOW, + deleted_at: NOW, + }); + expect(result.deleted_at).toBe(NOW); + }); + + it('rejects org without required fields', () => { + expect(() => orgSchema.parse({ id: UUID })).toThrow(); + }); + + it('rejects org with invalid slug', () => { + expect(() => + orgSchema.parse({ + id: UUID, + name: 'Test', + slug: 'BAD', + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }), + ).toThrow(); + }); +}); + +// ─── membershipSchema ──────────────────────────────────────── + +describe('membershipSchema', () => { + const validMembership = { + id: UUID, + org_id: UUID, + user_id: UUID, + role: 'member' as const, + billing_admin: false, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid membership', () => { + const result = membershipSchema.parse(validMembership); + expect(result.role).toBe('member'); + }); + + it('accepts all valid roles', () => { + for (const role of ['owner', 'admin', 'member', 'viewer']) { + const result = membershipSchema.parse({ ...validMembership, role }); + expect(result.role).toBe(role); + } + }); + + it('rejects invalid role', () => { + expect(() => membershipSchema.parse({ ...validMembership, role: 'superadmin' })).toThrow(); + }); + + it('defaults billing_admin to false', () => { + const result = createMembershipSchema.parse({ + user_id: UUID, + org_id: UUID, + role: 'admin', + }); + expect(result.billing_admin).toBe(false); + }); +}); + +// ─── updateMembershipSchema ────────────────────────────────── + +describe('updateMembershipSchema', () => { + it('accepts partial role update', () => { + const result = updateMembershipSchema.parse({ role: 'admin' }); + expect(result.role).toBe('admin'); + }); + + it('accepts partial billing_admin update', () => { + const result = updateMembershipSchema.parse({ billing_admin: true }); + expect(result.billing_admin).toBe(true); + }); + + it('accepts empty update', () => { + const result = updateMembershipSchema.parse({}); + expect(result.role).toBeUndefined(); + }); + + it('rejects invalid role in update', () => { + expect(() => updateMembershipSchema.parse({ role: 'invalid' })).toThrow(); + }); +}); + +// ─── siteSchema ────────────────────────────────────────────── + +describe('siteSchema', () => { + const validSite = { + id: UUID, + org_id: UUID, + slug: 'my-site', + business_name: 'My Site', + business_phone: null, + business_email: null, + business_address: null, + google_place_id: null, + bolt_chat_id: null, + current_build_version: null, + status: 'draft' as const, + lighthouse_score: null, + lighthouse_last_run: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid site', () => { + const result = siteSchema.parse(validSite); + expect(result.slug).toBe('my-site'); + }); + + it('accepts all valid statuses', () => { + for (const status of ['draft', 'building', 'published', 'archived']) { + const result = siteSchema.parse({ ...validSite, status }); + expect(result.status).toBe(status); + } + }); + + it('rejects invalid status', () => { + expect(() => siteSchema.parse({ ...validSite, status: 'deleted' })).toThrow(); + }); + + it('accepts lighthouse score in range', () => { + const result = siteSchema.parse({ ...validSite, lighthouse_score: 95 }); + expect(result.lighthouse_score).toBe(95); + }); + + it('rejects lighthouse score > 100', () => { + expect(() => siteSchema.parse({ ...validSite, lighthouse_score: 150 })).toThrow(); + }); + + it('rejects lighthouse score < 0', () => { + expect(() => siteSchema.parse({ ...validSite, lighthouse_score: -1 })).toThrow(); + }); +}); + +// ─── updateSiteSchema ──────────────────────────────────────── + +describe('updateSiteSchema', () => { + it('accepts partial update with business name', () => { + const result = updateSiteSchema.parse({ business_name: 'New Name' }); + expect(result.business_name).toBe('New Name'); + }); + + it('accepts setting nullable fields to null', () => { + const result = updateSiteSchema.parse({ business_phone: null, business_email: null }); + expect(result.business_phone).toBeNull(); + }); + + it('accepts status change', () => { + const result = updateSiteSchema.parse({ status: 'published' }); + expect(result.status).toBe('published'); + }); + + it('accepts build version update', () => { + const result = updateSiteSchema.parse({ current_build_version: 'v1.2.3' }); + expect(result.current_build_version).toBe('v1.2.3'); + }); + + it('accepts empty update', () => { + const result = updateSiteSchema.parse({}); + expect(Object.keys(result).length).toBe(0); + }); + + it('rejects invalid status', () => { + expect(() => updateSiteSchema.parse({ status: 'invalid' })).toThrow(); + }); +}); + +// ─── confidenceAttributeSchema ─────────────────────────────── + +describe('confidenceAttributeSchema', () => { + const valid = { + id: UUID, + org_id: UUID, + site_id: UUID, + attribute_name: 'business_name', + attribute_value: 'Joe Pizza', + confidence: 95, + source: 'google_places', + rationale: 'Matched via Places API', + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid confidence attribute', () => { + const result = confidenceAttributeSchema.parse(valid); + expect(result.confidence).toBe(95); + }); + + it('accepts 0 confidence', () => { + const result = confidenceAttributeSchema.parse({ ...valid, confidence: 0 }); + expect(result.confidence).toBe(0); + }); + + it('accepts 100 confidence', () => { + const result = confidenceAttributeSchema.parse({ ...valid, confidence: 100 }); + expect(result.confidence).toBe(100); + }); + + it('rejects confidence > 100', () => { + expect(() => confidenceAttributeSchema.parse({ ...valid, confidence: 101 })).toThrow(); + }); + + it('accepts null rationale', () => { + const result = confidenceAttributeSchema.parse({ ...valid, rationale: null }); + expect(result.rationale).toBeNull(); + }); +}); + +// ─── subscriptionSchema ────────────────────────────────────── + +describe('subscriptionSchema', () => { + const valid = { + id: UUID, + org_id: UUID, + stripe_customer_id: 'cus_abc123', + stripe_subscription_id: null, + plan: 'free' as const, + status: 'active' as const, + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + retention_offer_applied: false, + dunning_stage: 0, + last_payment_at: null, + last_payment_failed_at: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid free subscription', () => { + const result = subscriptionSchema.parse(valid); + expect(result.plan).toBe('free'); + }); + + it('accepts paid subscription', () => { + const result = subscriptionSchema.parse({ + ...valid, + plan: 'paid', + stripe_subscription_id: 'sub_xyz', + current_period_start: NOW, + current_period_end: NOW, + }); + expect(result.plan).toBe('paid'); + }); + + it('accepts all valid statuses', () => { + for (const status of [ + 'active', + 'past_due', + 'canceled', + 'unpaid', + 'trialing', + 'incomplete', + 'incomplete_expired', + 'paused', + ]) { + const result = subscriptionSchema.parse({ ...valid, status }); + expect(result.status).toBe(status); + } + }); + + it('rejects dunning_stage > 60', () => { + expect(() => subscriptionSchema.parse({ ...valid, dunning_stage: 61 })).toThrow(); + }); + + it('rejects dunning_stage < 0', () => { + expect(() => subscriptionSchema.parse({ ...valid, dunning_stage: -1 })).toThrow(); + }); + + it('rejects invalid plan', () => { + expect(() => subscriptionSchema.parse({ ...valid, plan: 'premium' })).toThrow(); + }); +}); + +// ─── saleWebhookPayloadSchema ──────────────────────────────── + +describe('saleWebhookPayloadSchema', () => { + const valid = { + site_id: null, + org_id: UUID, + stripe_customer_id: 'cus_123', + stripe_subscription_id: 'sub_456', + plan: 'paid' as const, + amount_cents: 5000, + currency: 'usd', + timestamp: NOW, + request_id: 'req-abc', + trace_id: 'trace-xyz', + }; + + it('accepts valid sale webhook payload', () => { + const result = saleWebhookPayloadSchema.parse(valid); + expect(result.amount_cents).toBe(5000); + }); + + it('accepts null site_id', () => { + const result = saleWebhookPayloadSchema.parse(valid); + expect(result.site_id).toBeNull(); + }); + + it('accepts site_id as UUID', () => { + const result = saleWebhookPayloadSchema.parse({ ...valid, site_id: UUID }); + expect(result.site_id).toBe(UUID); + }); + + it('rejects negative amount', () => { + expect(() => saleWebhookPayloadSchema.parse({ ...valid, amount_cents: -100 })).toThrow(); + }); + + it('rejects currency not 3 chars', () => { + expect(() => saleWebhookPayloadSchema.parse({ ...valid, currency: 'us' })).toThrow(); + expect(() => saleWebhookPayloadSchema.parse({ ...valid, currency: 'usdx' })).toThrow(); + }); +}); + +// ─── auditLogSchema ────────────────────────────────────────── + +describe('auditLogSchema', () => { + it('accepts valid audit log entry', () => { + const result = auditLogSchema.parse({ + id: UUID, + org_id: UUID, + actor_id: UUID, + action: 'site.created', + target_type: 'site', + target_id: UUID, + metadata_json: { key: 'value' }, + ip_address: '192.168.1.1', + request_id: 'req-123', + created_at: NOW, + }); + expect(result.action).toBe('site.created'); + }); + + it('accepts null actor_id for system actions', () => { + const result = auditLogSchema.parse({ + id: UUID, + org_id: UUID, + actor_id: null, + action: 'webhook.processed', + target_type: null, + target_id: null, + metadata_json: null, + ip_address: null, + request_id: null, + created_at: NOW, + }); + expect(result.actor_id).toBeNull(); + }); + + it('rejects empty action', () => { + expect(() => + auditLogSchema.parse({ + id: UUID, + org_id: UUID, + actor_id: null, + action: '', + target_type: null, + target_id: null, + metadata_json: null, + ip_address: null, + request_id: null, + created_at: NOW, + }), + ).toThrow(); + }); + + it('rejects action over 100 chars', () => { + expect(() => + auditLogSchema.parse({ + id: UUID, + org_id: UUID, + actor_id: null, + action: 'a'.repeat(101), + target_type: null, + target_id: null, + metadata_json: null, + ip_address: null, + request_id: null, + created_at: NOW, + }), + ).toThrow(); + }); +}); + +// ─── createAuditLogSchema ──────────────────────────────────── + +describe('createAuditLogSchema', () => { + it('accepts minimal audit log creation', () => { + const result = createAuditLogSchema.parse({ + org_id: UUID, + actor_id: null, + action: 'login', + }); + expect(result.action).toBe('login'); + }); + + it('accepts full audit log creation', () => { + const result = createAuditLogSchema.parse({ + org_id: UUID, + actor_id: UUID, + action: 'billing.changed', + target_type: 'subscription', + target_id: UUID, + metadata_json: { plan: 'paid' }, + ip_address: '10.0.0.1', + request_id: 'req-abc', + }); + expect(result.target_type).toBe('subscription'); + }); + + it('rejects missing org_id', () => { + expect(() => createAuditLogSchema.parse({ action: 'test', actor_id: null })).toThrow(); + }); +}); + +// ─── webhookEventSchema ────────────────────────────────────── + +describe('webhookEventSchema', () => { + const valid = { + id: UUID, + org_id: null, + provider: 'stripe' as const, + event_id: 'evt_123', + event_type: 'checkout.session.completed', + payload_pointer: null, + payload_hash: null, + status: 'received' as const, + error_message: null, + attempts: 0, + processed_at: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid webhook event', () => { + const result = webhookEventSchema.parse(valid); + expect(result.provider).toBe('stripe'); + }); + + it('accepts all valid statuses', () => { + for (const status of ['received', 'processing', 'processed', 'failed', 'quarantined']) { + const result = webhookEventSchema.parse({ ...valid, status }); + expect(result.status).toBe(status); + } + }); + + it('accepts all valid providers', () => { + for (const provider of ['stripe', 'dub', 'chatwoot', 'novu', 'lago']) { + const result = webhookEventSchema.parse({ ...valid, provider }); + expect(result.provider).toBe(provider); + } + }); + + it('rejects event_id over 500 chars', () => { + expect(() => webhookEventSchema.parse({ ...valid, event_id: 'x'.repeat(501) })).toThrow(); + }); + + it('accepts error message up to 2000 chars', () => { + const result = webhookEventSchema.parse({ + ...valid, + status: 'failed', + error_message: 'e'.repeat(2000), + }); + expect(result.error_message?.length).toBe(2000); + }); +}); + +// ─── workflowJobSchema ─────────────────────────────────────── + +describe('workflowJobSchema', () => { + const valid = { + id: UUID, + org_id: UUID, + job_name: 'generate_site', + site_id: UUID, + dedupe_key: 'site:abc:generate', + payload_pointer: 'r2://payloads/abc.json', + status: 'queued' as const, + attempt: 0, + max_attempts: 3, + started_at: null, + completed_at: null, + error_message: null, + result_pointer: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid queued job', () => { + const result = workflowJobSchema.parse(valid); + expect(result.status).toBe('queued'); + }); + + it('accepts all valid statuses', () => { + for (const status of ['queued', 'running', 'success', 'failed']) { + const result = workflowJobSchema.parse({ ...valid, status }); + expect(result.status).toBe(status); + } + }); + + it('rejects max_attempts > 10', () => { + expect(() => workflowJobSchema.parse({ ...valid, max_attempts: 11 })).toThrow(); + }); + + it('rejects max_attempts < 1', () => { + expect(() => workflowJobSchema.parse({ ...valid, max_attempts: 0 })).toThrow(); + }); + + it('rejects attempt < 0', () => { + expect(() => workflowJobSchema.parse({ ...valid, attempt: -1 })).toThrow(); + }); +}); + +// ─── jobEnvelopeSchema ─────────────────────────────────────── + +describe('jobEnvelopeSchema', () => { + it('accepts valid job envelope', () => { + const result = jobEnvelopeSchema.parse({ + job_id: UUID, + job_name: 'generate_site', + org_id: UUID, + dedupe_key: null, + payload_pointer: null, + attempt: 0, + max_attempts: 3, + }); + expect(result.job_name).toBe('generate_site'); + }); + + it('rejects missing required fields', () => { + expect(() => jobEnvelopeSchema.parse({ job_id: UUID })).toThrow(); + }); + + it('accepts dedupe_key', () => { + const result = jobEnvelopeSchema.parse({ + job_id: UUID, + job_name: 'test', + org_id: UUID, + dedupe_key: 'site:abc:v1', + payload_pointer: null, + attempt: 1, + max_attempts: 5, + }); + expect(result.dedupe_key).toBe('site:abc:v1'); + }); +}); + +// ─── hostnameRecordSchema ──────────────────────────────────── + +describe('hostnameRecordSchema', () => { + const valid = { + id: UUID, + org_id: UUID, + site_id: UUID, + hostname: 'test-sites.megabyte.space', + type: 'free_subdomain' as const, + status: 'active' as const, + cf_custom_hostname_id: 'cf-id-123', + ssl_status: 'active' as const, + verification_errors: null, + last_verified_at: NOW, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid hostname record', () => { + const result = hostnameRecordSchema.parse(valid); + expect(result.status).toBe('active'); + }); + + it('accepts all valid hostname statuses', () => { + for (const status of ['pending', 'active', 'moved', 'deleted', 'pending_deletion', 'verification_failed']) { + const result = hostnameRecordSchema.parse({ ...valid, status }); + expect(result.status).toBe(status); + } + }); + + it('accepts all valid SSL statuses', () => { + for (const ssl_status of ['pending', 'active', 'error', 'unknown']) { + const result = hostnameRecordSchema.parse({ ...valid, ssl_status }); + expect(result.ssl_status).toBe(ssl_status); + } + }); + + it('accepts verification_errors array', () => { + const result = hostnameRecordSchema.parse({ + ...valid, + status: 'verification_failed', + verification_errors: ['DNS not resolving', 'SSL timeout'], + }); + expect(result.verification_errors).toHaveLength(2); + }); + + it('rejects more than 10 verification errors', () => { + expect(() => + hostnameRecordSchema.parse({ + ...valid, + verification_errors: Array.from({ length: 11 }, (_, i) => `error ${i}`), + }), + ).toThrow(); + }); +}); + +// ─── hostnameStatusSchema ──────────────────────────────────── + +describe('hostnameStatusSchema', () => { + it('accepts valid status check response', () => { + const result = hostnameStatusSchema.parse({ + hostname: 'test.example.com', + status: 'active', + ssl_status: 'active', + verification_errors: null, + }); + expect(result.hostname).toBe('test.example.com'); + }); + + it('accepts pending with errors', () => { + const result = hostnameStatusSchema.parse({ + hostname: 'test.example.com', + status: 'verification_failed', + ssl_status: 'error', + verification_errors: ['CNAME not set'], + }); + expect(result.verification_errors).toHaveLength(1); + }); +}); + +// ─── analyticsDailySchema ──────────────────────────────────── + +describe('analyticsDailySchema', () => { + const valid = { + id: UUID, + org_id: UUID, + site_id: UUID, + date: '2024-01-15', + page_views: 100, + unique_visitors: 50, + bandwidth_bytes: 1024000, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid daily analytics', () => { + const result = analyticsDailySchema.parse(valid); + expect(result.page_views).toBe(100); + }); + + it('rejects invalid date format', () => { + expect(() => analyticsDailySchema.parse({ ...valid, date: '2024-1-15' })).toThrow(); + expect(() => analyticsDailySchema.parse({ ...valid, date: 'Jan 15' })).toThrow(); + }); + + it('defaults counters to 0', () => { + const result = analyticsDailySchema.parse({ + ...valid, + page_views: 0, + unique_visitors: 0, + bandwidth_bytes: 0, + }); + expect(result.page_views).toBe(0); + }); + + it('rejects negative counters', () => { + expect(() => analyticsDailySchema.parse({ ...valid, page_views: -1 })).toThrow(); + }); +}); + +// ─── funnelEventSchema ─────────────────────────────────────── + +describe('funnelEventSchema', () => { + it('accepts all valid event names', () => { + const events = [ + 'signup_started', + 'signup_completed', + 'site_created', + 'first_publish', + 'first_payment', + 'invite_sent', + 'invite_accepted', + 'churned', + ]; + for (const event_name of events) { + const result = funnelEventSchema.parse({ + id: UUID, + org_id: UUID, + user_id: null, + site_id: null, + event_name, + metadata_json: null, + created_at: NOW, + }); + expect(result.event_name).toBe(event_name); + } + }); + + it('rejects invalid event name', () => { + expect(() => + funnelEventSchema.parse({ + id: UUID, + org_id: UUID, + user_id: null, + site_id: null, + event_name: 'unknown_event', + metadata_json: null, + created_at: NOW, + }), + ).toThrow(); + }); + + it('accepts metadata', () => { + const result = funnelEventSchema.parse({ + id: UUID, + org_id: UUID, + user_id: UUID, + site_id: UUID, + event_name: 'first_publish', + metadata_json: { version: '1.0', slug: 'test' }, + created_at: NOW, + }); + expect(result.metadata_json).toEqual({ version: '1.0', slug: 'test' }); + }); +}); + +// ─── usageEventSchema ──────────────────────────────────────── + +describe('usageEventSchema', () => { + it('accepts valid usage event', () => { + const result = usageEventSchema.parse({ + id: UUID, + org_id: UUID, + event_type: 'llm_call', + quantity: 1, + metadata_json: { model: 'gpt-4', tokens: 500 }, + created_at: NOW, + }); + expect(result.event_type).toBe('llm_call'); + }); + + it('rejects negative quantity', () => { + expect(() => + usageEventSchema.parse({ + id: UUID, + org_id: UUID, + event_type: 'test', + quantity: -1, + metadata_json: null, + created_at: NOW, + }), + ).toThrow(); + }); + + it('rejects event_type over 100 chars', () => { + expect(() => + usageEventSchema.parse({ + id: UUID, + org_id: UUID, + event_type: 'x'.repeat(101), + quantity: 1, + metadata_json: null, + created_at: NOW, + }), + ).toThrow(); + }); +}); + +// ─── apiErrorSchema ────────────────────────────────────────── + +describe('apiErrorSchema', () => { + it('accepts valid API error', () => { + const result = apiErrorSchema.parse({ + error: { + code: 'BAD_REQUEST', + message: 'Invalid input', + }, + }); + expect(result.error.code).toBe('BAD_REQUEST'); + }); + + it('accepts all valid error codes', () => { + const codes = [ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'CONFLICT', + 'PAYLOAD_TOO_LARGE', + 'RATE_LIMITED', + 'VALIDATION_ERROR', + 'INTERNAL_ERROR', + 'SERVICE_UNAVAILABLE', + 'WEBHOOK_SIGNATURE_INVALID', + 'WEBHOOK_DUPLICATE', + 'IDEMPOTENCY_CONFLICT', + 'STRIPE_ERROR', + 'DOMAIN_PROVISIONING_ERROR', + 'AI_GENERATION_ERROR', + 'LIGHTHOUSE_FAILURE', + ]; + for (const code of codes) { + const result = apiErrorSchema.parse({ error: { code, message: 'test' } }); + expect(result.error.code).toBe(code); + } + }); + + it('rejects invalid error code', () => { + expect(() => apiErrorSchema.parse({ error: { code: 'UNKNOWN', message: 'test' } })).toThrow(); + }); + + it('rejects message over 2000 chars', () => { + expect(() => apiErrorSchema.parse({ error: { code: 'BAD_REQUEST', message: 'x'.repeat(2001) } })).toThrow(); + }); +}); + +// ─── userSchema ────────────────────────────────────────────── + +describe('userSchema', () => { + it('accepts valid user', () => { + const result = userSchema.parse({ + id: UUID, + email: 'test@example.com', + phone: '+14155551234', + display_name: 'John Doe', + avatar_url: 'https://example.com/avatar.jpg', + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }); + expect(result.email).toBe('test@example.com'); + }); + + it('accepts user with all nullable fields null', () => { + const result = userSchema.parse({ + id: UUID, + email: null, + phone: null, + display_name: null, + avatar_url: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }); + expect(result.email).toBeNull(); + }); + + it('rejects invalid email', () => { + expect(() => + userSchema.parse({ + id: UUID, + email: 'not-email', + phone: null, + display_name: null, + avatar_url: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }), + ).toThrow(); + }); + + it('rejects phone over 20 characters', () => { + expect(() => + userSchema.parse({ + id: UUID, + email: null, + phone: '+1234567890123456789012345', + display_name: null, + avatar_url: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }), + ).toThrow(); + }); +}); + +// ─── sessionSchema ─────────────────────────────────────────── + +describe('sessionSchema', () => { + it('accepts valid session', () => { + const result = sessionSchema.parse({ + id: UUID, + user_id: UUID, + token_hash: 'abcdef1234567890abcdef1234567890', + device_info: 'Chrome on macOS', + ip_address: '192.168.1.1', + expires_at: NOW, + last_active_at: NOW, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }); + expect(result.user_id).toBe(UUID); + }); + + it('accepts IPv6 addresses', () => { + const result = sessionSchema.parse({ + id: UUID, + user_id: UUID, + token_hash: 'hash123', + device_info: null, + ip_address: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + expires_at: NOW, + last_active_at: NOW, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }); + expect(result.ip_address).toBeTruthy(); + }); + + it('rejects token_hash over 128 chars', () => { + expect(() => + sessionSchema.parse({ + id: UUID, + user_id: UUID, + token_hash: 'x'.repeat(129), + device_info: null, + ip_address: null, + expires_at: NOW, + last_active_at: NOW, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }), + ).toThrow(); + }); +}); + +// ─── verifyMagicLinkSchema ─────────────────────────────────── + +describe('verifyMagicLinkSchema', () => { + it('accepts valid token', () => { + const result = verifyMagicLinkSchema.parse({ token: 'a'.repeat(64) }); + expect(result.token).toHaveLength(64); + }); + + it('rejects token shorter than 32 chars', () => { + expect(() => verifyMagicLinkSchema.parse({ token: 'short' })).toThrow(); + }); + + it('rejects token longer than 512 chars', () => { + expect(() => verifyMagicLinkSchema.parse({ token: 'a'.repeat(513) })).toThrow(); + }); + + it('rejects empty token', () => { + expect(() => verifyMagicLinkSchema.parse({ token: '' })).toThrow(); + }); + + it('rejects missing token', () => { + expect(() => verifyMagicLinkSchema.parse({})).toThrow(); + }); +}); + +// ─── createGoogleOAuthSchema ───────────────────────────────── + +describe('createGoogleOAuthSchema', () => { + it('accepts with redirect_url', () => { + const result = createGoogleOAuthSchema.parse({ + redirect_url: 'https://example.com/callback', + }); + expect(result.redirect_url).toBe('https://example.com/callback'); + }); + + it('accepts without redirect_url', () => { + const result = createGoogleOAuthSchema.parse({}); + expect(result.redirect_url).toBeUndefined(); + }); + + it('rejects invalid redirect_url', () => { + expect(() => createGoogleOAuthSchema.parse({ redirect_url: 'not-a-url' })).toThrow(); + }); +}); + +// ─── loginResponseSchema ───────────────────────────────────── + +describe('loginResponseSchema', () => { + it('accepts valid login response', () => { + const result = loginResponseSchema.parse({ + user: { + id: UUID, + email: 'test@example.com', + phone: null, + display_name: null, + avatar_url: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }, + session: { + token: 'session-token-abc', + expires_at: NOW, + }, + requires_2fa: true, + }); + expect(result.requires_2fa).toBe(true); + }); + + it('rejects missing session token', () => { + expect(() => + loginResponseSchema.parse({ + user: { + id: UUID, + email: null, + phone: null, + display_name: null, + avatar_url: null, + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }, + session: { expires_at: NOW }, + requires_2fa: false, + }), + ).toThrow(); + }); +}); + +// ─── researchDataSchema ────────────────────────────────────── + +describe('researchDataSchema', () => { + const valid = { + id: UUID, + org_id: UUID, + site_id: UUID, + task_name: 'nap_verification', + raw_output: 'Business Name: Test Co', + parsed_output: { name: 'Test Co', verified: true }, + confidence: 85, + source_urls: ['https://example.com'], + created_at: NOW, + updated_at: NOW, + deleted_at: null, + }; + + it('accepts valid research data', () => { + const result = researchDataSchema.parse(valid); + expect(result.task_name).toBe('nap_verification'); + }); + + it('rejects raw_output over 65536 chars', () => { + expect(() => researchDataSchema.parse({ ...valid, raw_output: 'x'.repeat(65537) })).toThrow(); + }); + + it('rejects more than 20 source URLs', () => { + expect(() => + researchDataSchema.parse({ + ...valid, + source_urls: Array.from({ length: 21 }, (_, i) => `https://example.com/${i}`), + }), + ).toThrow(); + }); + + it('accepts null parsed_output', () => { + const result = researchDataSchema.parse({ ...valid, parsed_output: null }); + expect(result.parsed_output).toBeNull(); + }); +}); diff --git a/packages/shared/src/__tests__/schemas.test.ts b/packages/shared/src/__tests__/schemas.test.ts new file mode 100644 index 0000000000..5e879f3372 --- /dev/null +++ b/packages/shared/src/__tests__/schemas.test.ts @@ -0,0 +1,589 @@ +import { z } from 'zod'; +import { + slugSchema, + emailSchema, + phoneSchema, + hostnameSchema, + httpsUrlSchema, + safeStringSchema, + nameSchema, + paginationSchema, + confidenceScoreSchema, + metadataSchema, +} from '../schemas/base'; +import { createOrgSchema, membershipSchema } from '../schemas/org'; +import { createSiteSchema, siteSchema } from '../schemas/site'; +import { createCheckoutSessionSchema, entitlementsSchema, saleWebhookPayloadSchema } from '../schemas/billing'; +import { createMagicLinkSchema, googleOAuthCallbackSchema } from '../schemas/auth'; +import { createAuditLogSchema } from '../schemas/audit'; +import { webhookIngestionSchema } from '../schemas/webhook'; +import { createWorkflowJobSchema, jobEnvelopeSchema } from '../schemas/workflow'; +import { envConfigSchema, validateEnvConfig } from '../schemas/config'; +import { healthCheckSchema } from '../schemas/api'; +import { createHostnameSchema, hostnameRecordSchema } from '../schemas/hostname'; + +// ─── Base Schemas ──────────────────────────────────────────── + +describe('slugSchema', () => { + it('accepts valid slugs', () => { + expect(slugSchema.parse('my-business')).toBe('my-business'); + expect(slugSchema.parse('abc')).toBe('abc'); + expect(slugSchema.parse('a1b2c3')).toBe('a1b2c3'); + }); + + it('rejects slugs shorter than 3 chars', () => { + expect(() => slugSchema.parse('ab')).toThrow(); + }); + + it('rejects slugs longer than 63 chars', () => { + expect(() => slugSchema.parse('a'.repeat(64))).toThrow(); + }); + + it('rejects slugs starting with hyphen', () => { + expect(() => slugSchema.parse('-bad-slug')).toThrow(); + }); + + it('rejects slugs ending with hyphen', () => { + expect(() => slugSchema.parse('bad-slug-')).toThrow(); + }); + + it('rejects slugs with uppercase', () => { + expect(() => slugSchema.parse('MyBusiness')).toThrow(); + }); + + it('rejects slugs with special characters', () => { + expect(() => slugSchema.parse('my_business!')).toThrow(); + }); + + it('rejects empty string', () => { + expect(() => slugSchema.parse('')).toThrow(); + }); + + it('rejects slugs with spaces', () => { + expect(() => slugSchema.parse('my business')).toThrow(); + }); + + it('rejects null/undefined', () => { + expect(() => slugSchema.parse(null)).toThrow(); + expect(() => slugSchema.parse(undefined)).toThrow(); + }); +}); + +describe('emailSchema', () => { + it('accepts valid emails and lowercases', () => { + expect(emailSchema.parse('Test@Example.COM')).toBe('test@example.com'); + }); + + it('rejects invalid emails', () => { + expect(() => emailSchema.parse('not-an-email')).toThrow(); + expect(() => emailSchema.parse('@no-local')).toThrow(); + expect(() => emailSchema.parse('no-domain@')).toThrow(); + }); + + it('rejects emails longer than 254 chars', () => { + expect(() => emailSchema.parse('a'.repeat(250) + '@b.com')).toThrow(); + }); + + it('rejects empty string', () => { + expect(() => emailSchema.parse('')).toThrow(); + }); +}); + +describe('phoneSchema', () => { + it('accepts E.164 phones', () => { + expect(phoneSchema.parse('+14155551234')).toBe('+14155551234'); + }); + + it('rejects phones without + prefix', () => { + expect(() => phoneSchema.parse('14155551234')).toThrow(); + }); + + it('rejects phones starting with +0', () => { + expect(() => phoneSchema.parse('+04155551234')).toThrow(); + }); + + it('rejects too short phones', () => { + expect(() => phoneSchema.parse('+1234')).toThrow(); + }); + + it('rejects too long phones', () => { + expect(() => phoneSchema.parse('+1234567890123456')).toThrow(); + }); +}); + +describe('hostnameSchema', () => { + it('accepts valid hostnames', () => { + expect(hostnameSchema.parse('example.com')).toBe('example.com'); + expect(hostnameSchema.parse('sub.example.com')).toBe('sub.example.com'); + expect(hostnameSchema.parse('my-site-sites.megabyte.space')).toBe('my-site-sites.megabyte.space'); + }); + + it('rejects hostnames without TLD', () => { + expect(() => hostnameSchema.parse('localhost')).toThrow(); + }); + + it('rejects hostnames with invalid chars', () => { + expect(() => hostnameSchema.parse('ex ample.com')).toThrow(); + expect(() => hostnameSchema.parse('ex@mple.com')).toThrow(); + }); + + it('rejects too long hostnames', () => { + expect(() => hostnameSchema.parse('a'.repeat(254) + '.com')).toThrow(); + }); +}); + +describe('httpsUrlSchema', () => { + it('accepts valid HTTPS URLs', () => { + expect(httpsUrlSchema.parse('https://example.com')).toBe('https://example.com'); + }); + + it('rejects HTTP URLs', () => { + expect(() => httpsUrlSchema.parse('http://example.com')).toThrow(); + }); + + it('rejects non-URL strings', () => { + expect(() => httpsUrlSchema.parse('not-a-url')).toThrow(); + }); +}); + +describe('safeStringSchema', () => { + it('accepts normal strings', () => { + expect(safeStringSchema.parse('Hello World')).toBe('Hello World'); + }); + + it('rejects script tags', () => { + expect(() => safeStringSchema.parse('')).toThrow(); + }); + + it('rejects javascript: URIs', () => { + expect(() => safeStringSchema.parse('javascript:alert(1)')).toThrow(); + }); + + it('rejects data: URIs', () => { + expect(() => safeStringSchema.parse('data:text/html,

hi

')).toThrow(); + }); + + it('rejects strings over 1000 chars', () => { + expect(() => safeStringSchema.parse('a'.repeat(1001))).toThrow(); + }); +}); + +describe('paginationSchema', () => { + it('applies defaults', () => { + const result = paginationSchema.parse({}); + expect(result.limit).toBe(20); + expect(result.offset).toBe(0); + }); + + it('clamps limit to 100', () => { + expect(() => paginationSchema.parse({ limit: 200 })).toThrow(); + }); + + it('rejects negative offset', () => { + expect(() => paginationSchema.parse({ offset: -1 })).toThrow(); + }); + + it('coerces string numbers', () => { + const result = paginationSchema.parse({ limit: '50', offset: '10' }); + expect(result.limit).toBe(50); + expect(result.offset).toBe(10); + }); +}); + +describe('confidenceScoreSchema', () => { + it('accepts 0-100', () => { + expect(confidenceScoreSchema.parse(0)).toBe(0); + expect(confidenceScoreSchema.parse(100)).toBe(100); + expect(confidenceScoreSchema.parse(50)).toBe(50); + }); + + it('rejects out of range', () => { + expect(() => confidenceScoreSchema.parse(-1)).toThrow(); + expect(() => confidenceScoreSchema.parse(101)).toThrow(); + }); + + it('rejects non-integers', () => { + expect(() => confidenceScoreSchema.parse(50.5)).toThrow(); + }); +}); + +describe('metadataSchema', () => { + it('accepts valid objects', () => { + expect(metadataSchema.parse({ key: 'value' })).toEqual({ key: 'value' }); + }); + + it('rejects oversized metadata', () => { + const huge = { data: 'x'.repeat(70000) }; + expect(() => metadataSchema.parse(huge)).toThrow(); + }); +}); + +// ─── Org Schemas ───────────────────────────────────────────── + +describe('createOrgSchema', () => { + it('accepts valid org creation', () => { + const result = createOrgSchema.parse({ name: 'My Business', slug: 'my-business' }); + expect(result.name).toBe('My Business'); + expect(result.slug).toBe('my-business'); + }); + + it('rejects script injection in name', () => { + expect(() => createOrgSchema.parse({ name: '', slug: 'valid' })).toThrow(); + }); + + it('rejects invalid slugs', () => { + expect(() => createOrgSchema.parse({ name: 'Valid', slug: 'NO' })).toThrow(); + }); +}); + +// ─── Site Schemas ──────────────────────────────────────────── + +describe('createSiteSchema', () => { + it('accepts valid site creation with just business name', () => { + const result = createSiteSchema.parse({ business_name: 'Joe Pizza' }); + expect(result.business_name).toBe('Joe Pizza'); + }); + + it('accepts full site creation', () => { + const result = createSiteSchema.parse({ + business_name: 'Joe Pizza', + slug: 'joe-pizza', + business_phone: '+14155551234', + business_email: 'joe@pizza.com', + business_address: '123 Main St', + google_place_id: 'ChIJ...', + }); + expect(result.slug).toBe('joe-pizza'); + }); + + it('rejects empty business name', () => { + expect(() => createSiteSchema.parse({ business_name: '' })).toThrow(); + }); + + it('rejects script injection in business name', () => { + expect(() => createSiteSchema.parse({ business_name: '' })).toThrow(); + }); + + it('rejects invalid emails', () => { + expect(() => createSiteSchema.parse({ business_name: 'Valid', business_email: 'not-email' })).toThrow(); + }); +}); + +// ─── Auth Schemas ──────────────────────────────────────────── + +describe('createMagicLinkSchema', () => { + it('accepts valid email', () => { + const result = createMagicLinkSchema.parse({ email: 'test@example.com' }); + expect(result.email).toBe('test@example.com'); + }); + + it('lowercases email', () => { + const result = createMagicLinkSchema.parse({ email: 'Test@Example.COM' }); + expect(result.email).toBe('test@example.com'); + }); + + it('rejects invalid email', () => { + expect(() => createMagicLinkSchema.parse({ email: 'invalid' })).toThrow(); + }); +}); + +describe('googleOAuthCallbackSchema', () => { + it('accepts valid callback params', () => { + const result = googleOAuthCallbackSchema.parse({ code: 'auth-code', state: 'csrf-state' }); + expect(result.code).toBe('auth-code'); + }); + + it('rejects empty code', () => { + expect(() => googleOAuthCallbackSchema.parse({ code: '', state: 'valid' })).toThrow(); + }); + + it('rejects missing state', () => { + expect(() => googleOAuthCallbackSchema.parse({ code: 'valid' })).toThrow(); + }); +}); + +// ─── Billing Schemas ───────────────────────────────────────── + +describe('createCheckoutSessionSchema', () => { + const validUuid = '00000000-0000-4000-8000-000000000001'; + + it('accepts valid checkout session', () => { + const result = createCheckoutSessionSchema.parse({ + org_id: validUuid, + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel', + }); + expect(result.org_id).toBe(validUuid); + }); + + it('rejects non-uuid org_id', () => { + expect(() => + createCheckoutSessionSchema.parse({ + org_id: 'not-uuid', + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel', + }), + ).toThrow(); + }); + + it('rejects non-URL success_url', () => { + expect(() => + createCheckoutSessionSchema.parse({ + org_id: validUuid, + success_url: 'not-a-url', + cancel_url: 'https://example.com/cancel', + }), + ).toThrow(); + }); +}); + +describe('entitlementsSchema', () => { + const validUuid = '00000000-0000-4000-8000-000000000001'; + + it('accepts valid free entitlements', () => { + const result = entitlementsSchema.parse({ + org_id: validUuid, + plan: 'free', + topBarHidden: false, + maxCustomDomains: 0, + chatEnabled: true, + analyticsEnabled: false, + }); + expect(result.topBarHidden).toBe(false); + }); + + it('accepts valid paid entitlements', () => { + const result = entitlementsSchema.parse({ + org_id: validUuid, + plan: 'paid', + topBarHidden: true, + maxCustomDomains: 5, + chatEnabled: true, + analyticsEnabled: true, + }); + expect(result.topBarHidden).toBe(true); + expect(result.maxCustomDomains).toBe(5); + }); +}); + +// ─── Webhook Schemas ───────────────────────────────────────── + +describe('webhookIngestionSchema', () => { + it('accepts valid webhook', () => { + const result = webhookIngestionSchema.parse({ + provider: 'stripe', + event_id: 'evt_123', + event_type: 'checkout.session.completed', + raw_body: '{"data":{}}', + }); + expect(result.provider).toBe('stripe'); + }); + + it('rejects unknown provider', () => { + expect(() => + webhookIngestionSchema.parse({ + provider: 'unknown', + event_id: 'evt_123', + event_type: 'test', + raw_body: '{}', + }), + ).toThrow(); + }); + + it('rejects oversized raw body', () => { + expect(() => + webhookIngestionSchema.parse({ + provider: 'stripe', + event_id: 'evt_123', + event_type: 'test', + raw_body: 'x'.repeat(256 * 1024 + 1), + }), + ).toThrow(); + }); + + it('rejects empty event_id', () => { + expect(() => + webhookIngestionSchema.parse({ + provider: 'stripe', + event_id: '', + event_type: 'test', + raw_body: '{}', + }), + ).toThrow(); + }); +}); + +// ─── Workflow Schemas ──────────────────────────────────────── + +describe('createWorkflowJobSchema', () => { + const validUuid = '00000000-0000-4000-8000-000000000001'; + + it('accepts valid job creation', () => { + const result = createWorkflowJobSchema.parse({ + job_name: 'generate_site', + org_id: validUuid, + }); + expect(result.job_name).toBe('generate_site'); + expect(result.max_attempts).toBe(3); + }); + + it('rejects empty job_name', () => { + expect(() => createWorkflowJobSchema.parse({ job_name: '', org_id: validUuid })).toThrow(); + }); + + it('rejects max_attempts > 10', () => { + expect(() => + createWorkflowJobSchema.parse({ + job_name: 'test', + org_id: validUuid, + max_attempts: 20, + }), + ).toThrow(); + }); +}); + +// ─── Config Schema ─────────────────────────────────────────── + +describe('envConfigSchema', () => { + const validConfig = { + ENVIRONMENT: 'test', + STRIPE_SECRET_KEY: 'sk_test_abc123', + STRIPE_PUBLISHABLE_KEY: 'pk_test_abc123', + STRIPE_WEBHOOK_SECRET: 'whsec_test', + CF_API_TOKEN: 'cf-token', + CF_ZONE_ID: 'zone-123', + SENDGRID_API_KEY: 'sg-key', + GOOGLE_CLIENT_ID: 'google-id', + GOOGLE_CLIENT_SECRET: 'google-secret', + GOOGLE_PLACES_API_KEY: 'places-key', + SENTRY_DSN: 'https://sentry.example.com/123', + }; + + it('accepts valid test config', () => { + const result = envConfigSchema.parse(validConfig); + expect(result.ENVIRONMENT).toBe('test'); + }); + + it('accepts valid production config with live keys', () => { + const prodConfig = { + ...validConfig, + ENVIRONMENT: 'production', + STRIPE_SECRET_KEY: 'sk_live_abc123', + STRIPE_PUBLISHABLE_KEY: 'pk_live_abc123', + }; + const result = envConfigSchema.parse(prodConfig); + expect(result.ENVIRONMENT).toBe('production'); + }); + + it('rejects production with test Stripe keys', () => { + const badConfig = { + ...validConfig, + ENVIRONMENT: 'production', + STRIPE_SECRET_KEY: 'sk_test_abc123', + STRIPE_PUBLISHABLE_KEY: 'pk_test_abc123', + }; + expect(() => envConfigSchema.parse(badConfig)).toThrow(); + }); + + it('rejects non-production with live Stripe keys', () => { + const badConfig = { + ...validConfig, + ENVIRONMENT: 'staging', + STRIPE_SECRET_KEY: 'sk_live_abc123', + STRIPE_PUBLISHABLE_KEY: 'pk_live_abc123', + }; + expect(() => envConfigSchema.parse(badConfig)).toThrow(); + }); + + it('rejects missing required fields', () => { + expect(() => envConfigSchema.parse({ ENVIRONMENT: 'test' })).toThrow(); + }); + + it('defaults METERING_PROVIDER to internal', () => { + const result = envConfigSchema.parse(validConfig); + expect(result.METERING_PROVIDER).toBe('internal'); + }); + + it('accepts lago metering provider', () => { + const result = envConfigSchema.parse({ ...validConfig, METERING_PROVIDER: 'lago' }); + expect(result.METERING_PROVIDER).toBe('lago'); + }); +}); + +describe('validateEnvConfig', () => { + it('throws on invalid config with descriptive error', () => { + expect(() => validateEnvConfig({})).toThrow(); + }); +}); + +// ─── Hostname Schemas ──────────────────────────────────────── + +describe('createHostnameSchema', () => { + const validUuid = '00000000-0000-4000-8000-000000000001'; + + it('accepts valid free subdomain', () => { + const result = createHostnameSchema.parse({ + site_id: validUuid, + hostname: 'my-biz-sites.megabyte.space', + type: 'free_subdomain', + }); + expect(result.type).toBe('free_subdomain'); + }); + + it('accepts valid custom CNAME', () => { + const result = createHostnameSchema.parse({ + site_id: validUuid, + hostname: 'www.example.com', + type: 'custom_cname', + }); + expect(result.type).toBe('custom_cname'); + }); + + it('rejects invalid hostname', () => { + expect(() => + createHostnameSchema.parse({ + site_id: validUuid, + hostname: 'not a hostname', + type: 'custom_cname', + }), + ).toThrow(); + }); + + it('rejects invalid type', () => { + expect(() => + createHostnameSchema.parse({ + site_id: validUuid, + hostname: 'example.com', + type: 'invalid', + }), + ).toThrow(); + }); +}); + +// ─── Health Check Schema ───────────────────────────────────── + +describe('healthCheckSchema', () => { + it('accepts valid health check', () => { + const result = healthCheckSchema.parse({ + status: 'ok', + version: '1.0.0', + environment: 'test', + timestamp: '2024-01-01T00:00:00.000Z', + }); + expect(result.status).toBe('ok'); + }); + + it('accepts health check with sub-checks', () => { + const result = healthCheckSchema.parse({ + status: 'degraded', + version: '1.0.0', + environment: 'production', + timestamp: '2024-01-01T00:00:00.000Z', + checks: { + db: { status: 'ok', latency_ms: 12 }, + kv: { status: 'error', message: 'timeout' }, + }, + }); + expect(result.checks?.db?.status).toBe('ok'); + }); +}); diff --git a/packages/shared/src/__tests__/utils.test.ts b/packages/shared/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..8ce3dbcf3b --- /dev/null +++ b/packages/shared/src/__tests__/utils.test.ts @@ -0,0 +1,343 @@ +import { sanitizeHtml, stripHtml, sanitizeSlug, businessNameToSlug } from '../utils/sanitize'; +import { redact, redactObject } from '../utils/redact'; +import { + AppError, + badRequest, + unauthorized, + forbidden, + notFound, + conflict, + payloadTooLarge, + rateLimited, + internalError, + validationError, +} from '../utils/errors'; +import { generateOtp, randomHex, randomUUID, timingSafeEqual } from '../utils/crypto'; + +// ─── Sanitize ──────────────────────────────────────────────── + +describe('sanitizeHtml', () => { + it('removes script tags', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('removes nested script tags', () => { + const result = sanitizeHtml(''); + expect(result).not.toContain(' { + expect(sanitizeHtml('')).not.toContain('onerror'); + }); + + it('removes javascript: URIs', () => { + expect(sanitizeHtml('javascript:alert(1)')).not.toContain('javascript'); + }); + + it('removes data: URIs', () => { + expect(sanitizeHtml('data:text/html,

hi

')).not.toContain('data:'); + }); + + it('removes vbscript: URIs', () => { + expect(sanitizeHtml('vbscript:alert(1)')).not.toContain('vbscript'); + }); + + it('removes iframes', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('removes object tags', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('removes embed tags', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('preserves safe HTML', () => { + expect(sanitizeHtml('

Hello World

')).toBe('

Hello World

'); + }); + + it('handles empty strings', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('handles plain text', () => { + expect(sanitizeHtml('Just text')).toBe('Just text'); + }); +}); + +describe('stripHtml', () => { + it('removes all HTML tags', () => { + expect(stripHtml('

Hello World

')).toBe('Hello World'); + }); + + it('handles empty string', () => { + expect(stripHtml('')).toBe(''); + }); + + it('handles text without HTML', () => { + expect(stripHtml('Just text')).toBe('Just text'); + }); +}); + +describe('sanitizeSlug', () => { + it('lowercases and strips invalid chars', () => { + expect(sanitizeSlug('My Business!')).toBe('my-business'); + }); + + it('collapses multiple hyphens', () => { + expect(sanitizeSlug('my---business')).toBe('my-business'); + }); + + it('trims leading/trailing hyphens', () => { + expect(sanitizeSlug('-my-business-')).toBe('my-business'); + }); + + it('truncates to 63 chars', () => { + expect(sanitizeSlug('a'.repeat(100))).toHaveLength(63); + }); + + it('handles empty string', () => { + expect(sanitizeSlug('')).toBe(''); + }); +}); + +describe('businessNameToSlug', () => { + it('converts business names to slugs', () => { + expect(businessNameToSlug("Joe's Pizza & Pasta")).toBe('joes-pizza-and-pasta'); + }); + + it('handles ampersands', () => { + expect(businessNameToSlug('A & B')).toBe('a-and-b'); + }); + + it('handles apostrophes', () => { + expect(businessNameToSlug("O'Brien\u2019s Shop")).toBe('obriens-shop'); + }); +}); + +// ─── Redact ────────────────────────────────────────────────── + +describe('redact', () => { + it('redacts Stripe test keys', () => { + expect(redact('key: sk_test_abc123xyz')).toContain('[REDACTED_TOKEN]'); + }); + + it('redacts Stripe live keys', () => { + expect(redact('key: sk_live_abc123xyz')).toContain('[REDACTED_TOKEN]'); + }); + + it('redacts Bearer tokens', () => { + expect(redact('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9')).toContain('[REDACTED_TOKEN]'); + }); + + it('redacts emails', () => { + expect(redact('user: test@example.com')).toContain('[REDACTED_EMAIL]'); + }); + + it('redacts phone numbers', () => { + expect(redact('phone: +14155551234')).toContain('[REDACTED_PHONE]'); + }); + + it('redacts webhook secrets', () => { + expect(redact('secret: whsec_abc123xyz1234')).toContain('[REDACTED_TOKEN]'); + }); + + it('preserves non-sensitive text', () => { + expect(redact('Hello World')).toBe('Hello World'); + }); + + it('handles empty strings', () => { + expect(redact('')).toBe(''); + }); +}); + +describe('redactObject', () => { + it('redacts sensitive keys', () => { + const result = redactObject({ password: 'secret123', name: 'John' }); + expect(result.password).toBe('[REDACTED]'); + expect(result.name).toBe('John'); + }); + + it('redacts nested objects', () => { + const result = redactObject({ + config: { api_key: 'secret', url: 'https://example.com' }, + }); + expect((result.config as Record).api_key).toBe('[REDACTED]'); + }); + + it('redacts string values containing tokens', () => { + const result = redactObject({ header: 'Bearer eyJhbGciOiJIUzI1NiJ9' }); + expect(result.header).toContain('[REDACTED_TOKEN]'); + }); + + it('preserves non-sensitive values', () => { + const result = redactObject({ id: 123, status: 'active' }); + expect(result.id).toBe(123); + expect(result.status).toBe('active'); + }); +}); + +// ─── Errors ────────────────────────────────────────────────── + +describe('AppError', () => { + it('creates error with correct properties', () => { + const err = new AppError({ + code: 'BAD_REQUEST', + message: 'Invalid input', + statusCode: 400, + details: { field: 'name' }, + }); + expect(err.code).toBe('BAD_REQUEST'); + expect(err.message).toBe('Invalid input'); + expect(err.statusCode).toBe(400); + expect(err.details).toEqual({ field: 'name' }); + }); + + it('serializes to JSON', () => { + const err = new AppError({ + code: 'NOT_FOUND', + message: 'Not found', + statusCode: 404, + requestId: 'req-123', + }); + const json = err.toJSON(); + expect(json.error.code).toBe('NOT_FOUND'); + expect(json.error.request_id).toBe('req-123'); + }); + + it('extends Error', () => { + const err = new AppError({ code: 'INTERNAL_ERROR', message: 'test', statusCode: 500 }); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('AppError'); + }); + + it('preserves cause', () => { + const cause = new Error('original'); + const err = new AppError({ + code: 'INTERNAL_ERROR', + message: 'wrapped', + statusCode: 500, + cause, + }); + expect(err.cause).toBe(cause); + }); +}); + +describe('error factories', () => { + it('badRequest returns 400', () => { + const err = badRequest('bad'); + expect(err.statusCode).toBe(400); + expect(err.code).toBe('BAD_REQUEST'); + }); + + it('unauthorized returns 401', () => { + const err = unauthorized(); + expect(err.statusCode).toBe(401); + }); + + it('forbidden returns 403', () => { + const err = forbidden(); + expect(err.statusCode).toBe(403); + }); + + it('notFound returns 404', () => { + const err = notFound(); + expect(err.statusCode).toBe(404); + }); + + it('conflict returns 409', () => { + const err = conflict('duplicate'); + expect(err.statusCode).toBe(409); + }); + + it('payloadTooLarge returns 413', () => { + const err = payloadTooLarge(); + expect(err.statusCode).toBe(413); + }); + + it('rateLimited returns 429', () => { + const err = rateLimited(); + expect(err.statusCode).toBe(429); + }); + + it('internalError returns 500', () => { + const err = internalError(); + expect(err.statusCode).toBe(500); + }); + + it('validationError returns 400 with details', () => { + const err = validationError('invalid', { fields: ['name'] }); + expect(err.statusCode).toBe(400); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.details).toEqual({ fields: ['name'] }); + }); +}); + +// ─── Crypto ────────────────────────────────────────────────── + +describe('randomHex', () => { + it('generates hex string of correct length', () => { + const hex = randomHex(16); + expect(hex).toHaveLength(32); + expect(/^[0-9a-f]+$/.test(hex)).toBe(true); + }); + + it('generates unique values', () => { + const a = randomHex(16); + const b = randomHex(16); + expect(a).not.toBe(b); + }); +}); + +describe('randomUUID', () => { + it('generates valid UUID v4', () => { + const uuid = randomUUID(); + expect(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(uuid)).toBe(true); + }); + + it('generates unique values', () => { + const a = randomUUID(); + const b = randomUUID(); + expect(a).not.toBe(b); + }); +}); + +describe('generateOtp', () => { + it('generates 6-digit OTP by default', () => { + const otp = generateOtp(); + expect(otp).toHaveLength(6); + expect(/^\d{6}$/.test(otp)).toBe(true); + }); + + it('generates OTP of specified length', () => { + const otp = generateOtp(4); + expect(otp).toHaveLength(4); + expect(/^\d{4}$/.test(otp)).toBe(true); + }); + + it('pads with leading zeros', () => { + // Run multiple times to increase chance of hitting a small number + const otps = Array.from({ length: 100 }, () => generateOtp()); + otps.forEach((otp) => expect(otp).toHaveLength(6)); + }); +}); + +describe('timingSafeEqual', () => { + it('returns true for equal strings', () => { + expect(timingSafeEqual('abc', 'abc')).toBe(true); + }); + + it('returns false for different strings', () => { + expect(timingSafeEqual('abc', 'def')).toBe(false); + }); + + it('returns false for different lengths', () => { + expect(timingSafeEqual('abc', 'abcd')).toBe(false); + }); + + it('returns true for empty strings', () => { + expect(timingSafeEqual('', '')).toBe(true); + }); +}); diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts new file mode 100644 index 0000000000..867ca2d2c5 --- /dev/null +++ b/packages/shared/src/constants/index.ts @@ -0,0 +1,142 @@ +/** Caps and rate limits */ +export const DEFAULT_CAPS = { + LLM_DAILY_SPEND_CENTS: 2000, // $20/day + SITES_PER_DAY: 20, + EMAILS_PER_DAY: 25, + MAX_CUSTOM_DOMAINS: 5, + MAX_REQUEST_BODY_BYTES: 256 * 1024, // 256KB + MAX_AI_MICROTASK_OUTPUT_BYTES: 64 * 1024, // 64KB + MAX_QUEUED_RETRIES: 5, + MAX_COMPUTE_TIME_MS: 300_000, // 5 minutes per job + MAX_STORAGE_FREE_MB: 100, + MAX_STORAGE_PAID_MB: 500, +} as const; + +/** Pricing */ +export const PRICING = { + MONTHLY_CENTS: 5000, // $50/mo + RETENTION_OFFER_CENTS: 2500, // $25/mo for 12 months + RETENTION_OFFER_MONTHS: 12, + CURRENCY: 'usd' as const, +} as const; + +/** Dunning schedule: days after invoice due date */ +export const DUNNING = { + REMINDER_DAYS: [0, 7, 14, 30] as const, + DOWNGRADE_DAY: 60, +} as const; + +/** Auth constants */ +export const AUTH = { + MAGIC_LINK_EXPIRY_HOURS: 24, + OTP_EXPIRY_MINUTES: 5, + OTP_MAX_ATTEMPTS: 3, + SESSION_EXPIRY_DAYS: 30, + SESSION_REFRESH_DAYS: 7, + OTP_LENGTH: 6, + TURNSTILE_TIMEOUT_MS: 300_000, +} as const; + +/** Entitlements by plan */ +export const ENTITLEMENTS = { + free: { + topBarHidden: false, + maxCustomDomains: 0, + chatEnabled: true, + analyticsEnabled: false, + }, + paid: { + topBarHidden: true, + maxCustomDomains: 5, + chatEnabled: true, + analyticsEnabled: true, + }, +} as const; + +/** Roles */ +export const ROLES = ['owner', 'admin', 'member', 'viewer'] as const; +export type Role = (typeof ROLES)[number]; + +/** Subscription states */ +export const SUBSCRIPTION_STATES = [ + 'active', + 'past_due', + 'canceled', + 'unpaid', + 'trialing', + 'incomplete', + 'incomplete_expired', + 'paused', +] as const; +export type SubscriptionState = (typeof SUBSCRIPTION_STATES)[number]; + +/** Job states */ +export const JOB_STATES = ['queued', 'running', 'success', 'failed'] as const; +export type JobState = (typeof JOB_STATES)[number]; + +/** Hostname provisioning states */ +export const HOSTNAME_STATES = [ + 'pending', + 'active', + 'moved', + 'deleted', + 'pending_deletion', + 'verification_failed', +] as const; +export type HostnameState = (typeof HOSTNAME_STATES)[number]; + +/** Funnel events */ +export const FUNNEL_EVENTS = [ + 'signup_started', + 'signup_completed', + 'site_created', + 'first_publish', + 'first_payment', + 'invite_sent', + 'invite_accepted', + 'churned', +] as const; +export type FunnelEvent = (typeof FUNNEL_EVENTS)[number]; + +/** Webhook providers */ +export const WEBHOOK_PROVIDERS = ['stripe', 'dub', 'chatwoot', 'novu', 'lago'] as const; +export type WebhookProvider = (typeof WEBHOOK_PROVIDERS)[number]; + +/** HTTP status codes used in typed errors */ +export const ERROR_CODES = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + RATE_LIMITED: 429, + INTERNAL: 500, + SERVICE_UNAVAILABLE: 503, +} as const; + +/** Brand copy */ +export const BRAND = { + TAGLINE: 'Your website\u2014handled. Finally.', + HEADLINE: 'Your business website\u2014live in under 15 minutes.', + PRIMARY_CTA: 'Launch My Site Now', + SECONDARY_CTA: 'See a Demo', + MICROCOPY: 'Domain included \u2022 Updates included \u2022 Cancel anytime', + CONTACT_EMAIL: 'hey@megabyte.space', + REPLY_TO_EMAIL: 'brian@megabyte.space', +} as const; + +/** Domain configuration */ +export const DOMAINS = { + /** Base domain for the marketing homepage (sites.megabyte.space) */ + SITES_BASE: 'sites.megabyte.space', + /** Base domain for staging (sites-staging.megabyte.space) */ + SITES_STAGING: 'sites-staging.megabyte.space', + /** Suffix for customer site subdomains: {slug}-sites.megabyte.space */ + SITES_SUFFIX: '-sites.megabyte.space', + /** Suffix for staging customer sites: {slug}-sites-staging.megabyte.space */ + SITES_STAGING_SUFFIX: '-sites-staging.megabyte.space', + BOLT_BASE: 'bolt.megabyte.space', + BOLT_STAGING: 'bolt-staging.megabyte.space', + CLAIM_BASE: 'claimyour.site', +} as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000000..9839694f86 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,37 @@ +/** + * @module @bolt/shared + * @packageDocumentation + * + * Root entry point for the `@bolt/shared` package. Re-exports every public + * symbol from the four sub-modules so consumers can import from a single + * path: `import { orgSchema, AppError, requireRole } from '@bolt/shared'`. + * + * | Sub-module | What it provides | + * | --------------- | ----------------------------------------------------------------------- | + * | `constants` | Caps, pricing, dunning, auth, entitlements, roles, domain config, brand | + * | `schemas` | Zod schemas and inferred types for every domain entity and API envelope | + * | `middleware` | RBAC role/permission checks and plan entitlement guards | + * | `utils` | Sanitisation, PII redaction, typed errors, and Web Crypto helpers | + * + * @example + * ```ts + * import { + * // constants + * DEFAULT_CAPS, ROLES, PRICING, + * // schemas + types + * orgSchema, siteSchema, type Org, type Site, + * // middleware + * requireRole, checkPermission, getEntitlements, + * // utils + * sanitizeHtml, AppError, badRequest, sha256Hex, + * } from '@bolt/shared'; + * + * const org = orgSchema.parse(raw); + * const canEdit = checkPermission('admin', 'site:write'); + * const hash = await sha256Hex(payload); + * ``` + */ +export * from './constants/index.js'; +export * from './schemas/index.js'; +export * from './middleware/index.js'; +export * from './utils/index.js'; diff --git a/packages/shared/src/middleware/entitlements.ts b/packages/shared/src/middleware/entitlements.ts new file mode 100644 index 0000000000..5f9a00ff2a --- /dev/null +++ b/packages/shared/src/middleware/entitlements.ts @@ -0,0 +1,94 @@ +/** + * Entitlement resolution for organisation billing plans. + * + * This module maps a subscription plan (`'free'` or `'paid'`) to a concrete + * set of feature flags and numeric limits (the {@link Entitlements} shape + * defined in `schemas/billing`). The worker and UI call these helpers to + * gate features such as custom-domain support, the top-bar ad, live chat, + * and analytics. + * + * | Export | Description | + * | -------------------- | --------------------------------------------------- | + * | `getEntitlements` | Return the full `Entitlements` object for a plan | + * | `requireEntitlement` | Check whether a single entitlement is enabled | + * + * @example + * ```ts + * import { getEntitlements, requireEntitlement } from '@shared/middleware/entitlements.js'; + * + * const ent = getEntitlements('org_abc', 'paid'); + * // => { org_id: 'org_abc', plan: 'paid', topBarHidden: true, ... } + * + * if (!requireEntitlement('free', 'chatEnabled')) { + * return new Response('Upgrade required', { status: 402 }); + * } + * ``` + * + * @module entitlements + * @packageDocumentation + */ + +import { ENTITLEMENTS } from '../constants/index.js'; +import type { Entitlements } from '../schemas/billing.js'; + +/** + * The two subscription tiers currently supported. + * + * - `'free'` -- default tier; top-bar ad shown, limited domains. + * - `'paid'` -- full-feature tier after Stripe checkout. + */ +type Plan = 'free' | 'paid'; + +/** + * Compute the full entitlements object for an organisation given its plan. + * + * Looks up the static entitlement definitions in {@link ENTITLEMENTS} and + * returns a new {@link Entitlements} record annotated with the `org_id` and + * `plan` that produced it. + * + * @param orgId - The Supabase organisation UUID (e.g. `'org_abc'`). + * @param plan - The organisation's current subscription tier. + * @returns A fully populated {@link Entitlements} object. + * + * @example + * ```ts + * const ent = getEntitlements('org_123', 'free'); + * console.warn(ent.topBarHidden); // false + * console.warn(ent.maxCustomDomains); // 0 + * ``` + */ +export function getEntitlements(orgId: string, plan: Plan): Entitlements { + const planEntitlements = ENTITLEMENTS[plan]; + return { + org_id: orgId, + plan, + topBarHidden: planEntitlements.topBarHidden, + maxCustomDomains: planEntitlements.maxCustomDomains, + chatEnabled: planEntitlements.chatEnabled, + analyticsEnabled: planEntitlements.analyticsEnabled, + }; +} + +/** + * Check whether a single boolean or numeric entitlement is truthy for a plan. + * + * This is a lightweight guard intended for use in request handlers and + * middleware where you need to gate on a single feature without building the + * full {@link Entitlements} object. + * + * @param plan - The organisation's current subscription tier. + * @param entitlement - The key to check (e.g. `'chatEnabled'`, `'topBarHidden'`). + * @returns `true` if the entitlement value is truthy for the given plan, + * `false` otherwise. + * + * @example + * ```ts + * if (!requireEntitlement('free', 'analyticsEnabled')) { + * return c.json({ error: 'Analytics requires a paid plan' }, 402); + * } + * ``` + */ +export function requireEntitlement(plan: Plan, entitlement: keyof typeof ENTITLEMENTS.paid): boolean { + const planEntitlements = ENTITLEMENTS[plan]; + return !!planEntitlements[entitlement]; +} diff --git a/packages/shared/src/middleware/index.ts b/packages/shared/src/middleware/index.ts new file mode 100644 index 0000000000..2dbd6e4086 --- /dev/null +++ b/packages/shared/src/middleware/index.ts @@ -0,0 +1,43 @@ +/** + * @module middleware + * @packageDocumentation + * + * Authorization and entitlement helpers shared between the Cloudflare Worker + * and any server-side consumer. These are pure functions (no framework + * dependency) so they can be composed into Hono middleware, test harnesses, + * or CLI tools. + * + * | Export | Source | Description | + * | ------------------- | -------------- | ----------------------------------------------------------------- | + * | `requireRole` | `rbac` | Returns `true` when a user's role meets or exceeds the minimum | + * | `checkPermission` | `rbac` | Returns `true` when a role (+ optional `billing_admin`) holds a permission | + * | `Permission` | `rbac` | Union type of all fine-grained permission strings | + * | `getEntitlements` | `entitlements` | Computes the full entitlements object for an org given its plan | + * | `requireEntitlement`| `entitlements` | Checks whether a single boolean entitlement is enabled for a plan | + * + * @example + * ```ts + * import { + * requireRole, + * checkPermission, + * getEntitlements, + * type Permission, + * } from '@bolt/shared/middleware'; + * + * // Role hierarchy check: owner > admin > member > viewer + * if (!requireRole(user.role, 'admin')) { + * throw new Error('Admin access required'); + * } + * + * // Fine-grained permission check + * const canPublish: boolean = checkPermission(user.role, 'site:publish'); + * + * // Compute plan entitlements for an org + * const ent = getEntitlements(org.id, subscription.plan); + * if (!ent.topBarHidden) { + * injectTopBar(response); + * } + * ``` + */ +export { requireRole, checkPermission, type Permission } from './rbac.js'; +export { getEntitlements, requireEntitlement } from './entitlements.js'; diff --git a/packages/shared/src/middleware/rbac.ts b/packages/shared/src/middleware/rbac.ts new file mode 100644 index 0000000000..97dc251529 --- /dev/null +++ b/packages/shared/src/middleware/rbac.ts @@ -0,0 +1,81 @@ +import type { Role } from '../constants/index.js'; +import { ROLES } from '../constants/index.js'; + +/** Permission types */ +export type Permission = + | 'org:read' + | 'org:write' + | 'org:delete' + | 'site:read' + | 'site:write' + | 'site:delete' + | 'site:publish' + | 'billing:read' + | 'billing:write' + | 'member:read' + | 'member:write' + | 'member:delete' + | 'admin:read' + | 'admin:write'; + +/** Role → permission mapping */ +const ROLE_PERMISSIONS: Record> = { + owner: new Set([ + 'org:read', + 'org:write', + 'org:delete', + 'site:read', + 'site:write', + 'site:delete', + 'site:publish', + 'billing:read', + 'billing:write', + 'member:read', + 'member:write', + 'member:delete', + 'admin:read', + 'admin:write', + ]), + admin: new Set([ + 'org:read', + 'org:write', + 'site:read', + 'site:write', + 'site:delete', + 'site:publish', + 'billing:read', + 'member:read', + 'member:write', + 'admin:read', + ]), + member: new Set(['org:read', 'site:read', 'site:write', 'site:publish', 'billing:read', 'member:read']), + viewer: new Set(['org:read', 'site:read', 'billing:read', 'member:read']), +}; + +/** + * Role hierarchy: owner > admin > member > viewer. + * Returns the index (lower = more powerful). + */ +function roleIndex(role: Role): number { + return ROLES.indexOf(role); +} + +/** + * Check if a role meets the minimum required role level. + */ +export function requireRole(userRole: Role, minRole: Role): boolean { + return roleIndex(userRole) <= roleIndex(minRole); +} + +/** + * Check if a role (+ optional billing_admin flag) has a specific permission. + */ +export function checkPermission(userRole: Role, permission: Permission, billingAdmin: boolean = false): boolean { + // billing_admin flag grants billing:write regardless of role + if (permission === 'billing:write' && billingAdmin) { + return true; + } + + const permissions = ROLE_PERMISSIONS[userRole]; + return permissions?.has(permission) ?? false; +} diff --git a/packages/shared/src/schemas/analytics.ts b/packages/shared/src/schemas/analytics.ts new file mode 100644 index 0000000000..88e422d09f --- /dev/null +++ b/packages/shared/src/schemas/analytics.ts @@ -0,0 +1,105 @@ +/** + * @module analytics + * @packageDocumentation + * + * Zod schemas for analytics, funnel tracking, and internal usage metering. + * + * These schemas validate rows in the `analytics_daily`, `funnel_events`, and + * `usage_events` Postgres tables. Daily rollups aggregate page-level traffic, + * funnel events track user progression through the sign-up flow, and usage + * events feed the internal metering system for billing. + * + * ## Schemas and Types + * + * | Export | Kind | Inferred Type | Description | + * | ---------------------- | ------------ | ------------------- | ---------------------------------------- | + * | `analyticsDailySchema` | `ZodObject` | `AnalyticsDaily` | Per-site daily traffic rollup | + * | `funnelEventSchema` | `ZodObject` | `FunnelEventRecord` | Conversion funnel event | + * | `usageEventSchema` | `ZodObject` | `UsageEvent` | Internal metering event for billing | + * + * ## Usage + * + * ```ts + * import { analyticsDailySchema, type AnalyticsDaily } from '@shared/schemas/analytics.js'; + * + * const row: AnalyticsDaily = analyticsDailySchema.parse(rawDbRow); + * console.log(row.page_views, row.unique_visitors); + * ``` + */ +import { z } from 'zod'; +import { baseFields, uuidSchema } from './base.js'; +import { FUNNEL_EVENTS } from '../constants/index.js'; + +/** + * Validates a row from the `analytics_daily` table -- a per-site, per-day + * rollup of traffic metrics. + * + * | Field | Type | Description | + * | ------------------ | --------------------- | -------------------------------------- | + * | `site_id` | UUID | Foreign key to the `sites` table | + * | `date` | `YYYY-MM-DD` string | Calendar date of the rollup | + * | `page_views` | non-negative integer | Total page views for the day | + * | `unique_visitors` | non-negative integer | Distinct visitor count | + * | `bandwidth_bytes` | non-negative integer | Total bytes transferred | + * + * Inherits {@link baseFields} (`id`, `org_id`, timestamps). + */ +export const analyticsDailySchema = z.object({ + ...baseFields, + site_id: uuidSchema, + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + page_views: z.number().int().min(0).default(0), + unique_visitors: z.number().int().min(0).default(0), + bandwidth_bytes: z.number().int().min(0).default(0), +}); + +/** + * Validates a row from the `funnel_events` table, which tracks user + * progression through the sign-up and site-creation conversion funnel. + * + * `event_name` is constrained to the values defined in {@link FUNNEL_EVENTS}. + * `user_id` and `site_id` may be `null` for anonymous or pre-site events. + * + * Unlike most entities this schema does **not** include `updated_at` or + * `deleted_at` -- funnel events are append-only. + */ +export const funnelEventSchema = z.object({ + id: baseFields.id, + org_id: uuidSchema, + user_id: uuidSchema.nullable(), + site_id: uuidSchema.nullable(), + event_name: z.enum(FUNNEL_EVENTS), + metadata_json: z.record(z.unknown()).nullable(), + created_at: baseFields.created_at, +}); + +/** + * Validates a row from the `usage_events` table used by the internal + * metering system (when `METERING_PROVIDER` is `'internal'`). + * + * Each event records a single billable action (e.g. page view, bandwidth + * consumed, AI generation) with an associated `quantity`. + * + * | Field | Type | Description | + * | --------------- | ------------------------ | ---------------------------------- | + * | `event_type` | string (max 100 chars) | Metering event category | + * | `quantity` | non-negative integer | Units consumed | + * | `metadata_json` | JSON object or `null` | Additional event context | + */ +export const usageEventSchema = z.object({ + id: baseFields.id, + org_id: uuidSchema, + event_type: z.string().max(100), + quantity: z.number().int().min(0), + metadata_json: z.record(z.unknown()).nullable(), + created_at: baseFields.created_at, +}); + +/** Inferred TypeScript type for a daily analytics rollup row. */ +export type AnalyticsDaily = z.infer; + +/** Inferred TypeScript type for a funnel event row. */ +export type FunnelEventRecord = z.infer; + +/** Inferred TypeScript type for an internal usage metering event row. */ +export type UsageEvent = z.infer; diff --git a/packages/shared/src/schemas/api.ts b/packages/shared/src/schemas/api.ts new file mode 100644 index 0000000000..3f136c0c78 --- /dev/null +++ b/packages/shared/src/schemas/api.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +/** Standard API error codes */ +export const apiErrorCodes = [ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'CONFLICT', + 'PAYLOAD_TOO_LARGE', + 'RATE_LIMITED', + 'VALIDATION_ERROR', + 'INTERNAL_ERROR', + 'SERVICE_UNAVAILABLE', + 'WEBHOOK_SIGNATURE_INVALID', + 'WEBHOOK_DUPLICATE', + 'IDEMPOTENCY_CONFLICT', + 'STRIPE_ERROR', + 'DOMAIN_PROVISIONING_ERROR', + 'AI_GENERATION_ERROR', + 'LIGHTHOUSE_FAILURE', +] as const; +export type ApiErrorCode = (typeof apiErrorCodes)[number]; + +/** Typed API error */ +export const apiErrorSchema = z.object({ + error: z.object({ + code: z.enum(apiErrorCodes), + message: z.string().max(2000), + request_id: z.string().max(255).optional(), + details: z.record(z.unknown()).optional(), + }), +}); + +/** Health check response */ +export const healthCheckSchema = z.object({ + status: z.enum(['ok', 'degraded', 'error']), + version: z.string(), + environment: z.string(), + timestamp: z.string().datetime(), + checks: z + .record( + z.object({ + status: z.enum(['ok', 'error']), + latency_ms: z.number().optional(), + message: z.string().optional(), + }), + ) + .optional(), +}); + +export type ApiError = z.infer; +export type HealthCheck = z.infer; diff --git a/packages/shared/src/schemas/audit.ts b/packages/shared/src/schemas/audit.ts new file mode 100644 index 0000000000..c522a9ce4a --- /dev/null +++ b/packages/shared/src/schemas/audit.ts @@ -0,0 +1,90 @@ +/** + * @module audit + * @packageDocumentation + * + * Zod schemas for the **audit log** subsystem. + * + * Every state-changing action in the system is recorded as an immutable audit + * log entry scoped to an organization. Entries capture who performed the action + * (`actor_id`), what was affected (`target_type` / `target_id`), and optional + * structured metadata. Audit logs are append-only -- they have a `created_at` + * timestamp but no `updated_at` or `deleted_at`. + * + * | Zod Schema | Inferred Type | Purpose | + * | ---------------------- | ---------------- | -------------------------------------------- | + * | `auditLogSchema` | `AuditLog` | Full audit log entry from the database | + * | `createAuditLogSchema` | `CreateAuditLog` | Payload for recording a new audit log entry | + * + * @example + * ```ts + * import { createAuditLogSchema, type CreateAuditLog } from '@blitz/shared/schemas/audit'; + * + * const entry: CreateAuditLog = { + * org_id: '550e8400-e29b-41d4-a716-446655440000', + * actor_id: '7c9e6679-7425-40de-944b-e07fc1f90ae7', + * action: 'site.published', + * target_type: 'site', + * target_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + * }; + * const parsed = createAuditLogSchema.parse(entry); + * ``` + */ +import { z } from 'zod'; +import { baseFields, uuidSchema, metadataSchema } from './base.js'; + +/** + * Full audit log entry as stored in the `audit_logs` database table. + * + * Fields: + * - `id` -- unique UUID for this entry. + * - `org_id` -- the organization this action belongs to (RLS scope). + * - `actor_id` -- UUID of the user who performed the action, or `null` for + * system-initiated actions (e.g. cron jobs, webhooks). + * - `action` -- a dot-separated verb describing the event (e.g. + * `"site.published"`, `"membership.created"`). 1-100 characters. + * - `target_type` -- the entity type affected (e.g. `"site"`, `"subscription"`). + * - `target_id` -- UUID of the affected entity, or `null` for org-level actions. + * - `metadata_json` -- arbitrary JSON context (max 64 KB), such as before/after + * snapshots or request parameters. + * - `ip_address` -- client IP (IPv4 or IPv6, max 45 chars). + * - `request_id` -- correlation ID for distributed tracing. + * - `created_at` -- ISO 8601 timestamp of when the event was recorded. + */ +export const auditLogSchema = z.object({ + id: baseFields.id, + org_id: baseFields.org_id, + actor_id: uuidSchema.nullable(), + action: z.string().min(1).max(100), + target_type: z.string().max(100).nullable(), + target_id: uuidSchema.nullable(), + metadata_json: metadataSchema.nullable(), + ip_address: z.string().max(45).nullable(), + request_id: z.string().max(255).nullable(), + created_at: baseFields.created_at, +}); + +/** + * Request payload for recording a new audit log entry. + * + * Requires `org_id`, `actor_id` (nullable for system actions), and `action`. + * The remaining fields (`target_type`, `target_id`, `metadata_json`, + * `ip_address`, `request_id`) are all optional and should be supplied when + * available for richer audit trails. Server-managed fields (`id`, + * `created_at`) are omitted and assigned automatically. + */ +export const createAuditLogSchema = z.object({ + org_id: uuidSchema, + actor_id: uuidSchema.nullable(), + action: z.string().min(1).max(100), + target_type: z.string().max(100).optional(), + target_id: uuidSchema.optional(), + metadata_json: metadataSchema.optional(), + ip_address: z.string().max(45).optional(), + request_id: z.string().max(255).optional(), +}); + +/** Inferred TypeScript type for a full audit log entry. */ +export type AuditLog = z.infer; + +/** Inferred TypeScript type for the create-audit-log request payload. */ +export type CreateAuditLog = z.infer; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000000..acb5a26f3a --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { baseFields, emailSchema, uuidSchema } from './base.js'; + +/** User schema */ +export const userSchema = z.object({ + id: baseFields.id, + email: emailSchema.nullable(), + phone: z.string().max(20).nullable(), + display_name: z.string().max(200).nullable(), + avatar_url: z.string().url().max(2048).nullable(), + created_at: baseFields.created_at, + updated_at: baseFields.updated_at, + deleted_at: baseFields.deleted_at, +}); + +/** Session schema */ +export const sessionSchema = z.object({ + id: baseFields.id, + user_id: uuidSchema, + token_hash: z.string().max(128), + device_info: z.string().max(500).nullable(), + ip_address: z.string().max(45).nullable(), + expires_at: z.string().datetime(), + last_active_at: z.string().datetime(), + created_at: baseFields.created_at, + updated_at: baseFields.updated_at, + deleted_at: baseFields.deleted_at, +}); + +/** Magic link request */ +export const createMagicLinkSchema = z.object({ + email: emailSchema, + redirect_url: z.string().url().max(2048).optional(), + turnstile_token: z.string().max(2048).optional(), +}); + +/** Verify magic link */ +export const verifyMagicLinkSchema = z.object({ + token: z.string().min(32).max(512), +}); + +/** Google OAuth initiation */ +export const createGoogleOAuthSchema = z.object({ + redirect_url: z.string().url().max(2048).optional(), +}); + +/** Google OAuth callback */ +export const googleOAuthCallbackSchema = z.object({ + code: z.string().min(1).max(4096), + state: z.string().min(1).max(4096), +}); + +/** Login response */ +export const loginResponseSchema = z.object({ + user: userSchema, + session: z.object({ + token: z.string(), + expires_at: z.string().datetime(), + }), + requires_2fa: z.boolean(), +}); + +export type User = z.infer; +export type Session = z.infer; +export type CreateMagicLink = z.infer; +export type VerifyMagicLink = z.infer; +export type GoogleOAuthCallback = z.infer; +export type LoginResponse = z.infer; diff --git a/packages/shared/src/schemas/base.ts b/packages/shared/src/schemas/base.ts new file mode 100644 index 0000000000..7e6df33a4d --- /dev/null +++ b/packages/shared/src/schemas/base.ts @@ -0,0 +1,286 @@ +/** + * @module base + * @packageDocumentation + * + * Foundational Zod schemas and reusable field definitions shared across the + * entire Project Sites data model. Every database-backed entity extends + * {@link baseFields}, and the primitive validators defined here (UUID, slug, + * email, hostname, etc.) are imported by every other schema module. + * + * ## Schemas and Types + * + * | Export | Kind | Description | + * | ------------------------ | ----------------- | -------------------------------------------------- | + * | `baseFields` | field map | `id`, `org_id`, `created_at`, `updated_at`, `deleted_at` columns | + * | `uuidSchema` | `ZodString` | UUID v4 string | + * | `slugSchema` | `ZodString` | Lowercase alphanumeric slug (3-63 chars) | + * | `emailSchema` | `ZodString` | RFC-compliant email, max 254 chars, lowercased | + * | `phoneSchema` | `ZodString` | E.164 international phone number | + * | `hostnameSchema` | `ZodString` | Valid DNS hostname (3-253 chars) | + * | `httpsUrlSchema` | `ZodString` | HTTPS-only URL, max 2048 chars | + * | `safeStringSchema` | `ZodString` | XSS-safe string (no script/data/javascript URIs) | + * | `nameSchema` | `ZodString` | Short safe string for names/titles (1-200 chars) | + * | `paginationSchema` | `ZodObject` | `{ limit, offset }` with coercion and defaults | + * | `errorEnvelopeSchema` | `ZodObject` | Standard API error response envelope | + * | `successEnvelopeSchema` | generic factory | Standard API success response with optional meta | + * | `confidenceScoreSchema` | `ZodNumber` | Integer 0-100 | + * | `metadataSchema` | `ZodRecord` | Arbitrary JSON capped at 64 KB | + * + * ## Usage + * + * ```ts + * import { baseFields, slugSchema, successEnvelopeSchema } from '@shared/schemas/base.js'; + * import { z } from 'zod'; + * + * const mySiteSchema = z.object({ + * ...baseFields, + * slug: slugSchema, + * name: z.string().min(1), + * }); + * + * const apiResponse = successEnvelopeSchema(mySiteSchema); + * type MySite = z.infer; + * ``` + */ +import { z } from 'zod'; + +/** + * Reusable base fields shared by every database-backed entity. + * + * Spread these into any `z.object()` call to inherit the standard primary key, + * organisation scope, and timestamp columns present on every Postgres table. + * + * | Field | Type | Description | + * | ------------ | ---------------------------- | --------------------------------- | + * | `id` | UUID v4 string | Primary key | + * | `org_id` | UUID v4 string | Owning organisation (RLS scope) | + * | `created_at` | ISO 8601 datetime string | Row creation timestamp | + * | `updated_at` | ISO 8601 datetime string | Last modification timestamp | + * | `deleted_at` | ISO 8601 datetime or `null` | Soft-delete timestamp | + */ +export const baseFields = { + id: z.string().uuid(), + org_id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime(), + deleted_at: z.string().datetime().nullable(), +}; + +/** + * Validates a UUID v4 string. + * + * Used as the standard identifier type for primary keys and foreign keys + * throughout the data model. + * + * @example + * ```ts + * uuidSchema.parse('f47ac10b-58cc-4372-a567-0e02b2c3d479'); // OK + * uuidSchema.parse('not-a-uuid'); // throws ZodError + * ``` + */ +export const uuidSchema = z.string().uuid(); + +/** + * Validates a URL-safe slug (e.g. site or organisation identifier). + * + * Rules: + * - 3 to 63 characters long (DNS label-safe). + * - Lowercase alphanumeric characters and hyphens only. + * - Must start and end with an alphanumeric character. + * + * @example + * ```ts + * slugSchema.parse('my-cool-site'); // OK + * slugSchema.parse('-bad'); // throws ZodError + * ``` + */ +export const slugSchema = z + .string() + .min(3) + .max(63) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, 'Invalid slug format'); + +/** + * Validates and normalises an email address. + * + * - Conforms to the RFC 5321 maximum of 254 characters. + * - The value is lower-cased automatically via `.toLowerCase()`. + * + * @example + * ```ts + * emailSchema.parse('User@Example.COM'); // "user@example.com" + * ``` + */ +export const emailSchema = z.string().email().max(254).toLowerCase(); + +/** + * Validates a phone number in E.164 international format. + * + * - Starts with `+` followed by a non-zero digit and 1-14 additional digits. + * - Total length between 10 and 15 characters (including `+`). + * + * @see {@link https://www.itu.int/rec/T-REC-E.164 | ITU-T E.164} + * + * @example + * ```ts + * phoneSchema.parse('+14155552671'); // OK + * phoneSchema.parse('4155552671'); // throws ZodError (missing '+') + * ``` + */ +export const phoneSchema = z + .string() + .min(10) + .max(15) + .regex(/^\+[1-9]\d{1,14}$/, 'Phone must be E.164 format'); + +/** + * Validates a fully-qualified DNS hostname. + * + * - 3 to 253 characters (per RFC 1035 / RFC 1123). + * - Each label is alphanumeric with optional internal hyphens. + * - TLD must be at least 2 alphabetic characters. + * + * @example + * ```ts + * hostnameSchema.parse('my-site.megabyte.space'); // OK + * hostnameSchema.parse('_invalid.host'); // throws ZodError + * ``` + */ +export const hostnameSchema = z + .string() + .min(3) + .max(253) + .regex(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, 'Invalid hostname format'); + +/** + * Validates a URL that **must** use the HTTPS scheme. + * + * - Maximum length of 2 048 characters. + * - Rejects `http://` and other non-HTTPS schemes. + * + * @example + * ```ts + * httpsUrlSchema.parse('https://example.com/page'); // OK + * httpsUrlSchema.parse('http://example.com/page'); // throws ZodError + * ``` + */ +export const httpsUrlSchema = z.string().url().startsWith('https://').max(2048); + +/** + * Validates a user-supplied string and rejects common XSS vectors. + * + * Blocked patterns: + * - `'); + * // => '

Hello

' + * + * const plain = stripHtml('Bold text'); + * // => 'Bold text' + * + * const slug = businessNameToSlug("Joe's Bar & Grill"); + * // => 'joes-bar-and-grill' + * ``` + * + * @module sanitize + * @packageDocumentation + */ + +/** + * Sanitize an HTML string by removing known XSS vectors. + * + * The following dangerous patterns are stripped: + * - `