forked from wangziqi/gongxue-base
test: add remote auth jwt smoke
This commit is contained in:
10
.env.example
10
.env.example
@@ -34,6 +34,16 @@ AUTH_CODE_TTL_SECONDS=300
|
|||||||
AUTH_SMS_COOLDOWN_SECONDS=60
|
AUTH_SMS_COOLDOWN_SECONDS=60
|
||||||
AUTH_SESSION_TTL_SECONDS=604800
|
AUTH_SESSION_TTL_SECONDS=604800
|
||||||
|
|
||||||
|
# 远程 Auth/JWKS 验收脚本配置。只在预生产/生产验收命令行临时设置真实 access token,
|
||||||
|
# 不要把真实 token 写入仓库、前端 runtime-config 或长期 .env。
|
||||||
|
# AUTH_SMOKE_API_BASE_URL=https://api.example.com
|
||||||
|
# AUTH_SMOKE_TENANT_ID=<tenant-uuid>
|
||||||
|
# AUTH_SMOKE_STUDENT_ACCESS_TOKEN=<real-supabase-access-token>
|
||||||
|
# AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN=<tenant-admin-supabase-access-token>
|
||||||
|
# AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN=<platform-admin-supabase-access-token>
|
||||||
|
# AUTH_SMOKE_WRONG_TENANT_ID=<another-tenant-uuid>
|
||||||
|
# AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true
|
||||||
|
|
||||||
# 安全目标默认关闭迁移期身份头。仅本地兼容旧测试/旧前端时可临时设为 true。
|
# 安全目标默认关闭迁移期身份头。仅本地兼容旧测试/旧前端时可临时设为 true。
|
||||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -249,6 +249,8 @@ npm run check:worker
|
|||||||
npm run check:importer
|
npm run check:importer
|
||||||
npm run check:taro
|
npm run check:taro
|
||||||
npm run test:readiness
|
npm run test:readiness
|
||||||
|
npm run test:auth:remote-smoke
|
||||||
|
npm run smoke:auth:remote
|
||||||
npm run audit:runtime
|
npm run audit:runtime
|
||||||
npm run pb:import:dry-run
|
npm run pb:import:dry-run
|
||||||
npm run pb:import:validate
|
npm run pb:import:validate
|
||||||
@@ -279,6 +281,21 @@ npm run readiness:production:db
|
|||||||
|
|
||||||
这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、域名是否未验证。
|
这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、域名是否未验证。
|
||||||
|
|
||||||
|
Supabase Auth/JWKS 上云后需要用真实 access token 跑远程验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_SMOKE_API_BASE_URL=https://api.example.com \
|
||||||
|
AUTH_SMOKE_TENANT_ID=<tenant-uuid> \
|
||||||
|
AUTH_SMOKE_STUDENT_ACCESS_TOKEN=<student-supabase-access-token> \
|
||||||
|
AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN=<tenant-admin-supabase-access-token> \
|
||||||
|
AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN=<platform-admin-supabase-access-token> \
|
||||||
|
AUTH_SMOKE_WRONG_TENANT_ID=<another-tenant-uuid> \
|
||||||
|
AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true \
|
||||||
|
npm run smoke:auth:remote
|
||||||
|
```
|
||||||
|
|
||||||
|
这个命令会验证真实 Supabase JWT 能访问 `/api/auth/me`、`/api/profile/me`,学生不能访问租户后台/平台后台,租户管理员不能访问平台后台,平台管理员能访问平台后台,坏 token 和错租户上下文会被拒绝。真实 access token 只允许在验收命令行临时提供,不要写入仓库、前端 `runtime-config.json` 或长期 `.env`。
|
||||||
|
|
||||||
RLS 需要同时跑动态隔离验收:
|
RLS 需要同时跑动态隔离验收:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -376,6 +393,7 @@ npm run check:worker
|
|||||||
npm run test:worker:commerce
|
npm run test:worker:commerce
|
||||||
npm run test:worker:assets
|
npm run test:worker:assets
|
||||||
npm run test:worker:exports
|
npm run test:worker:exports
|
||||||
|
npm run test:auth:remote-smoke
|
||||||
npm run test:rls
|
npm run test:rls
|
||||||
npm run test:api
|
npm run test:api
|
||||||
npm run check:refactor
|
npm run check:refactor
|
||||||
@@ -383,7 +401,7 @@ npm run audit:runtime
|
|||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
结果:通过。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
结果:通过。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||||
|
|
||||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收,覆盖主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
||||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
### P0:上云测试和前端主链路前必须处理
|
### P0:上云测试和前端主链路前必须处理
|
||||||
|
|
||||||
- 生产鉴权:API 已支持 Supabase Auth JWT、HS256 本地测试和 JWKS/RS256 集成回归;`npm run test:rls` 已补本地运行时 RLS 深测,继续做真实云端 Auth/JWKS 回归和生产数据库 RLS 验收,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。生产配置 JWKS 时必须同时配置 `AUTH_JWT_ISSUER`。
|
- 生产鉴权:API 已支持 Supabase Auth JWT、HS256 本地测试和 JWKS/RS256 集成回归;`npm run test:rls` 已补本地运行时 RLS 深测;`npm run smoke:auth:remote` 已补真实云端 Auth/JWKS smoke 命令。继续在预生产/生产执行真实 token 回归和生产数据库 RLS 验收,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。生产配置 JWKS 时必须同时配置 `AUTH_JWT_ISSUER`。
|
||||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
||||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||||
@@ -106,6 +106,7 @@
|
|||||||
|
|
||||||
- `npm run check:refactor` 在本地通过。
|
- `npm run check:refactor` 在本地通过。
|
||||||
- `npm run test:rls` 在本地通过,且新增租户表的 RLS 动态用例已补齐或有明确豁免说明。
|
- `npm run test:rls` 在本地通过,且新增租户表的 RLS 动态用例已补齐或有明确豁免说明。
|
||||||
|
- `npm run smoke:auth:remote` 在预生产/生产 API 上通过,真实 Supabase JWT 能映射学生、租户管理员和平台管理员,坏 token/错租户上下文被拒绝。
|
||||||
- 使用真实 PocketBase 导出数据完成一次 dry-run,产出问题清单和修复后的二次导入报告。
|
- 使用真实 PocketBase 导出数据完成一次 dry-run,产出问题清单和修复后的二次导入报告。
|
||||||
- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。
|
- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。
|
||||||
- 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。
|
- 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
## 不能误认为已商用完成的部分
|
## 不能误认为已商用完成的部分
|
||||||
|
|
||||||
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。
|
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;后端已提供 `npm run smoke:auth:remote` 用真实 Supabase access token 验收云端 Auth/JWKS 映射,已提供 `npm run test:rls` 做本地动态 RLS 深测;前端不要继续使用 `x-user-id`,真实 access token 也不要写入 `runtime-config.json`、页面代码或仓库。
|
||||||
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
|
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
|
||||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
|
|||||||
AUTH_JWT_ISSUER=https://<project-ref>.supabase.co/auth/v1
|
AUTH_JWT_ISSUER=https://<project-ref>.supabase.co/auth/v1
|
||||||
AUTH_JWT_AUDIENCE=authenticated
|
AUTH_JWT_AUDIENCE=authenticated
|
||||||
```
|
```
|
||||||
- `npm run test:api` 已覆盖本地 HS256 JWT 和本地 JWKS/RS256 验签路径;真实云端仍需用线上 Supabase access token 调 `GET /api/auth/me`、`GET /api/profile/me`、租户后台和平台后台各一条接口。
|
- `npm run test:api` 已覆盖本地 HS256 JWT 和本地 JWKS/RS256 验签路径;`npm run smoke:auth:remote` 用于真实云端 access token 回归 `GET /api/auth/me`、`GET /api/profile/me`、租户后台、平台后台、坏 token 和错租户上下文。
|
||||||
|
- 远程 Auth smoke 的真实 access token 只能在验收命令行临时注入,不能写入仓库、前端 `runtime-config.json`、CI 日志或长期 `.env`。
|
||||||
- 前端禁止通过 query/body/header 指定 userId。
|
- 前端禁止通过 query/body/header 指定 userId。
|
||||||
- `GET /api/auth/me` 已支持 Supabase JWT;后续要补租户成员、角色、权限返回。
|
- `GET /api/auth/me` 已支持 Supabase JWT;后续要补租户成员、角色、权限返回。
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ provider event id 幂等
|
|||||||
|
|
||||||
- `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。
|
- `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。
|
||||||
- `npm run check:refactor` 通过。
|
- `npm run check:refactor` 通过。
|
||||||
|
- `npm run smoke:auth:remote` 在预生产/生产 API 上通过,并使用真实 Supabase Auth access token 覆盖学生、租户管理员、平台管理员、坏 token 和错租户上下文。
|
||||||
- `npm run test:rls` 通过;必须确认主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝都有运行时证据。
|
- `npm run test:rls` 通过;必须确认主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝都有运行时证据。
|
||||||
- `npm run readiness:production` 没有 blocker。
|
- `npm run readiness:production` 没有 blocker。
|
||||||
- `npm run readiness:production:db` 没有 blocker,尤其是 `db.rls.tenant_tables_enabled`、`db.rls.tenant_tables_policy`、`db.rls.public_tenant_context` 必须通过。
|
- `npm run readiness:production:db` 没有 blocker,尤其是 `db.rls.tenant_tables_enabled`、`db.rls.tenant_tables_policy`、`db.rls.public_tenant_context` 必须通过。
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
1. 生产鉴权
|
1. 生产鉴权
|
||||||
- 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口;JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。
|
- 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口;JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。
|
||||||
- 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。
|
- 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。
|
||||||
- 已补自定义角色模板、菜单/模块/字段级配置 API、班级/学生范围权限;已新增 `npm run test:rls` 本地动态 RLS 深测,覆盖主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝;继续补真实云端 Auth/JWKS 回归和生产数据库 RLS 验收。
|
- 已补自定义角色模板、菜单/模块/字段级配置 API、班级/学生范围权限;已新增 `npm run test:rls` 本地动态 RLS 深测,覆盖主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝;已新增 `npm run smoke:auth:remote` 用真实 Supabase access token 验收云端 Auth/JWKS 映射;继续在预生产/生产执行并留档。
|
||||||
- 前端联调时禁止继续使用 `x-user-id`;`x-tenant-id` 只作为租户上下文,不能作为身份依据。
|
- 前端联调时禁止继续使用 `x-user-id`;`x-tenant-id` 只作为租户上下文,不能作为身份依据。
|
||||||
|
|
||||||
2. 对象存储
|
2. 对象存储
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
|
|
||||||
4. 部署配置
|
4. 部署配置
|
||||||
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
|
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
|
||||||
|
- Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。
|
||||||
- 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。
|
- 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。
|
||||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"supabase:reset": "supabase db reset",
|
"supabase:reset": "supabase db reset",
|
||||||
"db:smoke-seed": "node scripts/smoke-seed.js",
|
"db:smoke-seed": "node scripts/smoke-seed.js",
|
||||||
"smoke:core-api": "node scripts/smoke-core-api.js",
|
"smoke:core-api": "node scripts/smoke-core-api.js",
|
||||||
|
"smoke:auth:remote": "node scripts/remote-auth-jwt-smoke.js",
|
||||||
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
||||||
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
|
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
|
||||||
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
||||||
@@ -43,7 +44,8 @@
|
|||||||
"test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js",
|
"test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js",
|
||||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js",
|
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/remote-auth-jwt-smoke-test.js",
|
||||||
|
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
||||||
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
|
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
|
||||||
"readiness:production": "node scripts/production-readiness-check.js --skip-db",
|
"readiness:production": "node scripts/production-readiness-check.js --skip-db",
|
||||||
"readiness:production:db": "node scripts/production-readiness-check.js --check-db",
|
"readiness:production:db": "node scripts/production-readiness-check.js --check-db",
|
||||||
|
|||||||
119
scripts/remote-auth-jwt-smoke-test.js
Normal file
119
scripts/remote-auth-jwt-smoke-test.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { runRemoteAuthSmoke } from './remote-auth-jwt-smoke.js';
|
||||||
|
|
||||||
|
const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const wrongTenantId = '00000000-0000-0000-0000-000000000901';
|
||||||
|
|
||||||
|
function json(res, status, payload) {
|
||||||
|
res.writeHead(status, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearer(req) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
return header.replace(/^Bearer\s+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWrongTenant(req) {
|
||||||
|
return req.headers['x-tenant-id'] === wrongTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const token = bearer(req);
|
||||||
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
|
||||||
|
if (token === 'invalid.jwt.token') {
|
||||||
|
json(res, 401, { code: 'AUTH_SESSION_INVALID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWrongTenant(req) && url.pathname === '/api/profile/me') {
|
||||||
|
json(res, 401, { code: 'AUTH_SESSION_INVALID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === 'student-token') {
|
||||||
|
if (url.pathname === '/api/auth/me') {
|
||||||
|
json(res, 200, { user: { id: 'student-user' }, session: { source: 'supabase_jwt' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/profile/me') {
|
||||||
|
json(res, 200, { item: { userId: 'student-user' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/tenant-admin/overview') {
|
||||||
|
json(res, 403, { code: 'TENANT_ADMIN_REQUIRED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/platform-admin/overview') {
|
||||||
|
json(res, 403, { code: 'PLATFORM_ADMIN_REQUIRED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === 'tenant-token') {
|
||||||
|
if (url.pathname === '/api/auth/me') {
|
||||||
|
json(res, 200, { user: { id: 'tenant-user' }, session: { source: 'supabase_jwt' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/tenant-admin/overview') {
|
||||||
|
json(res, 200, { item: { id: tenantId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/platform-admin/overview') {
|
||||||
|
json(res, 403, { code: 'PLATFORM_ADMIN_REQUIRED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === 'platform-token') {
|
||||||
|
if (url.pathname === '/api/platform-admin/overview') {
|
||||||
|
json(res, 200, { item: { tenants: { total: 2 } } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/auth/me') {
|
||||||
|
json(res, 200, { user: { id: 'platform-user' }, session: { source: 'supabase_jwt' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 404, { code: 'NOT_FOUND', path: url.pathname });
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const results = await runRemoteAuthSmoke(
|
||||||
|
{
|
||||||
|
apiBaseUrl: `http://127.0.0.1:${address.port}`,
|
||||||
|
tenantId,
|
||||||
|
wrongTenantId,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
requireAdminTokens: true,
|
||||||
|
student: {
|
||||||
|
token: 'student-token',
|
||||||
|
expectedUserId: 'student-user',
|
||||||
|
},
|
||||||
|
tenantAdmin: {
|
||||||
|
token: 'tenant-token',
|
||||||
|
expectedUserId: 'tenant-user',
|
||||||
|
},
|
||||||
|
platformAdmin: {
|
||||||
|
token: 'platform-token',
|
||||||
|
expectedUserId: 'platform-user',
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ quiet: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(results.some(item => item.name === 'student.auth_me.session_source' && item.status === 'pass'), true);
|
||||||
|
assert.equal(results.some(item => item.name === 'student.tenant_admin_denied' && item.status === 'pass'), true);
|
||||||
|
assert.equal(results.some(item => item.name === 'platform_admin.overview' && item.status === 'pass'), true);
|
||||||
|
assert.equal(results.filter(item => item.status === 'fail').length, 0);
|
||||||
|
console.log('[PASS] remote Auth/JWKS smoke script');
|
||||||
|
} finally {
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
}
|
||||||
286
scripts/remote-auth-jwt-smoke.js
Normal file
286
scripts/remote-auth-jwt-smoke.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
function envString(env, key, fallback = '') {
|
||||||
|
return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envBoolean(env, key, fallback = false) {
|
||||||
|
const value = envString(env, key).toLowerCase();
|
||||||
|
if (!value) return fallback;
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function envNumber(env, key, fallback) {
|
||||||
|
const value = Number(envString(env, key));
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
return value.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactToken(value) {
|
||||||
|
if (!value) return '<empty>';
|
||||||
|
if (value.length <= 16) return '<redacted>';
|
||||||
|
return `${value.slice(0, 6)}...${value.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(env = process.env) {
|
||||||
|
const apiBaseUrl = envString(env, 'AUTH_SMOKE_API_BASE_URL', envString(env, 'API_BASE', ''));
|
||||||
|
const tenantId = envString(env, 'AUTH_SMOKE_TENANT_ID', envString(env, 'TENANT_ID', ''));
|
||||||
|
const studentToken = envString(env, 'AUTH_SMOKE_STUDENT_ACCESS_TOKEN', envString(env, 'SUPABASE_ACCESS_TOKEN', ''));
|
||||||
|
const tenantAdminToken = envString(env, 'AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN');
|
||||||
|
const platformAdminToken = envString(env, 'AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN');
|
||||||
|
const requireAdminTokens = envBoolean(env, 'AUTH_SMOKE_REQUIRE_ADMIN_TOKENS', false);
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
if (!apiBaseUrl) missing.push('AUTH_SMOKE_API_BASE_URL');
|
||||||
|
if (!tenantId) missing.push('AUTH_SMOKE_TENANT_ID');
|
||||||
|
if (!studentToken) missing.push('AUTH_SMOKE_STUDENT_ACCESS_TOKEN');
|
||||||
|
if (requireAdminTokens && !tenantAdminToken) missing.push('AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN');
|
||||||
|
if (requireAdminTokens && !platformAdminToken) missing.push('AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN');
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing required remote auth smoke env: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiBaseUrl: normalizeBaseUrl(apiBaseUrl),
|
||||||
|
tenantId,
|
||||||
|
wrongTenantId: envString(env, 'AUTH_SMOKE_WRONG_TENANT_ID'),
|
||||||
|
timeoutMs: envNumber(env, 'AUTH_SMOKE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
|
||||||
|
requireAdminTokens,
|
||||||
|
student: {
|
||||||
|
token: studentToken,
|
||||||
|
expectedUserId: envString(env, 'AUTH_SMOKE_EXPECTED_STUDENT_USER_ID'),
|
||||||
|
},
|
||||||
|
tenantAdmin: tenantAdminToken
|
||||||
|
? {
|
||||||
|
token: tenantAdminToken,
|
||||||
|
expectedUserId: envString(env, 'AUTH_SMOKE_EXPECTED_TENANT_ADMIN_USER_ID'),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
platformAdmin: platformAdminToken
|
||||||
|
? {
|
||||||
|
token: platformAdminToken,
|
||||||
|
expectedUserId: envString(env, 'AUTH_SMOKE_EXPECTED_PLATFORM_ADMIN_USER_ID'),
|
||||||
|
tenantId: envString(env, 'AUTH_SMOKE_PLATFORM_TENANT_ID', tenantId),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(token, tenantId) {
|
||||||
|
return {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
...(tenantId ? { 'x-tenant-id': tenantId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(config, path, { token, tenantId = config.tenantId, method = 'GET' } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||||
|
const url = new URL(path, config.apiBaseUrl);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
...(token ? authHeaders(token, tenantId) : {}),
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = {};
|
||||||
|
if (text.trim()) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = { raw: text.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: response.status, ok: response.ok, payload };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultCollector({ quiet = false } = {}) {
|
||||||
|
const results = [];
|
||||||
|
function pass(name, detail = {}) {
|
||||||
|
results.push({ status: 'pass', name, detail });
|
||||||
|
if (!quiet) console.log(`PASS ${name}`);
|
||||||
|
}
|
||||||
|
function fail(name, message, detail = {}) {
|
||||||
|
results.push({ status: 'fail', name, message, detail });
|
||||||
|
if (!quiet) {
|
||||||
|
console.error(`FAIL ${name}: ${message}`);
|
||||||
|
if (Object.keys(detail).length > 0) console.error(JSON.stringify(detail, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { results, pass, fail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectStatus(collector, name, response, expectedStatuses) {
|
||||||
|
const expected = Array.isArray(expectedStatuses) ? expectedStatuses : [expectedStatuses];
|
||||||
|
if (expected.includes(response.status)) {
|
||||||
|
collector.pass(name, { status: response.status });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
collector.fail(name, `Expected HTTP ${expected.join('/')} but got ${response.status}`, { payload: response.payload });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSupabaseJwtSession(collector, name, response, expectedUserId = '') {
|
||||||
|
if (!expectStatus(collector, `${name}.status`, response, 200)) return;
|
||||||
|
const source = response.payload?.session?.source;
|
||||||
|
if (source === 'supabase_jwt') collector.pass(`${name}.session_source`);
|
||||||
|
else collector.fail(`${name}.session_source`, 'Expected session.source=supabase_jwt', { source });
|
||||||
|
|
||||||
|
if (expectedUserId) {
|
||||||
|
const actualUserId = response.payload?.user?.id || response.payload?.item?.userId;
|
||||||
|
if (actualUserId === expectedUserId) collector.pass(`${name}.expected_user`);
|
||||||
|
else collector.fail(`${name}.expected_user`, 'Authenticated user does not match expected id', { actualUserId, expectedUserId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRemoteAuthSmoke(inputConfig, options = {}) {
|
||||||
|
const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env);
|
||||||
|
const collector = resultCollector({ quiet: options.quiet });
|
||||||
|
|
||||||
|
if (!options.quiet) {
|
||||||
|
console.log(`Remote auth smoke target: ${config.apiBaseUrl}`);
|
||||||
|
console.log(`Student token: ${redactToken(config.student.token)}`);
|
||||||
|
if (config.tenantAdmin) console.log(`Tenant admin token: ${redactToken(config.tenantAdmin.token)}`);
|
||||||
|
if (config.platformAdmin) console.log(`Platform admin token: ${redactToken(config.platformAdmin.token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentMe = await requestJson(config, '/api/auth/me', {
|
||||||
|
token: config.student.token,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
});
|
||||||
|
expectSupabaseJwtSession(collector, 'student.auth_me', studentMe, config.student.expectedUserId);
|
||||||
|
|
||||||
|
const studentProfile = await requestJson(config, '/api/profile/me', {
|
||||||
|
token: config.student.token,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'student.profile_me', studentProfile, 200);
|
||||||
|
if (config.student.expectedUserId && studentProfile.payload?.item?.userId !== config.student.expectedUserId) {
|
||||||
|
collector.fail('student.profile_expected_user', 'Profile user does not match expected student id', {
|
||||||
|
actualUserId: studentProfile.payload?.item?.userId,
|
||||||
|
expectedUserId: config.student.expectedUserId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
collector.pass('student.profile_expected_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentTenantAdmin = await requestJson(config, '/api/tenant-admin/overview', {
|
||||||
|
token: config.student.token,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'student.tenant_admin_denied', studentTenantAdmin, 403);
|
||||||
|
|
||||||
|
const studentPlatformAdmin = await requestJson(config, '/api/platform-admin/overview', {
|
||||||
|
token: config.student.token,
|
||||||
|
tenantId: '',
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'student.platform_admin_denied', studentPlatformAdmin, 403);
|
||||||
|
|
||||||
|
const badTokenProfile = await requestJson(config, '/api/profile/me', {
|
||||||
|
token: 'invalid.jwt.token',
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'invalid_token.profile_denied', badTokenProfile, 401);
|
||||||
|
|
||||||
|
if (config.wrongTenantId) {
|
||||||
|
const wrongTenantProfile = await requestJson(config, '/api/profile/me', {
|
||||||
|
token: config.student.token,
|
||||||
|
tenantId: config.wrongTenantId,
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'student.wrong_tenant_denied', wrongTenantProfile, [401, 403, 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.tenantAdmin) {
|
||||||
|
const tenantAdminMe = await requestJson(config, '/api/auth/me', {
|
||||||
|
token: config.tenantAdmin.token,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
});
|
||||||
|
expectSupabaseJwtSession(collector, 'tenant_admin.auth_me', tenantAdminMe, config.tenantAdmin.expectedUserId);
|
||||||
|
|
||||||
|
const tenantOverview = await requestJson(config, '/api/tenant-admin/overview', {
|
||||||
|
token: config.tenantAdmin.token,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'tenant_admin.overview', tenantOverview, 200);
|
||||||
|
|
||||||
|
const tenantPlatformAdmin = await requestJson(config, '/api/platform-admin/overview', {
|
||||||
|
token: config.tenantAdmin.token,
|
||||||
|
tenantId: '',
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'tenant_admin.platform_admin_denied', tenantPlatformAdmin, 403);
|
||||||
|
} else {
|
||||||
|
collector.pass('tenant_admin.optional_skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.platformAdmin) {
|
||||||
|
const platformOverview = await requestJson(config, '/api/platform-admin/overview', {
|
||||||
|
token: config.platformAdmin.token,
|
||||||
|
tenantId: '',
|
||||||
|
});
|
||||||
|
expectStatus(collector, 'platform_admin.overview', platformOverview, 200);
|
||||||
|
|
||||||
|
if (config.platformAdmin.tenantId) {
|
||||||
|
const platformMe = await requestJson(config, '/api/auth/me', {
|
||||||
|
token: config.platformAdmin.token,
|
||||||
|
tenantId: config.platformAdmin.tenantId,
|
||||||
|
});
|
||||||
|
expectSupabaseJwtSession(collector, 'platform_admin.auth_me', platformMe, config.platformAdmin.expectedUserId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collector.pass('platform_admin.optional_skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = collector.results.filter(item => item.status === 'fail');
|
||||||
|
if (!options.quiet) {
|
||||||
|
console.log(`\nRemote Auth/JWKS smoke checks: ${collector.results.length - failed.length} passed, ${failed.length} failed.`);
|
||||||
|
}
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const error = new Error(`Remote Auth/JWKS smoke failed with ${failed.length} failing checks`);
|
||||||
|
error.results = collector.results;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return collector.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await runRemoteAuthSmoke();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
if (/Missing required remote auth smoke env/.test(error.message)) {
|
||||||
|
console.error(`
|
||||||
|
Required example:
|
||||||
|
AUTH_SMOKE_API_BASE_URL=https://api.example.com
|
||||||
|
AUTH_SMOKE_TENANT_ID=<tenant-uuid>
|
||||||
|
AUTH_SMOKE_STUDENT_ACCESS_TOKEN=<real-supabase-access-token>
|
||||||
|
|
||||||
|
Recommended full pre-production example:
|
||||||
|
AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN=<tenant-admin-supabase-access-token>
|
||||||
|
AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN=<platform-admin-supabase-access-token>
|
||||||
|
AUTH_SMOKE_WRONG_TENANT_ID=<another-tenant-uuid>
|
||||||
|
AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildConfig, runRemoteAuthSmoke };
|
||||||
Reference in New Issue
Block a user