diff --git a/.cursor/rules/api-routes.mdc b/.cursor/rules/api-routes.mdc index 42c9f31..b8892e5 100644 --- a/.cursor/rules/api-routes.mdc +++ b/.cursor/rules/api-routes.mdc @@ -16,7 +16,7 @@ Keep new routes within this shape so auth, config, and validation stay uniform. ``` From `lib/server/env` + `lib/server/responses`. Returns a consistent 503 - when `DATABASE_URL` is missing (local dev, preview builds). + when `CLOUDRON_POSTGRESQL_URL` is missing (local dev, preview builds). 2. **Auth (when the route requires a user).** diff --git a/.env.example b/.env.example index fc4265b..f243b7e 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,21 @@ # Copy to `.env` for local development (never commit real secrets). # PostgreSQL — use `docker compose up -d postgres` and match user/db/password. -DATABASE_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule" +CLOUDRON_POSTGRESQL_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule" # Session signing + secret used when hashing magic-link tokens. Min 16 characters; use a long random string in production. SESSION_SECRET="dev-only-change-me-16chars-min" -# Optional: Nodemailer transport URL, e.g. `smtp://localhost:1025` with Mailhog from docker-compose. -# Leave unset in development to log the magic-link verify URL to the server console instead of sending email. -SMTP_URL= +# Optional Mailhog (docker compose mailhog service): +# CLOUDRON_MAIL_SMTP_SERVER=localhost +# CLOUDRON_MAIL_SMTP_PORT=1025 +# CLOUDRON_MAIL_SMTP_USERNAME= +# CLOUDRON_MAIL_SMTP_PASSWORD= + +# Leave mail vars unset in dev to log the magic-link verify URL to the server console instead of sending email. SMTP_FROM="Community Rule " -# CR-107: inbox for Ask an organizer form submissions (requires SMTP_URL in production). +# CR-107: inbox for Ask an organizer form submissions (requires CLOUDRON_MAIL_SMTP_* in production). ORGANIZER_INQUIRY_TO= # Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c0f27c..57f71a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ 1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters). 2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only - need Postgres. Without `SMTP_URL`, the **magic-link verify URL** is + need Postgres. Without `CLOUDRON_MAIL_SMTP_*`, the **magic-link verify URL** is printed in the dev server log. 3. `npm ci` 4. `npx prisma migrate dev` @@ -64,8 +64,9 @@ deployment-pipeline work. - Visit **[/login](http://localhost:3000/login)** or use **Log in** in the site header. -- Without `SMTP_URL`: copy the verify URL from the dev server terminal. -- With Mailhog: set `SMTP_URL=smtp://localhost:1025` and open the message +- Without `CLOUDRON_MAIL_SMTP_*`: copy the verify URL from the dev server terminal. +- With Mailhog: set `CLOUDRON_MAIL_SMTP_SERVER=localhost` and + `CLOUDRON_MAIL_SMTP_PORT=1025` (see `.env.example`) and open the message at [http://localhost:8025](http://localhost:8025). - Open the link in the **same browser** as the app (session cookie). diff --git a/Dockerfile b/Dockerfile index b9c544c..7193d79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Optional production image (Next.js standalone output + Prisma). # Build: docker build -t community-rule . -# Run: pass DATABASE_URL, SESSION_SECRET, etc. at runtime (see .env.example). +# Run: pass CLOUDRON_POSTGRESQL_URL, CLOUDRON_MAIL_SMTP_*, SESSION_SECRET, etc. at runtime (see .env.example). FROM node:20-bookworm-slim AS base WORKDIR /app diff --git a/docs/guides/backend-linear-tickets.md b/docs/guides/backend-linear-tickets.md index 4bdc534..9375ca9 100644 --- a/docs/guides/backend-linear-tickets.md +++ b/docs/guides/backend-linear-tickets.md @@ -24,7 +24,7 @@ Use this if you **do not** have SSH or hosting access yet. Most engineering tick ### You do **not** need the server admin for -- **Tickets 1–8, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. **Magic-link** sign-in email can use Mailhog or **dev server logs** (verify URL) when `SMTP_URL` is unset—no real SMTP required locally. +- **Tickets 1–8, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. **Magic-link** sign-in email can use Mailhog or **dev server logs** (verify URL) when `CLOUDRON_MAIL_SMTP_*` is unset—no real SMTP required locally. - **Verifying APIs:** Use `localhost` and the same Docker Postgres—no production host. ### The **first** time you need someone with hosting access @@ -35,10 +35,10 @@ Ask the admin to provide (or do for you) the items below—**Ticket 12** turns t | What | Why you need it | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Postgres** | Managed instance or container; a **`DATABASE_URL`** you can plug into the deployed app. | +| **Postgres** | Managed instance or container; a **`CLOUDRON_POSTGRESQL_URL`** you can plug into the deployed app. | | **Run migrations** | Someone runs **`npx prisma migrate deploy`** against that database **before** the new app version serves traffic (or gives you a secure way to run it in CI/CD). | | **`SESSION_SECRET`** | Long random string in production env (sessions **+ hashed magic-link tokens**). | -| **SMTP** | **`SMTP_URL`** + **`SMTP_FROM`** for real **sign-in link** email; not required on laptop if you use logs/Mailhog. | +| **SMTP** | **`CLOUDRON_MAIL_SMTP_*`** + **`SMTP_FROM`** for real **sign-in link** email; not required on laptop if you use logs/Mailhog. | | **DNS for mail** | Often **SPF/DKIM** so **magic-link** messages are not spam—admin or whoever owns DNS. | | **TLS + hostname** | HTTPS URL for the site; reverse proxy (nginx, Caddy, etc.) in front of Node. | | **Health check** | Load balancer or platform should probe **`GET /api/health`** (or your chosen path). | @@ -137,7 +137,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi 2. Flow: email → “Send link” → user opens link (email, Mailhog, or dev log) → `GET /api/auth/magic-link/verify?token=...` sets session and redirects; optional `next` for post-login path. 3. Surface API errors: invalid email, 429 `retryAfterMs`, expired/invalid token, network failure (accessible copy). 4. Ensure `fetch` calls use `credentials: "include"` where needed (see [lib/create/api.ts](lib/create/api.ts)). -5. **Dev:** without `SMTP_URL`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`. +5. **Dev:** without `CLOUDRON_MAIL_SMTP_*`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `CLOUDRON_MAIL_SMTP_SERVER=localhost` + `CLOUDRON_MAIL_SMTP_PORT=1025`. 6. **Marketing header:** When signed in (`fetchAuthSession`), **Log in** becomes **Profile** linking to [`/profile`](app/(app)/profile/page.tsx) (placeholder until Ticket 15 / CR-86). Implemented in [TopWithPathname.tsx](app/components/navigation/Top/TopWithPathname.tsx) + [Top.container.tsx](app/components/navigation/Top/Top.container.tsx). **Acceptance criteria:** @@ -678,7 +678,7 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule** **Per-ticket detail:** -1. **Bridge `CLOUDRON_*` env vars to canonical names.** Cloudron injects `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_SERVER/PORT/USERNAME/PASSWORD`; the app reads `DATABASE_URL` / `SMTP_URL`. Recommended approach: read both names in [`lib/server/env.ts`](../../lib/server/env.ts) and assemble `SMTP_URL` from the four parts in [`lib/server/mail.ts`](../../lib/server/mail.ts) when only the Cloudron names are present. Alternative: a `start.sh` shim in the image. Acceptance: with only `CLOUDRON_*` set, app connects to DB and sends mail; with only canonical names set (current behavior), unchanged; unit tests cover both. +1. **Cloudron-native env vars (CR-96).** App reads `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` only (no `DATABASE_URL` / `SMTP_URL` shim). Local dev uses the same names in `.env`. SMTP URL assembled in [`lib/server/env.ts`](../../lib/server/env.ts); mail senders use `getSmtpUrl()`. Acceptance: with only `CLOUDRON_*` set, app connects to DB and sends mail; unit tests in `tests/unit/env.test.ts`. 2. **Container image registry: choose, build, push.** Acceptance: `docker pull /communityrule:` works from a Cloudron-reachable network. CI builds and pushes on merge to `main` (stretch). 3. **Cloudron staging install + smoke.** Acceptance: `curl https:///api/health` returns `{"ok":true,"database":"connected"}`; magic-link request → click link → `GET /api/auth/session` returns a user; publishing a rule succeeds. 4. **Cloudron production install + DNS cutover.** Acceptance: production subdomain resolves to the new app; old subdomain still works during overlap; sign-in + publish succeed against production; backups confirmed. diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index ee13127..0dc535b 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -76,7 +76,7 @@ Full table: [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes**. **Step 2.** Use **Prisma** — `schema.prisma`, `npx prisma migrate dev` / `migrate deploy`. -**Step 3.** Add **SMTP** (or Mailhog locally) for **magic-link** sign-in email in deployed environments; when `SMTP_URL` is unset in dev, the app can log the **verify URL** to the console (same pattern as [`lib/server/mail.ts`](lib/server/mail.ts)). +**Step 3.** Add **SMTP** (or Mailhog locally) for **magic-link** sign-in email in deployed environments; when `CLOUDRON_MAIL_SMTP_*` is unset in dev, the app can log the **verify URL** to the console (same pattern as [`lib/server/mail.ts`](lib/server/mail.ts)). **Step 4.** **Redis / queues / Kubernetes** — not required for v1. **Exception:** before running **multiple app instances**, plan a **shared rate-limit store** (often Redis) for **passwordless email (magic-link request)**; the current limiter is in-memory per process ([`lib/server/rateLimit.ts`](lib/server/rateLimit.ts)). @@ -157,7 +157,7 @@ Match the current API behavior; tighten as product evolves: --- -**Step 1.** Copy `.env.example` to `.env`. Set `DATABASE_URL` and secrets (see file comments). +**Step 1.** Copy `.env.example` to `.env`. Set `CLOUDRON_POSTGRESQL_URL` and secrets (see file comments). **Step 2.** Start Postgres locally: @@ -183,7 +183,7 @@ npm run dev **Step 6.** **Magic-link sign-in** (happy path): 1. `POST /api/auth/magic-link/request` with `{ "email": "you@example.com" }` (optional `"next"` for redirect after verify). -2. Open the link from email, Mailhog, or **server logs** when `SMTP_URL` is unset (dev). +2. Open the link from email, Mailhog, or **server logs** when `CLOUDRON_MAIL_SMTP_*` is unset (dev). 3. Browser hits `GET /api/auth/magic-link/verify?token=...` (and optional `next=...`); response sets the session cookie and redirects. 4. `GET /api/auth/session` should show your user in the same browser. @@ -221,7 +221,7 @@ npm run dev **Optional QA:** Run automated tests against an **ephemeral** database in CI instead of maintaining a fourth long-lived server. -**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. Admin handoff (access, env vars, platform settings, open decisions): [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). Note: Cloudron injects `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*`; the app reads `DATABASE_URL` / `SMTP_URL`, so a small env-var bridge in [`lib/server/env.ts`](../../lib/server/env.ts) / [`lib/server/mail.ts`](../../lib/server/mail.ts) is needed (tracked in [**CR-96**](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names), filed under CR-83 — see [backend-linear-tickets.md](backend-linear-tickets.md) Ticket 12 follow-ups). +**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. Admin handoff (access, env vars, platform settings, open decisions): [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). The app reads Cloudron-injected `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` via [`lib/server/env.ts`](../../lib/server/env.ts) (CR-96). **Admin / infra (coordinate with whoever runs the server):** diff --git a/docs/guides/ops-backend-deploy.md b/docs/guides/ops-backend-deploy.md index 468a354..032df08 100644 --- a/docs/guides/ops-backend-deploy.md +++ b/docs/guides/ops-backend-deploy.md @@ -58,13 +58,13 @@ Cloudron addons are not "enabled" platform-wide; they are requested per-app in the manifest and provisioned at install time. - `CLOUDRON_POSTGRESQL_URL` — from the **postgresql** addon. The app - reads `DATABASE_URL`; bridging is a small in-app code change (see - §8 [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names)). + reads this name directly (Prisma + [`lib/server/env.ts`](../../lib/server/env.ts)). - `CLOUDRON_MAIL_SMTP_SERVER` / `_PORT` / `_USERNAME` / `_PASSWORD` — from the **sendmail** addon. The platform Mail server is configured for `communityrule.info` with **Amazon SES relay** + "allow custom - from address" on, so `SMTP_FROM` of our choice will deliver. The - app reads `SMTP_URL`; bridged the same way. + from address" on, so `SMTP_FROM` of our choice will deliver. The app + assembles a Nodemailer transport URL from these four vars in + [`lib/server/env.ts`](../../lib/server/env.ts). ### I set manually via `cloudron configure --app --set-env` @@ -188,8 +188,8 @@ All filed in Linear, titled `[Backend] …`, assigned to me, in the **Community-rule** team, **Backlog** state. 1. [**CR-96**](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) - — `[Backend] Bridge CLOUDRON_* env vars to canonical names`. No - blockers; can land now. + — `[Backend] Cloudron-native env vars` (shipped: app reads + `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` only). 2. [**CR-97**](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) — `[Backend] Container image registry: choose, build, push`. Blocked by registry decision (§6.3). diff --git a/docs/guides/template-recommendation-matrix.md b/docs/guides/template-recommendation-matrix.md index e3c336c..aee7597 100644 --- a/docs/guides/template-recommendation-matrix.md +++ b/docs/guides/template-recommendation-matrix.md @@ -788,7 +788,7 @@ and return all rows. score-0 templates still present at the end in curated order. - [x] No-facets `GET /api/templates` matches today's curated ordering (no regression for the existing marketing/templates surfaces). -- [x] DB-down smoke: with `DATABASE_URL` unset, the four wizard +- [x] DB-down smoke: with `CLOUDRON_POSTGRESQL_URL` unset, the four wizard card-deck steps still render the full deck from messages (no 5xx, no broken cards). - [x] Editing a `data/create/customRule/
.json` entry and diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 8812e57..2eb9ecf 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -26,7 +26,7 @@ Starts a throwaway Postgres on `127.0.0.1:5433`, runs `prisma migrate deploy`, checks the connection, then removes the container. Port **5433** avoids clashing with `docker compose` on **5432**. If you already use Compose on 5432: `docker compose up -d postgres` then -`DATABASE_URL=postgresql://communityrule:communityrule@127.0.0.1:5432/communityrule npm run db:deploy`. +`CLOUDRON_POSTGRESQL_URL=postgresql://communityrule:communityrule@127.0.0.1:5432/communityrule npm run db:deploy`. Do not rewrite migrations already applied to shared DBs — see [CONTRIBUTING.md](../CONTRIBUTING.md) and diff --git a/lib/server/env.ts b/lib/server/env.ts index f6bf30b..7f11729 100644 --- a/lib/server/env.ts +++ b/lib/server/env.ts @@ -8,6 +8,24 @@ export function getSessionPepper(): string { return secret; } -export function isDatabaseConfigured(): boolean { - return Boolean(process.env.DATABASE_URL?.trim()); +export function getDatabaseUrl(): string | undefined { + return process.env.CLOUDRON_POSTGRESQL_URL?.trim() || undefined; +} + +export function getSmtpUrl(): string | undefined { + const server = process.env.CLOUDRON_MAIL_SMTP_SERVER?.trim(); + const port = process.env.CLOUDRON_MAIL_SMTP_PORT?.trim(); + if (!server || !port) return undefined; + + const username = process.env.CLOUDRON_MAIL_SMTP_USERNAME?.trim() ?? ""; + const password = process.env.CLOUDRON_MAIL_SMTP_PASSWORD?.trim() ?? ""; + if (username || password) { + const auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; + return `smtp://${auth}${server}:${port}`; + } + return `smtp://${server}:${port}`; +} + +export function isDatabaseConfigured(): boolean { + return Boolean(getDatabaseUrl()); } diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 44f4da2..29e29fe 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -1,18 +1,19 @@ import nodemailer from "nodemailer"; import { logger } from "../logger"; +import { getSmtpUrl } from "./env"; export async function sendMagicLinkEmail( to: string, verifyUrl: string, ): Promise { - const url = process.env.SMTP_URL; + const url = getSmtpUrl(); if (!url) { if (process.env.NODE_ENV === "development") { logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`); return; } - throw new Error("SMTP_URL is not configured"); + throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); } const transporter = nodemailer.createTransport(url); @@ -33,7 +34,7 @@ export async function sendRuleStakeholderInviteEmail( verifyUrl: string, ruleTitle: string, ): Promise { - const url = process.env.SMTP_URL; + const url = getSmtpUrl(); if (!url) { if (process.env.NODE_ENV === "development") { @@ -42,7 +43,7 @@ export async function sendRuleStakeholderInviteEmail( ); return; } - throw new Error("SMTP_URL is not configured"); + throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); } const transporter = nodemailer.createTransport(url); @@ -66,7 +67,7 @@ export async function sendOrganizerInquiryNotification(params: { requestId: string; }): Promise { const { to, fromEmail, visitorEmail, message, requestId } = params; - const url = process.env.SMTP_URL; + const url = getSmtpUrl(); if (!url) { if (process.env.NODE_ENV === "development") { @@ -75,7 +76,7 @@ export async function sendOrganizerInquiryNotification(params: { ); return; } - throw new Error("SMTP_URL is not configured"); + throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); } const transporter = nodemailer.createTransport(url); @@ -93,14 +94,14 @@ export async function sendEmailChangeEmail( to: string, verifyUrl: string, ): Promise { - const url = process.env.SMTP_URL; + const url = getSmtpUrl(); if (!url) { if (process.env.NODE_ENV === "development") { logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`); return; } - throw new Error("SMTP_URL is not configured"); + throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); } const transporter = nodemailer.createTransport(url); diff --git a/lib/server/responses.ts b/lib/server/responses.ts index 1dcc0e9..a58c80e 100644 --- a/lib/server/responses.ts +++ b/lib/server/responses.ts @@ -50,7 +50,7 @@ export function errorJson( export function dbUnavailable(): NextResponse { return errorJson( "db_unavailable", - "Database is not configured (DATABASE_URL).", + "Database is not configured (CLOUDRON_POSTGRESQL_URL).", 503, ); } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3fbf29..e145794 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,7 +4,7 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") + url = env("CLOUDRON_POSTGRESQL_URL") } model User { diff --git a/scripts/migrate-smoke-local.sh b/scripts/migrate-smoke-local.sh index 970ce3d..23d359d 100755 --- a/scripts/migrate-smoke-local.sh +++ b/scripts/migrate-smoke-local.sh @@ -7,7 +7,7 @@ POSTGRES_USER="${POSTGRES_USER:-communityrule}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-communityrule}" POSTGRES_DB="${POSTGRES_DB:-communityrule}" CONTAINER_NAME="${CONTAINER_NAME:-migrate-smoke-pg}" -export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${PG_HOST_PORT}/${POSTGRES_DB}" +export CLOUDRON_POSTGRESQL_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${PG_HOST_PORT}/${POSTGRES_DB}" cleanup() { docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true @@ -41,6 +41,6 @@ echo "→ prisma migrate deploy" npm run db:deploy echo "→ Verifying connection (SELECT 1)" -echo "SELECT 1;" | npx --no-install prisma db execute --stdin --url "$DATABASE_URL" +echo "SELECT 1;" | npx --no-install prisma db execute --stdin --url "$CLOUDRON_POSTGRESQL_URL" echo "→ migrate smoke OK" diff --git a/tests/unit/api/health.route.test.ts b/tests/unit/api/health.route.test.ts index 7a32bbd..da9568f 100644 --- a/tests/unit/api/health.route.test.ts +++ b/tests/unit/api/health.route.test.ts @@ -22,7 +22,7 @@ beforeEach(() => { }); describe("GET /api/health", () => { - it("returns not_configured when DATABASE_URL is unset", async () => { + it("returns not_configured when database is not configured", async () => { isDatabaseConfiguredMock.mockReturnValue(false); const res = await GET( new NextRequest("https://x.test/api/health"), diff --git a/tests/unit/env.test.ts b/tests/unit/env.test.ts new file mode 100644 index 0000000..b04648c --- /dev/null +++ b/tests/unit/env.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + getDatabaseUrl, + getSmtpUrl, + isDatabaseConfigured, +} from "../../lib/server/env"; + +const ENV_KEYS = [ + "CLOUDRON_POSTGRESQL_URL", + "CLOUDRON_MAIL_SMTP_SERVER", + "CLOUDRON_MAIL_SMTP_PORT", + "CLOUDRON_MAIL_SMTP_USERNAME", + "CLOUDRON_MAIL_SMTP_PASSWORD", +] as const; + +const ORIGINAL_ENV = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), +) as Record<(typeof ENV_KEYS)[number], string | undefined>; + +function clearEnv(): void { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +function restoreEnv(): void { + for (const key of ENV_KEYS) { + const originalValue = ORIGINAL_ENV[key]; + if (originalValue === undefined) { + delete process.env[key]; + continue; + } + process.env[key] = originalValue; + } +} + +beforeEach(() => { + clearEnv(); +}); + +afterEach(() => { + restoreEnv(); +}); + +describe("getDatabaseUrl / isDatabaseConfigured", () => { + it("returns the URL when CLOUDRON_POSTGRESQL_URL is set", () => { + process.env.CLOUDRON_POSTGRESQL_URL = + "postgresql://user:pass@localhost:5432/db"; + expect(getDatabaseUrl()).toBe( + "postgresql://user:pass@localhost:5432/db", + ); + expect(isDatabaseConfigured()).toBe(true); + }); + + it("returns undefined when unset", () => { + expect(getDatabaseUrl()).toBeUndefined(); + expect(isDatabaseConfigured()).toBe(false); + }); + + it("treats whitespace-only as unset", () => { + process.env.CLOUDRON_POSTGRESQL_URL = " "; + expect(getDatabaseUrl()).toBeUndefined(); + expect(isDatabaseConfigured()).toBe(false); + }); +}); + +describe("getSmtpUrl", () => { + it("returns undefined when server or port is missing", () => { + process.env.CLOUDRON_MAIL_SMTP_SERVER = "localhost"; + expect(getSmtpUrl()).toBeUndefined(); + + clearEnv(); + process.env.CLOUDRON_MAIL_SMTP_PORT = "1025"; + expect(getSmtpUrl()).toBeUndefined(); + }); + + it("builds a no-auth URL for Mailhog-style local SMTP", () => { + process.env.CLOUDRON_MAIL_SMTP_SERVER = "localhost"; + process.env.CLOUDRON_MAIL_SMTP_PORT = "1025"; + expect(getSmtpUrl()).toBe("smtp://localhost:1025"); + }); + + it("builds an authenticated URL with encoded credentials", () => { + process.env.CLOUDRON_MAIL_SMTP_SERVER = "smtp.example.com"; + process.env.CLOUDRON_MAIL_SMTP_PORT = "587"; + process.env.CLOUDRON_MAIL_SMTP_USERNAME = "user@domain"; + process.env.CLOUDRON_MAIL_SMTP_PASSWORD = "p@ss:word"; + expect(getSmtpUrl()).toBe( + "smtp://user%40domain:p%40ss%3Aword@smtp.example.com:587", + ); + }); + + it("includes auth when only username is set", () => { + process.env.CLOUDRON_MAIL_SMTP_SERVER = "smtp.example.com"; + process.env.CLOUDRON_MAIL_SMTP_PORT = "25"; + process.env.CLOUDRON_MAIL_SMTP_USERNAME = "apikey"; + expect(getSmtpUrl()).toBe("smtp://apikey:@smtp.example.com:25"); + }); +});