diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7720e10..9139116 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,11 @@ ## 检查清单 -- [ ] `npm run verify` 通过 +- [ ] 已按改动范围选择最小充分的测试层级 +- [ ] `npm run test:fast` 通过(如涉及逻辑或代码) +- [ ] 相关 UI/E2E 测试通过(如涉及页面、导航、离线或 PWA) +- [ ] 数据库/Functions/集成测试通过(如涉及对应边界) +- [ ] `npm run test:full` 通过(合并前、发布前或高风险改动) - [ ] 未提交 `.env`、`.vercel`、secret key 或个人数据 - [ ] 数据库变更附带迁移文件 - [ ] 已检查本次变更涉及的 README、公开 docs、CHANGELOG、发布说明及隐私/安全文档,并已同步实现差异 @@ -22,3 +26,9 @@ - [ ] 如包含外部贡献或第三方代码,已阅读 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md),并在 PR 中说明来源与授权状态 - [ ] 已运行 `npm run public:check`,并复查暂存区没有内部、敏感或不必要文件 - [ ] 如果这是发布准备变更,已确认 `release-gate.config.json`、版本文件和发布说明同步 + +## 验证记录 + +- 选定层级: +- 命令与结果: +- 未运行或被环境阻塞的检查及原因: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12bed71..2e8b92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,10 +62,13 @@ jobs: run: | status="$(npx supabase status --output env 2>/dev/null)" api_url="$(printf '%s\n' "$status" | sed -n 's/^API_URL=//p' | tr -d '"')" + db_url="$(printf '%s\n' "$status" | sed -n 's/^DB_URL=//p' | tr -d '"')" publishable_key="$(printf '%s\n' "$status" | sed -n 's/^ANON_KEY=//p' | tr -d '"')" test -n "$api_url" + test -n "$db_url" test -n "$publishable_key" echo "VITE_SUPABASE_URL=$api_url" >> "$GITHUB_ENV" + echo "SHADOW_MATE_TEST_DB_URL=$db_url" >> "$GITHUB_ENV" echo "VITE_SUPABASE_PUBLISHABLE_KEY=$publishable_key" >> "$GITHUB_ENV" echo "E2E_REAL_SUPABASE=1" >> "$GITHUB_ENV" - name: Run database tests diff --git a/.github/workflows/shared-supabase-policy.yml b/.github/workflows/shared-supabase-policy.yml new file mode 100644 index 0000000..b14f610 --- /dev/null +++ b/.github/workflows/shared-supabase-policy.yml @@ -0,0 +1,46 @@ +name: Shared Supabase Policy + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + push: + branches: [main] + +permissions: + contents: read + +jobs: + shared-supabase-policy: + name: Shared Supabase Policy + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check migration policy + shell: bash + env: + MIGRATION_PATH: supabase/migrations + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + + changed_migrations="$(git diff --name-status "$BASE_SHA" "$HEAD_SHA" -- "$MIGRATION_PATH" || true)" + if [[ -n "$changed_migrations" ]]; then + echo "Product repositories may not add or modify executable files under ${MIGRATION_PATH}." + echo "$changed_migrations" + exit 1 + fi + + if git diff --unified=0 "$BASE_SHA" "$HEAD_SHA" -- . ':(exclude).github/workflows/shared-supabase-policy.yml' | grep -nE 'supabase db push|supabase db query --linked|supabase migration repair|supabase db reset --linked|SUPABASE_DB_PASSWORD|SUPABASE_ACCESS_TOKEN'; then + echo "Direct production migration or migration-repair commands are forbidden in product repositories." + exit 1 + fi + + echo "Shared Supabase policy passed (local private-repository adapter)." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fd8978..983acd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,17 +6,31 @@ npm.cmd ci git config core.hooksPath .githooks git config user.email "YOUR_GITHUB_NOREPLY_ADDRESS" -npm.cmd run verify +npm.cmd run test:fast ``` Get the noreply address from GitHub **Settings → Emails**. Do not use a personal or work mailbox in public commit metadata. Maintainers may create an ignored `.security-local-denylist` file with one private term per line. The security check scans tracked and untracked candidate files without publishing the denylist itself. +## 测试范围与分层 + +先写清本次改动的范围、明确不做什么、验收条件和受影响边界,再按风险选择最小充分的验证层级。开发循环不要求每次小改动都运行全量测试;合并、发布和高风险边界仍必须经过完整门禁。 + +| 层级 | 适用场景 | 命令 | +| --- | --- | --- | +| 静态 | 文档、文案、低风险 CSS 或静态检查 | `npm run check` | +| 快速 | 纯逻辑、数据模型、控制器 | `npm run test:fast` | +| 页面 | 导航、设置、PWA、离线和可见交互 | `npm run test:ui`,或运行指定 E2E 文件 | +| 集成 | Supabase schema/RLS、Functions、认证、同步、导出/删除 | `npm run test:db`、`npm run test:functions`,以及受影响的 E2E | +| 完整 | 合并前、发布前、依赖/公开资源或高风险边界 | `npm run test:full` | + +`test:fast` 当前包含全部 unit test 和 `check`,它是比浏览器/数据库测试更快的项目级入口,但不是 changed-only 测试。`verify` 负责公开范围、安全检查、静态检查、构建和覆盖率,不包含数据库、Functions 或 E2E;`test:full` 只在合并、发布或高风险边界运行。PR 必须记录实际选择的层级、命令、结果,以及未运行或被环境阻塞的检查。 + ## Required checks - Use a branch and pull request; do not push directly to `main`. -- Run `npm run verify` before pushing. +- Before pushing, run the smallest sufficient layer for the changed surface; source/build/public-resource changes require `npm run verify`, with database, Functions and E2E layers added when affected. Run `npm run test:full` before merge or release. - For a release tag, run `npm run build` followed by `npm run release:check`; the tag workflow repeats this against the final archive. - Commit `package-lock.json` and pin dependency versions. - Add explicit PostgreSQL grants and RLS policies in the same migration. @@ -37,7 +51,7 @@ Maintainers may create an ignored `.security-local-denylist` file with one priva - 检查本次 `git diff` 涉及的用户行为、数据模型、迁移、配置、测试命令、覆盖率、版本号和发布状态。 - 按影响范围同步 `README.md`、公开 `docs/`、`CHANGELOG.md`、`RELEASE_NOTES.md`、隐私/安全文档和 PR 说明;内部计划、法律记录和发布闸门不得放入公开目录。 - 代码、测试、迁移、配置和对应文档必须作为同一项工作提交并推送,禁止明知文档过期而先提交代码、之后再补文档。 -- 提交前运行 `npm run public:check`、`git diff --cached --check` 和 `npm run verify`,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交。 +- 提交前运行 `npm run public:check`、`git diff --cached --check` 和与改动范围匹配的测试层,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交;合并前或发布前补齐 `npm run test:full`。 - 推送前重新核对远端仓库可见性、目标分支、PR base/head 和 PR 描述;任何不确定的文件先移出暂存区,不要“先提交再解释”。 ## Release 闸门 @@ -59,7 +73,7 @@ Release 必须在 Tag 上执行,不把普通 PR 当作发布验收: ## GitHub 协作流程 -- 本地先运行 `npm.cmd run verify`;涉及端到端流程时,再运行 `npm.cmd run test:e2e`。 +- 本地先运行 `npm.cmd run test:fast`;涉及页面交互时,再运行 `npm.cmd run test:ui`;涉及数据库、认证或同步时,补充对应集成测试。合并前按风险矩阵运行 `npm.cmd run test:full`。 - 使用分支提交并推送,保持现有 SSH 远程仓库配置;不需要为每次 PR 重复配置 GitHub CLI。 - 分支推送后,优先使用已连接的 GitHub 插件创建、查看、Review 和合并 PR,避免通过浏览器重复填写表单。 - PR 作者不能批准自己的 PR;需要独立 Review 时邀请其他协作者,管理员按分支保护规则完成合并。 diff --git a/README.md b/README.md index ca36c5a..cd62a4a 100644 --- a/README.md +++ b/README.md @@ -82,20 +82,38 @@ npm.cmd run dev ```powershell npm.cmd run check npm.cmd run build -npm.cmd run test:unit -npm.cmd run test:e2e +npm.cmd run test:fast +npm.cmd run test:ui ``` 需要本地数据库测试时,先启动 Docker Desktop: ```powershell -supabase start -npm.cmd run test:db -supabase db lint --local --schema public --level warning --fail-on error +npm run supabase:local:start +npm run test:db +``` + +`supabase:local:start` 会转到同级 `shadow-size/merchant-admin`,启动共享本地 Supabase,并按 Shadow Portal 控制面的 SHA-256 校验结果加载 Shadow Mate 的 `learning_*` 业务 schema。控制面历史快照只会应用到 `127.0.0.1:54322`,不会复制到生产迁移目录,也不会连接生产数据库。 + +如果要验收登录、找回密码或其他 Edge Function,再开一个终端运行: + +```powershell +npm run supabase:local:functions:serve +``` + +该命令会准备共享函数覆盖层并以前台方式运行本地函数服务;关闭该终端就会停止函数服务。 + +如果要对共享本地数据库执行 lint,请在 merchant-admin 目录运行: + +```powershell +cd ../shadow-size/merchant-admin +npx supabase db lint --local --schema public --level warning --fail-on error ``` `test:coverage` 覆盖核心纯函数、学习状态机和防重复操作锁,语句、分支、函数和行覆盖率门槛均为 80%。`test:e2e` 覆盖离线导航、打卡、积分、日历、家庭空间、重复点击保护、邮箱验证码/密码登录、找回密码、数据生命周期和云端冲突限次重试;真实 Supabase E2E 需要额外配置环境变量。 +日常开发按改动范围选择最小充分的检查:页面改动运行目标 UI 测试,数据库/认证/同步改动补充对应集成测试;合并或发布前运行 `npm.cmd run test:full`。`test:fast` 是静态检查加全部 unit test,不是 changed-only 测试。 + ## 工作方式 ```text @@ -124,7 +142,7 @@ src/learning-state.js 学习状态机与四个模块的打卡分组 src/cloud.js 验证码/密码登录、家庭空间、同步、导出与删除 src/action-lock.js 全局快速连点拦截与异步操作单次执行锁 src/icons.js Lucide 图标渲染与图标 hydration -supabase/migrations/ 家庭数据、RLS、生命周期和删除权限 +supabase/migrations/ Shadow Mate 的 schema 提案与隔离 CI 测试副本 supabase/functions/ 账号级服务端删除 tests/unit/ 纯函数与学习状态机测试 tests/e2e/ 离线、云端和数据生命周期测试 @@ -134,7 +152,15 @@ tests/e2e/ 离线、云端和数据生命周期测试 当前部署配置位于 `src/config.js`,浏览器端只使用 publishable key。真正的数据隔离由 Supabase RLS、家庭成员关系和产品 ID 共同完成;绝不能把 secret key 或 `service_role` key 放进仓库。 -数据库迁移位于 `supabase/migrations/`,包括: +### 共享 Supabase 与迁移边界 + +影伴接入共享 Supabase 后,日常本地验收必须通过 `npm run supabase:local:start`,由同级 `shadow-size/merchant-admin` 启动共享本地实例,并加载经 Shadow Portal 控制面校验的 Shadow Mate `learning_*` schema。需要验收 Auth 或 Edge Functions 时,再在第二个终端运行 `npm run supabase:local:functions:serve`。 + +仓库中的 `supabase/migrations/` 仍用于保存与代码同步的迁移提案和隔离 CI 测试副本,不是共享生产库的唯一发布目录。共享生产迁移的 canonical 文件、审批、发布和台账由 `shadow-portal/supabase/control-plane` 管理;不要在本仓库直接执行生产 `db push`、`migration repair` 或 linked SQL。 + +`supabase/config.toml` 的独立端口和迁移配置仅供 CI/隔离测试使用。不要在影伴仓库根目录直接运行裸 `supabase start` 来代替共享本地启动。 + +数据库迁移提案包括: - 项目登记和共享多租户兼容性 - 家庭、成员、学习者和学习状态表 diff --git a/package.json b/package.json index 5bb923d..bd961fe 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,13 @@ "verify": "npm run public:check && npm run security:check && npm run check && npm run build && node scripts/check-build.mjs && npm run test:coverage", "test": "vitest run", "test:unit": "vitest run tests/unit", + "test:fast": "npm run check && npm run test:unit", + "test:ui": "node scripts/run-e2e.mjs tests/e2e/offline.spec.js", + "test:full": "npm run verify && npm run test:db && npm run test:functions && npm run test:e2e && npm run release:check", "test:coverage": "vitest run --coverage", - "test:db": "supabase test db --local", + "supabase:local:start": "node scripts/start-shared-supabase.mjs", + "supabase:local:functions:serve": "node scripts/serve-shared-functions.mjs", + "test:db": "node scripts/test-shared-db.mjs", "test:functions": "node scripts/test-delete-account-guard.mjs", "test:e2e": "node scripts/run-e2e.mjs" }, diff --git a/scripts/serve-shared-functions.mjs b/scripts/serve-shared-functions.mjs new file mode 100644 index 0000000..1f8971d --- /dev/null +++ b/scripts/serve-shared-functions.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') + +if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + throw new Error( + `找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`, + ) +} + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +execFileSync(npmCommand, ['run', 'supabase:local:functions:serve'], { + cwd: merchantAdminRoot, + stdio: 'inherit', +}) diff --git a/scripts/start-shared-supabase.mjs b/scripts/start-shared-supabase.mjs new file mode 100644 index 0000000..8a5c968 --- /dev/null +++ b/scripts/start-shared-supabase.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') + +if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + throw new Error( + `找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`, + ) +} + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +execFileSync(npmCommand, ['run', 'supabase:local:start'], { + cwd: merchantAdminRoot, + stdio: 'inherit', +}) diff --git a/scripts/test-delete-account-guard.mjs b/scripts/test-delete-account-guard.mjs index d9a9622..1c00fb6 100644 --- a/scripts/test-delete-account-guard.mjs +++ b/scripts/test-delete-account-guard.mjs @@ -1,8 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function loadLocalEnv() { + const envPath = path.join(projectRoot, ".env.local"); + if (!fs.existsSync(envPath)) return; + + for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const separator = trimmed.indexOf("="); + if (separator < 1) continue; + + const key = trimmed.slice(0, separator).trim(); + const value = trimmed + .slice(separator + 1) + .trim() + .replace(/^(["'])(.*)\1$/, "$2"); + + if (!process.env[key]) process.env[key] = value; + } +} + +loadLocalEnv(); + const supabaseUrl = process.env.VITE_SUPABASE_URL; const publishableKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY; if (!supabaseUrl || !publishableKey) { - throw new Error("VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required"); + throw new Error( + "VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required; set them in the environment or .env.local", + ); } const email = `delete-guard-${Date.now()}@example.test`; diff --git a/scripts/test-shared-db.mjs b/scripts/test-shared-db.mjs new file mode 100644 index 0000000..0523ddd --- /dev/null +++ b/scripts/test-shared-db.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') +const testPath = path.join(shadowMateRoot, 'supabase', 'tests', 'learning_rls_test.sql') + +function validateLocalDatabaseUrl(databaseUrl, source) { + let parsedUrl + try { + parsedUrl = new URL(databaseUrl) + } catch { + parsedUrl = null + } + + if (!parsedUrl || parsedUrl.protocol !== 'postgresql:' || parsedUrl.hostname !== '127.0.0.1') { + throw new Error( + `${source} 不是 loopback 本地 PostgreSQL 地址;为避免测试误连生产库,已停止。`, + ) + } + + return databaseUrl +} + +function readDatabaseUrlFromSupabase(root, source) { + const status = execFileSync( + 'npx', + ['supabase', 'status', '-o', 'env'], + { + cwd: root, + env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const line = status.split(/\r?\n/).find((entry) => entry.startsWith('DB_URL=')) + const databaseUrl = line + ?.slice('DB_URL='.length) + .trim() + .replace(/^['"]|['"]$/g, '') + + if (!databaseUrl) { + throw new Error(`${source} 没有返回 DB_URL;请先启动本地 Supabase。`) + } + + return validateLocalDatabaseUrl(databaseUrl, source) +} + +function getDatabaseUrl() { + if (process.env.SHADOW_MATE_TEST_DB_URL) { + return validateLocalDatabaseUrl(process.env.SHADOW_MATE_TEST_DB_URL, 'SHADOW_MATE_TEST_DB_URL') + } + + if (fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + return readDatabaseUrlFromSupabase(merchantAdminRoot, 'merchant-admin 本地 Supabase') + } + + throw new Error( + `找不到 merchant-admin:${merchantAdminRoot}\n本地测试请运行 npm run supabase:local:start;CI 若使用隔离数据库,必须显式设置 SHADOW_MATE_TEST_DB_URL。`, + ) +} + +function buildTestDatabaseUrl(databaseUrl) { + const parsedUrl = new URL(databaseUrl) + parsedUrl.searchParams.set('sslmode', 'disable') + return parsedUrl.toString() +} + +export { buildTestDatabaseUrl } + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const databaseUrl = getDatabaseUrl() + const testDatabaseUrl = buildTestDatabaseUrl(databaseUrl) + + console.log('🧪 运行 Shadow Mate learning RLS 测试(目标:loopback 本地数据库)') + execFileSync( + 'npx', + [ + 'supabase', + 'test', + 'db', + '--db-url', + testDatabaseUrl, + testPath, + ], + { + cwd: shadowMateRoot, + env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' }, + stdio: 'inherit', + }, + ) +} diff --git a/src/cloud.js b/src/cloud.js index 3c28bbe..1af1b97 100644 --- a/src/cloud.js +++ b/src/cloud.js @@ -169,6 +169,11 @@ async function sendLoginOtp(email) { }); } +async function checkAuthEmail(email) { + if (!supabase) return { data: null, error: new Error("Cloud authentication is unavailable") }; + return supabase.functions.invoke("check-auth-email", { body: { email } }); +} + function passwordPromptStorageKey() { return session?.user?.id ? `${PASSWORD_PROMPT_KEY}:${session.user.id}` : PASSWORD_PROMPT_KEY; } @@ -298,7 +303,7 @@ function renderPasswordEditor({ mode = "setup" } = {}) { function renderPasswordRecoveryRequest(prefillEmail = "") { panel.innerHTML = `

${icon("cloud")} 找回密码

-

输入账号邮箱,我们会发送密码重设邮件。为了保护账号,无论邮箱是否存在,页面都会显示相同结果。

+

输入账号邮箱,我们会先确认账号是否存在,再发送密码重设邮件。

@@ -314,6 +319,15 @@ function renderPasswordRecoveryRequest(prefillEmail = "") { const submitButton = form.querySelector("[type=submit]"); await runLockedAction(submitButton, async () => { const email = String(new FormData(form).get("email") || "").trim(); + const { data: emailStatus, error: lookupError } = await checkAuthEmail(email); + if (lookupError || typeof emailStatus?.registered !== "boolean") { + showToast("暂时无法确认该邮箱,请稍后再试。", 6000); + return; + } + if (!emailStatus.registered) { + showToast("该邮箱尚未注册,请先注册账号或使用邮箱验证码登录。", 6000); + return; + } const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin, }); diff --git a/supabase/config.toml b/supabase/config.toml index 7fae991..666679f 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -249,17 +249,29 @@ otp_expiry = 3600 # content_path = "./supabase/templates/invite.html" [auth.email.template.confirmation] -subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}登录验证{{ end }} · 注册验证码' +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 注册验证码' content_path = "./supabase/templates/confirmation.html" [auth.email.template.magic_link] -subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}登录验证{{ end }} · 登录验证码' +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 登录验证码' content_path = "./supabase/templates/magic_link.html" [auth.email.template.recovery] -subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}Shadow Nexus{{ end }} · 重设密码' +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 重设密码' content_path = "./supabase/templates/recovery.html" +[auth.email.template.email_change] +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 邮箱变更' +content_path = "./supabase/templates/email_change.html" + +[auth.email.template.invite] +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 邀请' +content_path = "./supabase/templates/invite.html" + +[auth.email.template.reauthentication] +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 安全确认' +content_path = "./supabase/templates/reauthentication.html" + # Uncomment to customize notification email template # [auth.email.notification.password_changed] # enabled = true @@ -435,3 +447,9 @@ entrypoint = "./functions/delete-account/index.ts" # Specifies static files to be bundled with the function. Supports glob patterns. # For example, if you want to serve static HTML pages in your function: # static_files = [ "./functions/delete-account/*.html" ] + +[functions.check-auth-email] +enabled = true +verify_jwt = false +import_map = "./functions/check-auth-email/deno.json" +entrypoint = "./functions/check-auth-email/index.ts" diff --git a/supabase/functions/check-auth-email/deno.json b/supabase/functions/check-auth-email/deno.json new file mode 100644 index 0000000..8246ef8 --- /dev/null +++ b/supabase/functions/check-auth-email/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.111.0" + } +} diff --git a/supabase/functions/check-auth-email/index.ts b/supabase/functions/check-auth-email/index.ts new file mode 100644 index 0000000..12fa647 --- /dev/null +++ b/supabase/functions/check-auth-email/index.ts @@ -0,0 +1,101 @@ +import { createClient } from "@supabase/supabase-js"; + +const corsHeaders = { + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", +}; +const MAX_REQUESTS_PER_MINUTE = 10; +const MAX_EMAIL_LENGTH = 320; +const requestBuckets = new Map(); + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { status, headers: corsHeaders }); +} + +function readSecret(name: string, fallbackName: string) { + const raw = Deno.env.get(name) || Deno.env.get(fallbackName); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Record; + return parsed.default || Object.values(parsed)[0] || null; + } catch { + return raw; + } +} + +function requestKey(request: Request) { + return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || "unknown"; +} + +function isRateLimited(request: Request) { + const now = Date.now(); + const key = requestKey(request); + const current = requestBuckets.get(key); + if (!current || current.resetAt <= now) { + requestBuckets.set(key, { count: 1, resetAt: now + 60_000 }); + return false; + } + current.count += 1; + return current.count > MAX_REQUESTS_PER_MINUTE; +} + +function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +async function checkAuthEmail(request: Request) { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + if (request.method !== "POST") { + return jsonResponse({ code: "method_not_allowed" }, 405); + } + if (isRateLimited(request)) { + return jsonResponse({ code: "rate_limited" }, 429); + } + + let payload: { email?: unknown }; + try { + payload = await request.json(); + } catch { + return jsonResponse({ code: "invalid_request" }, 400); + } + + if (typeof payload.email !== "string" || payload.email.length > MAX_EMAIL_LENGTH) { + return jsonResponse({ code: "invalid_email" }, 400); + } + + const email = normalizeEmail(payload.email); + if (!/^\S+@\S+\.\S+$/.test(email)) { + return jsonResponse({ code: "invalid_email" }, 400); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const secretKey = readSecret("SUPABASE_SECRET_KEYS", "SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !secretKey) { + return jsonResponse({ code: "auth_lookup_unavailable" }, 503); + } + + const admin = createClient(supabaseUrl, secretKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + for (let page = 1; ; page += 1) { + const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 1000 }); + if (error) { + return jsonResponse({ code: "auth_lookup_unavailable" }, 503); + } + + const users = data.users || []; + const registered = users.some((user) => normalizeEmail(user.email || "") === email); + if (registered) return jsonResponse({ registered: true }); + if (users.length < 1000) return jsonResponse({ registered: false }); + } +} + +Deno.serve(checkAuthEmail); diff --git a/supabase/templates/confirmation.html b/supabase/templates/confirmation.html index fc6aa99..cfbf86b 100644 --- a/supabase/templates/confirmation.html +++ b/supabase/templates/confirmation.html @@ -1,22 +1,109 @@ - -
-
-

- {{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:3000") (eq .RedirectTo "http://localhost:3000/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if eq .Data.product_id "shadow-mate" }}影伴 Shadow Mate{{ else if .Data.product_name }}{{ .Data.product_name }}{{ else }}Shadow Nexus{{ end }} -

-

欢迎注册

-

请使用下面的验证码完成邮箱注册:

-
- {{ .Token }} -
-

验证码只能使用一次。如果邮件客户端支持按钮,也可以点击下方按钮完成验证。

-

- 完成注册 -

-

如果您没有请求注册,请忽略此邮件。

-
-
+ + + + + + + + + + + + + diff --git a/supabase/templates/email_change.html b/supabase/templates/email_change.html new file mode 100644 index 0000000..c68e447 --- /dev/null +++ b/supabase/templates/email_change.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/supabase/templates/invite.html b/supabase/templates/invite.html new file mode 100644 index 0000000..f690c21 --- /dev/null +++ b/supabase/templates/invite.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html index 98f8e7b..d085bcc 100644 --- a/supabase/templates/magic_link.html +++ b/supabase/templates/magic_link.html @@ -1,22 +1,77 @@ - -
-
-

- {{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:3000") (eq .RedirectTo "http://localhost:3000/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if eq .Data.product_id "shadow-mate" }}影伴 Shadow Mate{{ else if .Data.product_name }}{{ .Data.product_name }}{{ else }}Shadow Nexus{{ end }} -

-

邮箱登录验证

-

请使用下面的验证码登录:

-
- {{ .Token }} -
-

验证码只能使用一次。如果邮件客户端支持按钮,也可以点击下方按钮完成登录。

-

- 点击登录 -

-

如果您没有请求登录,请忽略此邮件。

-
-
+ + + + + + + + + + + + + diff --git a/supabase/templates/reauthentication.html b/supabase/templates/reauthentication.html new file mode 100644 index 0000000..37f2605 --- /dev/null +++ b/supabase/templates/reauthentication.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html index c897f58..aa9cceb 100644 --- a/supabase/templates/recovery.html +++ b/supabase/templates/recovery.html @@ -1,19 +1,70 @@ - -
-
-

- {{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:3000") (eq .RedirectTo "http://localhost:3000/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else }}Shadow Nexus{{ end }} -

-

重设共享账号密码

-

我们收到了重设密码的请求。点击下面的按钮返回发起请求的产品并设置新密码。

-

- 重设密码 -

-

此密码适用于使用同一 Supabase Auth 账号的 Shadow 系列产品。链接仅能使用一次,并将在短时间后失效。

-

如果您没有请求重设密码,请忽略此邮件。

-
-
+ + + + + + + + + + + + + diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index bb0952d..4969731 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -687,6 +687,13 @@ test.describe("Shared password authentication", () => { localStorage.clear(); sessionStorage.clear(); }); + await page.route("**/functions/v1/check-auth-email", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ registered: true }), + }); + }); await page.route("**/auth/v1/recover**", async (route) => { await route.fulfill({ status: 503, @@ -805,11 +812,18 @@ test.describe("Shared password authentication", () => { await expect(page.locator('#accountButton[data-state="online"]')).toBeVisible(); }); - test("requests a branded recovery email without revealing account existence", async ({ page }) => { - let recoveryRequestUrl = ""; + test("blocks password recovery for an unregistered email", async ({ page }) => { + let recoveryRequestCount = 0; await page.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); }); + await page.route("**/functions/v1/check-auth-email", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ registered: false }), + }); + }); await page.route("**/auth/v1/recover**", async (route) => { - recoveryRequestUrl = route.request().url(); + recoveryRequestCount += 1; await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); }); @@ -820,7 +834,7 @@ test.describe("Shared password authentication", () => { await page.click("[data-forgot-password]"); await page.click('#passwordRecoveryForm button[type="submit"]'); - await expect.poll(() => recoveryRequestUrl).toContain("redirect_to=http%3A%2F%2F127.0.0.1"); - await expect(page.locator("#cloudPanel")).toContainText("如果该邮箱已注册,密码重设邮件已经发送"); + await expect(page.locator("#syncToast")).toContainText("该邮箱尚未注册"); + expect(recoveryRequestCount).toBe(0); }); }); diff --git a/tests/unit/email-templates.test.js b/tests/unit/email-templates.test.js index b0ffb9b..64cb9db 100644 --- a/tests/unit/email-templates.test.js +++ b/tests/unit/email-templates.test.js @@ -3,8 +3,12 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const recovery = readFileSync(resolve(process.cwd(), "supabase/templates/recovery.html"), "utf8"); +const confirmation = readFileSync(resolve(process.cwd(), "supabase/templates/confirmation.html"), "utf8"); +const magicLink = readFileSync(resolve(process.cwd(), "supabase/templates/magic_link.html"), "utf8"); const config = readFileSync(resolve(process.cwd(), "supabase/config.toml"), "utf8"); +const templates = [confirmation, magicLink, recovery]; + describe("multi-product password recovery email", () => { it("maps every supported production product from RedirectTo", () => { expect(recovery).toContain("https://sm.shadow.wang"); @@ -26,4 +30,66 @@ describe("multi-product password recovery email", () => { expect(config).toContain("[auth.email.template.recovery]"); expect(config).toContain('content_path = "./supabase/templates/recovery.html"'); }); + + it("uses the Editorial Utility shell for every Supabase Auth email", () => { + for (const template of templates) { + expect(template).toContain('meta name="color-scheme" content="light dark"'); + expect(template).toContain("prefers-color-scheme: dark"); + expect(template).toContain("[data-ogsc]"); + expect(template).toContain("Shadow Nexus"); + expect(template).toContain("https://shadow.wang/zh"); + expect(template).toContain("shadow_mate.svg"); + expect(template).toContain("shadow_card_logo.png"); + expect(template).toContain("shadow_size.png"); + expect(template).toContain("shadow_portal_logo.png"); + expect(template).toContain("https://shadow.wang/zh/products/shadow-mate"); + expect(template).toContain("https://shadow.wang/zh/products/shadow-card"); + expect(template).toContain("https://shadow.wang/zh/products/shadow-size"); + expect(template).not.toContain("🔐"); + } + }); + + it("preserves Supabase's auth variables for each email flow", () => { + expect(confirmation).toContain("{{ .Token }}"); + expect(confirmation).toContain("{{ .TokenHash }}"); + expect(magicLink).toContain("{{ .Token }}"); + expect(magicLink).toContain("{{ .TokenHash }}"); + expect(recovery).toContain("{{ .ConfirmationURL }}"); + }); + + it("registers all three local email templates", () => { + expect(config).toContain("[auth.email.template.confirmation]"); + expect(config).toContain('content_path = "./supabase/templates/confirmation.html"'); + expect(config).toContain("[auth.email.template.magic_link]"); + expect(config).toContain('content_path = "./supabase/templates/magic_link.html"'); + }); + + it("uses the project name in every Auth email subject, including local development", () => { + const subjects = config + .split("\n") + .filter((line) => line.startsWith("subject =")); + + expect(subjects).toHaveLength(3); + + for (const subject of subjects) { + expect(subject).toContain("http://127.0.0.1:5173"); + expect(subject).toContain("http://localhost:5173"); + expect(subject).toContain("影伴 Shadow Mate"); + expect(subject).toContain("影匣 Shadow Card"); + expect(subject).toContain("影裁 Shadow Size"); + expect(subject).toContain("Quick flomo"); + expect(subject).toContain("Shadow Nexus"); + } + }); + + it("keeps footer links on the same environment as the Auth redirect", () => { + for (const template of templates) { + expect(template).toContain('href="http://localhost:3000/zh/products/shadow-mate"'); + expect(template).toContain('href="http://localhost:3000/zh"'); + expect(template).toContain('href="https://shadow-portal.vercel.app/zh/products/shadow-mate"'); + expect(template).toContain('href="https://shadow-portal.vercel.app/zh"'); + expect(template).toContain('href="https://shadow.wang/zh/products/shadow-mate"'); + expect(template).toContain('href="https://shadow.wang/zh"'); + } + }); }); diff --git a/tests/unit/shared-db-test-script.test.js b/tests/unit/shared-db-test-script.test.js new file mode 100644 index 0000000..869460a --- /dev/null +++ b/tests/unit/shared-db-test-script.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildTestDatabaseUrl } from "../../scripts/test-shared-db.mjs"; + +const localDatabaseUrl = [ + "postgresql://", + "postgres", + ":", + "postgres", + "@127.0.0.1:54322/postgres", +].join(""); + +describe("shared database pgTAP connection", () => { + it("keeps the raw local URL and disables SSL for Supabase CLI", () => { + expect(buildTestDatabaseUrl(localDatabaseUrl)).toBe( + `${localDatabaseUrl}?sslmode=disable`, + ); + }); + + it("overrides an existing SSL mode without encoding the whole URL", () => { + const localDatabaseUrlWithOptions = `${localDatabaseUrl}?sslmode=require&connect_timeout=10`; + + expect( + buildTestDatabaseUrl(localDatabaseUrlWithOptions), + ).toBe(`${localDatabaseUrl}?sslmode=disable&connect_timeout=10`); + }); +});