feat(task-scheduler): Phase 1 — modularize server + add OAuth/JWKS + /api/v1/* routes

Refactor server.js (647 → 99 lines) into 30+ modules under src/:
- auth/: JWKS validation, JWT middleware, OAuth client_credentials
- routes/v1/: jobs (POST/GET/:id) + promote with input validation
- routes/legacy.js: existing /jobs multipart path (backward compatible)
- services/: jobService, healthService, sseService, statusMapper,
  doneListener
- middleware/: requestId, errorHandler, perClientRateLimit,
  uploadConcurrency, upload (multer + storage)
- redis/: Lua scripts for atomic claim/release_active_job
- storage/: local + minio adapters; fileAccessAgent/: PUT promote client
- config.js: env var validation with fail-fast

Phase 1 features (T1–T11):
- T1 Auth middleware + JWKS (Member Center OAuth2 resource server)
- T2 OAuth client (Member Center client_credentials, Basic auth)
- T3 /api/v1/* router skeleton
- T4 server.js refactor (legacy endpoints fully preserved, real-Redis
  regression verified — existing worker consumer group untouched)
- T5 POST /api/v1/jobs (multipart, OWASP-audited, 2 Critical / 6 Major
  fixed; Risk-A/B documented as accepted)
- T6 GET /api/v1/jobs + GET /:id (cursor pagination, ETag, IDOR-safe)
- T7 POST /jobs/:id/promote (FAA PUT with own service token, 300s
  timeout, fail-fast on missing FAA URL)
- T8 /health upgrade (healthy/degraded/unhealthy + 30s background cache)
- T9 stage_timings (release_active_job in terminal states)
- T10 env + Docker integration (MULTIPART_* + concurrency limiter)
- T11 README (498 lines) + OpenAPI 3.0 spec (1588 lines)

Tests: 630 pass across 29 suites. Updated Dockerfile + .dockerignore +
docker-compose.yml env passthrough (no hardcoded secrets, fail-fast on
missing required vars).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-01 10:55:05 +08:00
parent 548f53ccbf
commit 4d381c0b50
67 changed files with 23881 additions and 637 deletions

View File

@ -0,0 +1,61 @@
# T10Docker build 時排除以下檔案,避免進 production image
#
# 重點:
# 1. .env / *.env — secret 不該進 image由 docker-compose / secret manager 注入
# 2. node_modules — Dockerfile 的 `npm ci` 會在 image 內重新安裝production-only
# 3. tests / fixtures — 測試檔不該進 production image減少 attack surface 與 image size
# 4. IDE / VCS — .vscode, .idea, .git 都是開發工具產物
# 5. Coverage / 暫存 — 任何 build artifact
# === 環境變數 / 密鑰 ===
.env
.env.*
!env.example
# === Node ===
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
.pnpm-store
# === 測試 ===
**/__tests__
**/*.test.js
**/*.spec.js
coverage
.nyc_output
jest.config.js
# === IDE / OS ===
.vscode
.idea
.DS_Store
*.swp
*.swo
*~
# === Git ===
.git
.gitignore
.gitattributes
# === Docker避免遞迴===
Dockerfile*
.dockerignore
docker-compose*.yml
# === 文件(不需進 image===
README.md
CHANGELOG.md
LICENSE
docs
# === 暫存 / build artifact ===
*.log
*.pid
*.seed
dist
build
tmp

View File

@ -1,23 +1,46 @@
# Task Scheduler DockerfilePhase 1
#
# 設計重點T10 補強):
# 1. 用 node:18-alpine —— 最小化 image 大小(~150MB vs node:18 的 ~1GB
# 2. 兩階段:先複製 package*.json + npm ci再 COPY 其他檔,善用 Docker 層快取
# 3. 只裝 production deps--only=production / --omit=dev—— jest / nodemon 不進 image
# 4. 非 root user 執行,降低 RCE 後的影響面
# 5. .dockerignore 已排除 .env / tests / node_modules / IDE 設定
# 6. HEALTHCHECK 對接 /health 端點T8 已實作)
# 7. 環境變數透過 docker-compose / Kubernetes secret 注入,不在 image 內
FROM node:18-alpine
WORKDIR /app
# curl 只給 HEALTHCHECK 用alpine 預設無
RUN apk add --no-cache curl
# === 第一層:依賴(變動較少,快取友善)===
# 先 COPY package*.jsonnpm ci 後再 COPY 原始碼,避免改 src 就 invalidate npm install 層
COPY package*.json ./
RUN npm ci --only=production
# --omit=dev 對齊新版 npm替代 --only=productionjest / nodemon 等 devDependencies 不會被裝
RUN npm ci --omit=dev && npm cache clean --force
# === 第二層:原始碼 ===
# .dockerignore 已排除 .env / tests / __tests__ / node_modules這裡 COPY . . 是安全的
COPY . .
# === 安全:非 root user ===
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# 建立 job data dir 並改 ownerworker / scheduler 共用 volume 用)
RUN mkdir -p /data/jobs && chown -R appuser:appgroup /app /data/jobs
USER appuser
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
# Health check 對接 /health 端點T8含 redis / Member Center / FAA reachability
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:4000/health || exit 1
CMD ["npm", "start"]
# 啟動:直接 node server.js不用 npm start以利 SIGTERM signal 直接送到 node
# - npm start 會 fork 一層SIGTERM 不一定傳到 child影響 graceful shutdown
CMD ["node", "server.js"]

View File

@ -0,0 +1,498 @@
# Task Scheduler — Kneron Model Converter Phase 1
Kneron Model Converter 的 Job 管理與 queue orchestration 服務。負責接收上游
visionA-backend / Web UI的轉檔請求協調 ONNX → BIE → NEF pipeline並把成功
的結果檔 promote 到 File Access Agent / NAS 模型庫。
> **Phase 1 對外 API 完整規格** → 見 `docs/openapi.yaml`
---
## 1. 專案介紹
### 1.1 服務角色
```
public Internet internal
visionA-backend ─→ Nginx (443, public vhost) ─→ /api/v1/* ─→ task-scheduler ─→ Worker
Web UI ─→ Nginx (80, internal vhost) ─→ /jobs ──┘ │
ONNX → BIE → NEF
MinIO Bucket
POST /api/v1/jobs/:id/promote
File Access Agent
NAS 模型庫
```
task-scheduler 是 Phase 1 唯一暴露給上游的應用層元件,承擔:
- 對外 API**Phase 1 新增**`/api/v1/*` 共 4 個端點 + 2 個 Phase 2 預留
- 內部 API**保留既有**`/jobs/*` 共 6 個 legacy 端點Web UI 用)
- 健康檢查:`/health`(公開)
### 1.2 技術堆疊
| 層級 | 技術 | 版本 |
|------|------|------|
| 執行環境 | Node.js | 18+ (alpine image, 部署用) |
| Web framework | Express | 4.x |
| Queue | Redis Stream + ioredis | 5.x |
| 物件儲存 | MinIOS3 compatibleAWS SDK v3 | latest |
| 認證 | OAuth 2.0 + JWTjose | jose 5.x |
| 上傳 | multer (memoryStorage) | 1.4.x |
| 速率限制 | express-rate-limit | 6.x |
| 安全 headers | helmet | 7.x |
| 測試 | Jest | 29.x |
---
## 2. 前置需求
| 項目 | 版本 / 說明 |
|------|-----------|
| Node.js | 18+fetch 原生支援、`duplex: 'half'` |
| npm | 9+ |
| Docker / docker-compose可選 | 24.x+ |
| Redis | 7.xdev / prod 都需要) |
| MinIO | latestPOST /api/v1/jobs 必須啟用) |
| Member Center | OAuth 2.0 Authorization Server提供 JWKS / token endpoint |
| File Access Agent | promote 階段呼叫,需支援 `PUT /files/{key}` |
dev 環境若無真實 Member Center / FAA可用 placeholder 值(見 `env.example`)。
---
## 3. 啟動方式
### 3.1 本機開發(純 Node
```bash
cd apps/task-scheduler
cp env.example .env
# 編輯 .env至少把以下 placeholder 替換為真實值:
# - MEMBER_CENTER_*(若要實際打 Member Center
# - KNERON_CONVERTER_CLIENT_SECRET
# - MINIO_*(若 STORAGE_BACKEND=minio
npm install
npm start
# → 監聽 PORT預設 4000
```
### 3.2 Docker 單體
```bash
docker build -t task-scheduler:dev apps/task-scheduler
docker run --rm --env-file apps/task-scheduler/.env -p 4000:4000 task-scheduler:dev
```
### 3.3 docker-compose推薦
專案根目錄已有 `docker-compose.yml`,會一併啟動 Redis、MinIO、Workers、frontend
```bash
cd /path/to/kneron_model_converter
cp apps/task-scheduler/env.example .env # 或維護一份 root .env
docker compose up -d --build
```
服務埠對外:
- Scheduler API`http://localhost:4000`
- Web UI`http://localhost:3000`
- MinIO Console`http://localhost:9001`
### 3.4 Health check
```bash
curl http://localhost:4000/health | jq .
```
回應為三層 statushealthy / degraded / unhealthy+ 各依賴狀態,
詳見 [§ 7. 監控](#7-監控)。
### 3.5 Graceful shutdown
服務監聽 `SIGTERM` / `SIGINT`:收到後會先停掉 health background polling
再讓 Express 自然關閉。容器 / K8s 部署時 `terminationGracePeriodSeconds`
建議至少 30 秒。
---
## 4. 專案結構
```
apps/task-scheduler/
├── server.js ← entry< 140 組裝 deps啟動 listenerlisten
├── src/
│ ├── app.js ← Express app factory
│ ├── config.js ← 集中讀 env啟動時 fail-fast
│ ├── redis.js ← Redis client + helpers
│ ├── auth/
│ │ ├── jwks.js ← jose remote JWKS cache + jwtVerify
│ │ ├── middleware.js ← requireAuth(scope) Express middleware
│ │ └── oauthClient.js ← Converter as OAuth Clientclient_credentials
│ ├── fileAccessAgent/
│ │ ├── client.js ← FAA HTTP clientPUT only重試 + 401 invalidate
│ │ └── errors.js
│ ├── middleware/
│ │ ├── errorHandler.js ← 統一 error 格式v1 限定)
│ │ ├── requestId.js ← X-Request-Id 透傳 / 生成
│ │ ├── perClientRateLimit.js ← per-client_id rate limiter
│ │ ├── upload.js ← multer 設定
│ │ └── uploadConcurrency.js ← per-process upload semaphore防 OOM
│ ├── routes/
│ │ ├── legacy.js ← /jobs* 6 個端點Web UI 用)
│ │ └── v1/
│ │ ├── index.js ← /api/v1 mount + 內部 errorHandler
│ │ ├── jobs.js ← POST/GET /jobs, GET /jobs/:id, 預留 501
│ │ ├── promote.js ← POST /jobs/:id/promote
│ │ └── validators/
│ │ └── createJob.js ← multipart fields validator
│ ├── services/
│ │ ├── jobService.js ← Job CRUD + claim_active / advance / fail
│ │ ├── doneListener.js ← Redis Stream 背景 listener
│ │ ├── healthService.js ← /health 背景 polling cache
│ │ ├── statusMapper.js ← 內部大寫 status → 對外 status + stage
│ │ └── sseService.js ← SSE 推送legacy
│ ├── storage/
│ │ ├── minio.js ← AWS SDK v3 S3 facade
│ │ └── local.js ← STORAGE_BACKEND=local 模式
│ ├── redis/
│ │ └── luaScripts.js ← claim_active_job / release_active_job
│ └── utils/
│ └── sanitize.js ← filename / user_id / path 安全處理
├── docs/
│ └── openapi.yaml ← Phase 1 對外 API spec給 visionA 等消費者)
├── tests/ ← 單元 + 整合測試(見 src/**/__tests__/
├── package.json
├── Dockerfile ← 多層快取 + 非 root user + HEALTHCHECK
├── env.example ← 完整環境變數範本(不含真實 secret
└── README.md ← 本檔
```
---
## 5. 環境變數
完整清單(含預設、必填與否、說明)見 [`env.example`](./env.example)。
簡表(依分類):
### 5.1 必填(缺漏會 fail-fast、process exit code 1
| 變數 | 用途 |
|------|------|
| `REDIS_URL` | Redis 連線(含 password |
| `STORAGE_BACKEND` | `local` / `minio`POST /api/v1/jobs 必須 `minio` |
| `MEMBER_CENTER_ISSUER` | JWT iss 比對基準 |
| `MEMBER_CENTER_JWKS_URL` | JWKS endpoint驗 token 用) |
| `MEMBER_CENTER_TOKEN_URL` | token endpoint取 promote 用 token |
| `KNERON_CONVERTER_AUDIENCE` | 接受 JWT 的 aud |
| `KNERON_CONVERTER_CLIENT_ID` | Converter 自己 OAuth client |
| `KNERON_CONVERTER_CLIENT_SECRET` | **不要進 git用 secret manager** |
| `FILE_ACCESS_AGENT_BASE_URL` | promote 目標production 強制 https |
| `FILE_ACCESS_AGENT_AUDIENCE` | promote token 的 aud |
`STORAGE_BACKEND=minio` 時還需:`MINIO_ENDPOINT_URL` / `MINIO_BUCKET` /
`MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY`
### 5.2 可選(有合理預設)
涵蓋:
- 上傳上限(`MULTIPART_MODEL_MAX_BYTES` 預設 500MB、`MULTIPART_REF_IMAGE_MAX_BYTES`
預設 10MB、`MULTIPART_REF_IMAGES_MAX_COUNT` 預設 100
- 上傳並發(`MAX_CONCURRENT_UPLOADS` 預設 5、`UPLOAD_RETRY_AFTER_SECONDS` 預設 30
- Rate limit`API_V1_RATE_LIMIT_WINDOW_MS` 預設 5min、`API_V1_RATE_LIMIT_MAX` 預設 300
- JWKS 行為(`JWKS_CACHE_MAX_AGE_MS``JWKS_COOLDOWN_MS``JWT_CLOCK_TOLERANCE_SEC`
- OAuth client`OAUTH_TOKEN_REFRESH_SKEW_MS``OAUTH_TOKEN_TIMEOUT_MS`
- promote timeout`PROMOTE_TIMEOUT_MS` 預設 300s
- Tenant 隔離(`CONVERTER_TENANT_ID`,空字串 = 不檢查)
- Scope 命名覆寫(`CONVERTER_SCOPE_WRITE` / `CONVERTER_SCOPE_READ`
### 5.3 安全提醒
- `.env` 已在 `.gitignore`;不要 commit
- production 用 secret managerVault / AWS Secrets Manager / K8s Secret
而不是把 secret 直接放進 docker-compose env
- 任何含 `REPLACE-ME` 字樣或 `.invalid` TLD 的 placeholder**部署前必須替換**
---
## 6. API 概覽
### 6.1 Phase 1 對外 API`/api/v1/*`
| 方法 | 路徑 | scope | 說明 |
|------|------|-------|------|
| POST | `/api/v1/jobs` | `converter:job.write` | 建立轉檔 jobmultipart |
| GET | `/api/v1/jobs` | `converter:job.read` | Recovery 列表user_id 必填) |
| GET | `/api/v1/jobs/:id` | `converter:job.read` | 單一 job 狀態(含 ETag |
| POST | `/api/v1/jobs/:id/promote` | `converter:job.write` | 結果檔搬到 FAA |
| POST | `/api/v1/jobs/:id/download-tokens` | `converter:job.read` | **Phase 2 預留**,回 501 |
| DELETE | `/api/v1/jobs/:id` | `converter:job.write` | **Phase 2 預留**,回 501 |
完整規格、所有 schema、所有錯誤情境的 example見 [`docs/openapi.yaml`](./docs/openapi.yaml)。
### 6.2 Legacy / 內部 API`/jobs/*`,僅內網 vhost 暴露)
對 Web UI 100% 不變更行為T4 重構僅是「移動 + 抽象」):
| 方法 | 路徑 | 說明 |
|------|------|------|
| POST | `/jobs` | Web UI 上傳建 jobmultipart無 user_id 概念) |
| GET | `/jobs` | 列出全部 joblegacy KEYS scan |
| GET | `/jobs/:jobId` | 查單一 job |
| GET | `/jobs/:jobId/events` | SSE 推送 |
| GET | `/jobs/:jobId/download/:filename` | 下載結果檔 |
| GET | `/queues/stats` | Redis Stream / Group 統計 |
### 6.3 健康檢查
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/health` | 公開,不需認證 |
---
## 7. Auth 流程
### 7.1 上游消費者visionA-backend取 token
Converter 是 OAuth 2.0 Resource Server。建議消費者用 `client_credentials`
grant 從 Member Center 取得 service-to-service token
```
POST {member-center}/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<your-client>
&client_secret=<your-secret>
&scope=converter:job.write converter:job.read
&audience=kneron_converter_api
```
### 7.2 Converter 端驗證
每個 `/api/v1/*` request 進入時:
1. Bearer token 驗章(`jose.createRemoteJWKSet` + `jwtVerify`
2. `iss` / `aud` / `exp`(含 60 秒 clock skew
3. `scope`(端點要求的 scope 必須在 token claim 內)
4. `tenant_id`(若 `CONVERTER_TENANT_ID` 非空則檢查)
5. `client_id`(用於 rate limit / log / job 隔離)
驗證失敗時:
- 回 v1 標準錯誤格式(`{error: {code, message, details, request_id}}`
- **設 `Connection: close` header + `req.socket.destroy()`**:阻止
unauthorized client 繼續灌大檔。但這是 best-effort真正的 body 上限
靠 Nginx `client_max_body_size`(部署層)
### 7.3 Converter 取 promote 用 token
promote 時 Converter 切換成 OAuth Client`client_credentials`
`files:upload.write` scope tokenPUT 到 FAA。
token cache per scope過期前 60s 主動 refreshFAA 回 401 時自動
invalidate cache 並重試一次。
---
## 8. 錯誤碼總表
| HTTP | code | 說明 |
|------|------|------|
| 400 | `validation_error` | 欄位格式錯(`details.fields[]` 列具體欄位) |
| 400 | `invalid_multipart` | multipart parse 失敗、缺必要 file、副檔名不符 |
| 401 | `invalid_token` | JWT 無效 / 簽章錯 / 缺 claim |
| 401 | `token_expired` | JWT 過期 |
| 403 | `insufficient_scope` | scope 不足(`details.required_scope` / `provided_scopes` |
| 403 | `tenant_mismatch` | tenant_id 不符 |
| 404 | `job_not_found` | job 不存在或不屬於該 client不洩漏存在性 |
| 404 | `not_found` | 路徑不存在 |
| 409 | `user_has_active_job` | 同 user 已有未完成 job`details.active_job_*` |
| 409 | `job_not_ready_for_promote` | promote 時 job 非 completed |
| 409 | `source_not_available` | promote 的 source stage 沒產出 |
| 413 | `file_too_large` | 上傳超過大小上限model 500MB / ref_image 10MB |
| 422 | `invalid_object_key` | promote target_object_key 格式不合法 |
| 429 | `rate_limit_exceeded` | per-client rate limit |
| 500 | `misconfiguration` | 伺服器設定錯(如 STORAGE_BACKEND 非 minio |
| 500 | `internal_error` | 其他未分類錯誤 |
| 501 | `not_implemented` | Phase 2 預留端點 |
| 502 | `storage_unavailable` | MinIO 寫入失敗 |
| 502 | `file_gateway_unavailable` | FAA 不可用 / 拒絕 |
| 503 | `auth_service_unavailable` | Member Center 取 token 失敗 |
| 503 | `service_busy` | upload concurrency 已滿(`Retry-After` header |
response 完整 schema 見 [`docs/openapi.yaml`](./docs/openapi.yaml#components/schemas/ApiError)。
---
## 9. 與其他服務的關係
| 服務 | 連接方式 | 用途 | 失敗影響 |
|------|---------|------|---------|
| Member Center | HTTPS | 驗 visionA token / 取 promote token | 新 token 無法驗cache 內舊 token 仍可用promote 階段失敗 |
| File Access Agent | HTTPS | promote 結果檔搬到 NAS | promote 失敗,但 job 本身已 completed可重試 |
| MinIO | HTTP / HTTPS | 原始模型 / 結果檔暫存7 天 lifecycle | POST /jobs 直接 502promote 也會失敗 |
| Redis | TCP | Job state、active_job lock、Stream queue | 整個服務 unhealthy |
| Workeronnx / bie / nef | Redis Stream | 跑 pipeline | Job 卡在某個 stageTTL 7 天會自動清 |
---
## 10. 監控
### 10.1 `/health` 的三層 status
| status | HTTP | 對應狀態 |
|--------|------|---------|
| `healthy` | 200 | Redis / MC / FAA 都連通 |
| `degraded` | 200 | Redis 連通,但 MC / FAA 任一不可達 |
| `unhealthy` | 503 | Redis 斷線 |
response body 同時包含 `dependencies.{redis, member_center, file_access_agent}`
細節,可給 K8s readiness / liveness probe 區分嚴重度。
### 10.2 結構化日誌
所有 v1 路徑的 handler 都輸出 JSON logstdout
```json
{
"service": "task-scheduler",
"timestamp": "2026-04-25T12:00:00.123Z",
"level": "INFO",
"action": "jobs.create.success",
"request_id": "7c6e4f3b-...",
"job_id": "550e8400-...",
"user_id": "alice",
"client_id": "kneron_converter_dev",
"size_bytes": 204800000,
"ref_images_count": 0,
"duration_ms": 234
}
```
`action` 欄位採 `domain.event` 格式,便於用 jq / loki 過濾。
### 10.3 Rate limit headers
回應自動帶:
- `X-RateLimit-Limit` / `RateLimit-Limit`
- `X-RateLimit-Remaining` / `RateLimit-Remaining`
- 超限時:`Retry-After`(秒)
---
## 11. Phase 1 已知接受風險
> 本節為摘要,完整內容見 [`.autoflow/04-architecture/security.md`](../../.autoflow/04-architecture/security.md)。
### 11.1 user_id 信任邊界(最重要)
- `user_id` 來自 multipart form fieldPOST或 query stringGET
**不**從 JWT claim derive
- Converter 完全信任 visionA-backend 帶來的 user_id 是對的,**不做 user 層級 ACL**
- visionA-backend 一旦被 compromiseattacker 可冒充任何 user_id
**Phase 1 接受此風險的理由**
1. visionA-backend 是內部受控系統,非 Internet-facing
2. Phase 1 重點是 pipeline 跑通;安全強化排在 Phase 2
3. HMAC / OBO 流程要 visionA / Member Center 配合,已對齊但尚未實作
**Phase 1 mitigation**
- per-client_id rate limit300 req / 5 min
- 結構化 audit log 含 `client_id` + `user_id`
- 7 天 active_job TTL避免 lock 永久不釋放)
- `user_id` 嚴格白名單(`^[A-Za-z0-9._-]{1,128}$`)擋 XSS / Redis key injection
**Phase 2 候補**HMAC-signed user_id短期/ OAuth Token Exchange中期
### 11.2 大檔上傳的 OOM 風險
- multer 用 `memoryStorage` — 每個並發 upload 吃 model size 大小的 heap
- 5 並發 × 500MB = 2.5GB`MAX_CONCURRENT_UPLOADS` 預設 54GB 容器安全)
- 超過時 503 + `Retry-After`client 主動 backoff
### 11.3 Trust boundary 與 Nginx 層
- 401/403 後 server 雖會 `socket.destroy()`,但這是 best-effort
- 真正的 body 大小上限由 Nginx vhost `client_max_body_size 600M` 把關
- Nginx 雙 vhost 設定詳見 TDD §7.1DevOps 範圍,非後端)
### 11.4 Per-process statePhase 2 才需處理)
- rate limiter / upload concurrency 都是 in-process counter
- Phase 1 部署為單 instance無問題Phase 2 多 instance 時要改 Redis store
---
## 12. 測試
```bash
npm test # 跑所有 unit + integration test630 tests~4 秒)
npm test -- --watch # watch 模式
npm test -- src/auth # 只跑 auth 模組的測試
```
測試金字塔:
- 單元測試70%service / validator / utils / middleware
- 整合測試20%route + middleware + Redis 模擬 / FAA mock
- E2E10%):由 Testing Agent 跑(不在本套件內)
CI 用:`npm test`
---
## 13. 故障排除(常見場景)
| 症狀 | 可能原因 | 排查 |
|------|---------|------|
| 啟動立刻 exit 1 | env 缺漏 | 看 `[Scheduler] Config validation failed` log對照 `env.example` |
| 401 invalid_token / token_expired | clock skew、JWKS cache 沒拿到新 kid | 檢查 server 時鐘、`MEMBER_CENTER_JWKS_URL` 可達性 |
| 401 後 client 連線立刻斷 | 設計如此(`Connection: close` + `socket.destroy()` | 正常行為,避免 client 繼續灌 body |
| 409 user_has_active_job 但前一個 job 已 failed | active_job lock 沒被釋放 | 看 worker done listener 是否運作;最壞情況 7 天 TTL 會自動清 |
| 502 storage_unavailable | MinIO 不可達 / 認證錯 | 檢查 `MINIO_*` env、bucket 是否存在 |
| 502 file_gateway_unavailable | FAA 5xx 或 4xx 拒絕(非 401 | 看 server log `promote.faa_put_failed`FAA 端排查 |
| 503 auth_service_unavailable | Member Center token endpoint 死 / 401 兩次 | 確認 `MEMBER_CENTER_TOKEN_URL` 可達、`KNERON_CONVERTER_CLIENT_*` 對 |
| 503 service_busy + Retry-After | upload concurrency 已滿 | 等 Retry-After或調高 `MAX_CONCURRENT_UPLOADS`(注意 OOM |
| 503 unhealthy/health | Redis 斷線 | 檢查 `REDIS_URL` 與 Redis 服務狀態 |
| GET /jobs 回 400 missing user_id | Phase 1 強制 user_id 必填 | client 端帶 user_id query string |
| 大檔上傳跑到一半 5xx | Nginx `client_max_body_size` 太小 | 部署層調 `client_max_body_size 600M`(不在 backend 範圍) |
更多細節:
- `.autoflow/04-architecture/TDD.md`(完整規格)
- `.autoflow/04-architecture/security.md`(安全模型 / 接受風險)
- `.autoflow/05-implementation/tasks-phase1.md`(任務拆分與決策紀錄)
---
## 14. 文件參照
| 文件 | 內容 |
|------|------|
| [`docs/openapi.yaml`](./docs/openapi.yaml) | Phase 1 對外 API spec給 visionA-backend 等消費者 import |
| [`env.example`](./env.example) | 完整環境變數清單(含說明、預設、必填與否) |
| `../../.autoflow/04-architecture/TDD.md` | 完整技術設計文件 |
| `../../.autoflow/04-architecture/security.md` | 安全模型 / 接受風險 / Phase 2 候補 |
| `../../.autoflow/04-architecture/design-doc.md` | 架構決策(為什麼選這些方案) |
| `../../.autoflow/02-prd/PRD.md` | 產品需求 / user stories |
| `../../.autoflow/05-implementation/tasks-phase1.md` | T1-T11 任務拆分與審查紀錄 |
---
## 15. License
MIT

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,200 @@
# Task Scheduler Configuration
###############################################################################
# Task Scheduler 環境變數範本Phase 1 完整版T10 收斂)
#
# 三類分區(依顯示順序):
# 1. 必填production 必須設真實值)— 缺漏會 fail-fastprocess exit code 1
# 2. 可選(合理預設)— 不設會用程式內 default
# 3. 開發 placeholder — 用 RFC 2606 `.invalid` TLD 確保不會誤連到真實服務
#
# 部署準則:
# - 切勿 commit `.env`(已在 .gitignore歷史 commit 待 D7 處理)
# - production 用 secret managerVault / AWS Secrets Manager不要直接設環境變數
# - 任何含 `REPLACE-ME` 字樣或 `.invalid` TLD 的值,部署前必須替換
###############################################################################
# =============================================================================
# 1. 應用基本設定
# =============================================================================
# 監聽 port必填但有合理預設
PORT=4000
# Node 環境development / staging / production
# - production 時 FILE_ACCESS_AGENT_BASE_URL 強制 HTTPS
NODE_ENV=development
# Redis
# Log 等級debug / info / warn / error
LOG_LEVEL=info
# =============================================================================
# 2. Redis必填
# =============================================================================
# - 不設會用 default但實際部署需指向真實 Redis
# - 帶 passwordredis://:password@host:6379
REDIS_URL=redis://localhost:6379
# Job data directory (shared volume with workers)
# =============================================================================
# 3. Job 資料目錄local storage 用)
# =============================================================================
# - STORAGE_BACKEND=local 時,此目錄為 worker / scheduler 共用 volume
# - STORAGE_BACKEND=minio 時,仍會用此目錄存暫時檔(如 health check
JOB_DATA_DIR=/data/jobs
# Frontend URL (for CORS)
# =============================================================================
# 4. CORS必填
# =============================================================================
FRONTEND_URL=http://localhost:3000
# Storage backend: "local" (shared volume) or "minio"
# =============================================================================
# 5. Storage backend必填
# =============================================================================
# - "local":用 JOB_DATA_DIR 共用 volume單機開發 / docker-compose
# - "minio":用 MinIO / S3-compatibleproduction 推薦POST /api/v1/jobs 必須 minio
STORAGE_BACKEND=local
# MinIO settings (only used when STORAGE_BACKEND=minio)
# =============================================================================
# 6. MinIO / S3 設定
# =============================================================================
# - STORAGE_BACKEND=minio 時為必填
# - STORAGE_BACKEND=local 時可留空
# - 注意production 不要把真實 secret 寫在這裡,改用 secret manager
MINIO_ENDPOINT_URL=http://192.168.0.130:9000
MINIO_BUCKET=convertet-working-space
MINIO_ACCESS_KEY=convuser
MINIO_SECRET_KEY=your-secret-here
MINIO_SECRET_KEY=REPLACE-ME-IN-PRODUCTION
MINIO_REGION=us-east-1
# bucket lifecycle— 上傳後 N 天自動清,避免 orphan 累積
MINIO_LIFECYCLE_DAYS=7
# =============================================================================
# 7. OAuth / Member Center必填
# =============================================================================
#
# ⚠️ 下方 `*.invalid` 主機名都是 RFC 2606 保留 TLDDNS 永不解析。
# 本地開發跑「不需 OAuth 的 legacy /jobs 流程」可直接照抄;
# production 部署前務必替換為真實 Member Center URL否則 token 驗證 / 取得會 DNS 失敗。
#
# 三組 URL 通常來自同一個 Member Center 服務:
# - ISSUERJWT 的 iss claim 比對基準
# - JWKS_URL取公鑰用做 JWT 簽章驗證
# - TOKEN_URLConverter 自己取 token 用client_credentials grant
MEMBER_CENTER_ISSUER=https://auth.example.invalid
MEMBER_CENTER_JWKS_URL=https://auth.example.invalid/.well-known/jwks
MEMBER_CENTER_TOKEN_URL=https://auth.example.invalid/oauth/token
# =============================================================================
# 8. Converter 身份(必填)
# =============================================================================
#
# Converter 同時是:
# - Resource Server接收 visionA-backend 的 tokenaudience 必須為 KNERON_CONVERTER_AUDIENCE
# - OAuth Client自己去 Member Center 取 token 打 File Access Agent身份用 client_id / secret
KNERON_CONVERTER_AUDIENCE=kneron_converter_api
KNERON_CONVERTER_CLIENT_ID=kneron_converter_dev
KNERON_CONVERTER_CLIENT_SECRET=REPLACE-ME-IN-PRODUCTION
# 若需 tenant 隔離,設此值;空字串代表不檢查 tenant claim
CONVERTER_TENANT_ID=
# =============================================================================
# 9. Scope 命名(可選,預設對齊 TDD §8
# =============================================================================
# 通常不需改;除非 Member Center 端命名不一樣
# CONVERTER_SCOPE_WRITE=converter:job.write
# CONVERTER_SCOPE_READ=converter:job.read
# =============================================================================
# 10. File Access Agent必填
# =============================================================================
#
# Promote 時 Converter 把產出 stream PUT 到 FAA。
# - URL 必須是合法 http(s) URLNODE_ENV=production 強制 https
# - 本地開發可用 placeholder.invalid TLD不影響非 promote 流程
FILE_ACCESS_AGENT_BASE_URL=https://files.example.invalid
FILE_ACCESS_AGENT_AUDIENCE=file_access_api
# =============================================================================
# 11. Promote 行為(可選)
# =============================================================================
# 單檔 PUT timeout毫秒。預設 300000300s覆蓋 500MB @ 5MB/s 最壞)。
# 部署環境檔案普遍較小可調低GB 級檔案可調高。
# PROMOTE_TIMEOUT_MS=300000
# =============================================================================
# 12. JWKS / JWT 行為(可選)
# =============================================================================
# 預設值對齊 TDD §5.1。
# JWKS_CACHE_MAX_AGE_MS=600000 # JWKS cache 有效期10 分鐘)
# JWKS_COOLDOWN_MS=30000 # 同 kid 連續 miss 的 cooldown30 秒)
# JWT_CLOCK_TOLERANCE_SEC=60 # 時鐘偏差容忍(秒)
# =============================================================================
# 13. OAuth Client cache可選
# =============================================================================
# OAUTH_TOKEN_REFRESH_SKEW_MS=60000 # token 距 expiresAt 還剩多少 ms 主動 refresh
# OAUTH_TOKEN_TIMEOUT_MS=10000 # 取 token timeout10s
# =============================================================================
# 14. Multipart 上傳上限可選T10 修 D5
# =============================================================================
#
# 為什麼用 env
# 不同部署環境記憶體配額差異大dev 容器 2GB / prod 16GB固定 500MB 不夠彈性。
# 調這些值不需改原始碼。
#
# 三個值都必須 > 0非法值會 fail-fast。
# MULTIPART_MODEL_MAX_BYTES=524288000 # 500MBmodel 檔案上限)
# MULTIPART_REF_IMAGE_MAX_BYTES=10485760 # 10MB單張 ref_image 上限)
# MULTIPART_REF_IMAGES_MAX_COUNT=100 # ref_images 張數上限
# =============================================================================
# 15. Upload concurrency可選T10 修 D5
# =============================================================================
#
# 為什麼需要:
# multer memoryStorage 把整份 multipart load 進 buffer每個並發 upload 吃掉
# model size 大小的 heap。5 並發 × 500MB ≈ 2.5GB heap4GB 容器有風險。
# per-process counter 限制同時間 multipart parse + handler 進行中的請求數量。
#
# 超過上限時:直接 503 service_busy + Retry-After header不 queue讓 client 主動 backoff。
# MAX_CONCURRENT_UPLOADS=5 # 同時間最多 5 個 upload 進行中
# UPLOAD_RETRY_AFTER_SECONDS=30 # 503 response 的 Retry-After
# =============================================================================
# 16. Per-client_id rate limit可選T3 起)
# =============================================================================
# 對 /api/v1/* 套用window 內每個 client_id 最多 max 個 request。
# 預設 5min / 300 req對齊 TDD §1.1)。
# API_V1_RATE_LIMIT_WINDOW_MS=300000
# API_V1_RATE_LIMIT_MAX=300

View File

@ -17,6 +17,7 @@
"express-rate-limit": "^6.10.0",
"helmet": "^7.0.0",
"ioredis": "^5.3.2",
"jose": "^5.10.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.0"
@ -5062,6 +5063,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -9,21 +9,22 @@
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1",
"ioredis": "^5.3.2",
"uuid": "^9.0.0",
"dotenv": "^16.3.1",
"helmet": "^7.0.0",
"express-rate-limit": "^6.10.0",
"morgan": "^1.10.0",
"@aws-sdk/client-s3": "^3.400.0",
"compression": "^1.7.4",
"@aws-sdk/client-s3": "^3.400.0"
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^6.10.0",
"helmet": "^7.0.0",
"ioredis": "^5.3.2",
"jose": "^5.10.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.6.2"
"jest": "^29.6.2",
"nodemon": "^3.0.1"
},
"keywords": [
"kneron",

View File

@ -1,641 +1,133 @@
/**
* Kneron Toolchain Task Scheduler
* Kneron Toolchain Task Scheduler entry point
*
* 職責
* 1. REST API 建立 job查詢狀態上傳檔案下載結果
* 2. Job State 透過 Redis Hash 管理 job 生命週期
* 3. Queue 調度 透過 Redis Stream 派工給 Worker
* 4. Done 監聽 接收 Worker 完成事件推進到下一階段
* 5. SSE 即時推送 job 狀態給前端
* 1. 啟動時 fail-fast 驗證 config D3 T1-deviations.md
* 2. 建立各層 dependencyredis / minio / sseService / jobService
* 3. 組裝 Express appmount legacy 路由
* 4. 在背景啟動 done queue listener
* 5. listen port
*
* **本檔不應再寫業務邏輯**所有路由 / service / storage 細節都在 src/
*
* 重構說明T4
* src/redis.js Redis client helper
* src/storage/minio.js MinIO facade
* src/storage/local.js local volume helper
* src/services/sseService.js SSE client 管理
* src/services/jobService.js Job CRUD / advance / fail
* src/services/doneListener.js done queue 背景監聽
* src/middleware/upload.js multer 上傳設定
* src/routes/legacy.js 既有 7 個路由
* src/app.js Express app 組裝
*
* 既有 /jobs* 端點行為**完全不變**byte-for-byte除時間戳
* D3 修復本檔在 require 階段即呼叫 loadConfig() 必填 env 缺漏會 throw exit(1)
*/
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const morgan = require('morgan');
const compression = require('compression');
const { v4: uuidv4 } = require('uuid');
const Redis = require('ioredis');
const path = require('path');
const fs = require('fs');
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
'use strict';
/* eslint-disable no-console */
require('dotenv').config();
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const { loadConfig } = require('./src/config');
const { createClients } = require('./src/redis');
const { createMinioFacade } = require('./src/storage/minio');
const { createSseService } = require('./src/services/sseService');
const { createJobService, STAGES } = require('./src/services/jobService');
const { ensureWorkerGroups, startListenDone } = require('./src/services/doneListener');
const { createUploader } = require('./src/middleware/upload');
const { createHealthService } = require('./src/services/healthService');
const { createApp } = require('./src/app');
// D3 fail-fast缺必填 env 即 process.exit(1)
let config;
try {
config = loadConfig();
} catch (err) {
console.error('[Scheduler] Config validation failed:', err.message);
process.exit(1);
}
// 既有 env — 待後續整合到 config.js
const PORT = process.env.PORT || 4000;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const JOB_DATA_DIR = process.env.JOB_DATA_DIR || '/data/jobs';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
// MinIO config
const STORAGE_BACKEND = process.env.STORAGE_BACKEND || 'local';
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT_URL || 'http://192.168.0.130:9000';
const MINIO_BUCKET = process.env.MINIO_BUCKET || 'convertet-working-space';
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'convuser';
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || '';
const MINIO_REGION = process.env.MINIO_REGION || 'us-east-1';
let minio = null;
if (STORAGE_BACKEND === 'minio') {
minio = new S3Client({
endpoint: MINIO_ENDPOINT,
region: MINIO_REGION,
credentials: {
accessKeyId: MINIO_ACCESS_KEY,
secretAccessKey: MINIO_SECRET_KEY,
},
forcePathStyle: true, // Required for MinIO
});
console.log(`[Scheduler] MinIO storage enabled: ${MINIO_ENDPOINT}/${MINIO_BUCKET}`);
// 依賴組裝
const { redis, redisSub } = createClients(REDIS_URL);
const minio = createMinioFacade();
if (minio.client) {
console.log(`[Scheduler] MinIO storage enabled: ${minio.endpoint}/${minio.bucket}`);
}
const sseService = createSseService();
const jobService = createJobService({ redis, sseService, jobDataDir: JOB_DATA_DIR });
async function uploadToMinIO(key, body, contentType) {
if (!minio) return;
await minio.send(new PutObjectCommand({
Bucket: MINIO_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
}));
}
async function getFromMinIO(key) {
if (!minio) return null;
const response = await minio.send(new GetObjectCommand({
Bucket: MINIO_BUCKET,
Key: key,
}));
// Convert Body to Buffer (AWS SDK v3 Body is a web stream in Node 18)
const chunks = [];
for await (const chunk of response.Body) {
chunks.push(chunk);
}
return {
body: Buffer.concat(chunks),
contentLength: response.ContentLength,
};
}
// Pipeline: fixed stage order
const STAGES = ['onnx', 'bie', 'nef'];
const STAGE_QUEUES = {
onnx: 'queue:onnx',
bie: 'queue:bie',
nef: 'queue:nef',
};
const DONE_QUEUE = 'queue:done';
const DONE_GROUP = 'scheduler';
// ---------------------------------------------------------------------------
// Redis clients (one for commands, one for blocking reads)
// ---------------------------------------------------------------------------
const redis = new Redis(REDIS_URL);
const redisSub = new Redis(REDIS_URL);
redis.on('error', (err) => console.error('Redis error:', err));
redisSub.on('error', (err) => console.error('Redis subscriber error:', err));
// ---------------------------------------------------------------------------
// Express setup
// ---------------------------------------------------------------------------
const app = express();
app.use(helmet());
app.use(compression());
app.use(morgan('short'));
app.use(cors({ origin: FRONTEND_URL, credentials: true }));
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
message: 'Too many requests, please try again later.',
// T10multer uploader 從 config 取上限(修 D5
// - maxFileSize = MULTIPART_MODEL_MAX_BYTES預設 500MB
// - maxRefImages = MULTIPART_REF_IMAGES_MAX_COUNT預設 100
// ref_image per-file 10MB 上限由 validator 用 config.multipart.refImageMaxBytes 把關
const uploader = createUploader({
maxFileSize: config.multipart.modelMaxBytes,
maxRefImages: config.multipart.refImagesMaxCount,
});
app.use('/api', limiter);
// T8建立 healthService不在這裡 start等 listenDoneQueue 起來後再 start
const healthService = createHealthService({ redis, config });
const app = createApp(
{ redis, jobService, sseService, minio, uploader, healthService },
{ config, storageBackend: STORAGE_BACKEND }
);
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// File upload — store to job directory
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
});
// ---------------------------------------------------------------------------
// SSE: keep track of connected clients per job_id
// ---------------------------------------------------------------------------
const sseClients = new Map(); // job_id -> Set<res>
function sendSSE(jobId, data) {
const clients = sseClients.get(jobId);
if (!clients) return;
const payload = `data: ${JSON.stringify(data)}\n\n`;
for (const res of clients) {
res.write(payload);
}
}
// ---------------------------------------------------------------------------
// Helper: get / set job record in Redis
// ---------------------------------------------------------------------------
async function getJob(jobId) {
const raw = await redis.get(`job:${jobId}`);
if (!raw) return null;
return JSON.parse(raw);
}
async function setJob(jobId, job) {
job.updated_at = new Date().toISOString();
await redis.set(`job:${jobId}`, JSON.stringify(job));
// Notify SSE clients
sendSSE(jobId, job);
}
// ---------------------------------------------------------------------------
// Helper: enqueue a task to a stage queue
// ---------------------------------------------------------------------------
async function enqueueStage(stage, job) {
const queue = STAGE_QUEUES[stage];
const message = {
job_id: job.job_id,
created_at: job.created_at,
input_dir: path.join(JOB_DATA_DIR, job.job_id),
parameters: job.parameters || {},
};
await redis.xadd(queue, '*', 'data', JSON.stringify(message));
console.log(`[Scheduler] Enqueued job ${job.job_id} to ${queue}`);
}
// ---------------------------------------------------------------------------
// Helper: advance job to next stage or mark completed
// ---------------------------------------------------------------------------
async function advanceJob(jobId, completedStage) {
const job = await getJob(jobId);
if (!job) {
console.warn(`[Scheduler] Job ${jobId} not found, ignoring done event`);
return;
}
const currentIndex = STAGES.indexOf(completedStage);
if (currentIndex < 0) {
console.warn(`[Scheduler] Unknown stage: ${completedStage}`);
return;
}
const nextIndex = currentIndex + 1;
if (nextIndex < STAGES.length) {
// Advance to next stage
const nextStage = STAGES[nextIndex];
job.status = nextStage.toUpperCase();
job.stage = nextStage;
job.progress = Math.round(((nextIndex) / STAGES.length) * 100);
await setJob(jobId, job);
await enqueueStage(nextStage, job);
} else {
// All stages completed
job.status = 'COMPLETED';
job.stage = null;
job.progress = 100;
await setJob(jobId, job);
console.log(`[Scheduler] Job ${jobId} COMPLETED`);
}
}
// ---------------------------------------------------------------------------
// Helper: mark job as failed
// ---------------------------------------------------------------------------
async function failJob(jobId, step, reason) {
const job = await getJob(jobId);
if (!job) return;
job.status = 'FAILED';
job.error = { step, reason };
await setJob(jobId, job);
console.log(`[Scheduler] Job ${jobId} FAILED at ${step}: ${reason}`);
}
// ---------------------------------------------------------------------------
// Done queue listener — runs in background
// ---------------------------------------------------------------------------
async function ensureConsumerGroup(queue, group) {
try {
await redis.xgroup('CREATE', queue, group, '0', 'MKSTREAM');
} catch (err) {
// Group already exists — OK
if (!err.message.includes('BUSYGROUP')) throw err;
}
}
async function listenDoneQueue() {
const consumerName = `scheduler-${process.pid}`;
await ensureConsumerGroup(DONE_QUEUE, DONE_GROUP);
console.log(`[Scheduler] Listening on ${DONE_QUEUE} as ${consumerName}`);
while (true) {
try {
const results = await redisSub.xreadgroup(
'GROUP', DONE_GROUP, consumerName,
'COUNT', 10,
'BLOCK', 5000,
'STREAMS', DONE_QUEUE, '>'
);
if (!results) continue;
for (const [, messages] of results) {
for (const [messageId, fields] of messages) {
try {
const data = JSON.parse(fields[1]); // fields = ['data', '{...}']
const { job_id, step, result, reason } = data;
console.log(`[Scheduler] Done event: job=${job_id} step=${step} result=${result}`);
if (result === 'ok') {
await advanceJob(job_id, step);
} else {
await failJob(job_id, step, reason || 'Unknown error');
}
// ACK the message
await redisSub.xack(DONE_QUEUE, DONE_GROUP, messageId);
} catch (err) {
console.error('[Scheduler] Error processing done event:', err);
}
}
}
} catch (err) {
if (err.message.includes('Connection is closed')) {
console.error('[Scheduler] Redis connection lost, retrying in 3s...');
await new Promise((r) => setTimeout(r, 3000));
} else {
console.error('[Scheduler] Done listener error:', err);
await new Promise((r) => setTimeout(r, 1000));
}
}
}
}
// ---------------------------------------------------------------------------
// Ensure worker queue consumer groups exist on startup
// ---------------------------------------------------------------------------
async function ensureWorkerGroups() {
const groups = {
'queue:onnx': 'onnx-workers',
'queue:bie': 'bie-workers',
'queue:nef': 'nef-workers',
};
for (const [queue, group] of Object.entries(groups)) {
await ensureConsumerGroup(queue, group);
}
}
// ---------------------------------------------------------------------------
// API Routes
// ---------------------------------------------------------------------------
// Health check
app.get('/health', async (req, res) => {
try {
await redis.ping();
res.json({
service: 'task-scheduler',
status: 'healthy',
timestamp: new Date().toISOString(),
redis: 'connected',
});
} catch {
res.status(503).json({
service: 'task-scheduler',
status: 'unhealthy',
redis: 'disconnected',
});
}
});
// POST /jobs — Create a new job
app.post('/jobs', upload.fields([
{ name: 'model', maxCount: 1 },
{ name: 'ref_images', maxCount: 100 },
]), async (req, res) => {
try {
// Validate required fields
const { model_id, version, platform } = req.body;
if (!model_id || !version || !platform) {
return res.status(400).json({ error: 'model_id, version, platform are required' });
}
if (!req.files || !req.files.model || req.files.model.length === 0) {
return res.status(400).json({ error: 'model file is required' });
}
const jobId = uuidv4();
if (minio) {
// S3 mode: upload files to MinIO
const modelFile = req.files.model[0];
const s3Prefix = `jobs/${jobId}`;
await uploadToMinIO(
`${s3Prefix}/input/${modelFile.originalname}`,
modelFile.buffer,
modelFile.mimetype || 'application/octet-stream',
);
if (req.files.ref_images) {
for (const img of req.files.ref_images) {
await uploadToMinIO(
`${s3Prefix}/input/ref_images/${img.originalname}`,
img.buffer,
img.mimetype || 'image/jpeg',
);
}
}
console.log(`[Scheduler] Uploaded job ${jobId} files to MinIO`);
} else {
// Local mode: write to shared volume
const jobDir = path.join(JOB_DATA_DIR, jobId);
const inputDir = path.join(jobDir, 'input');
const refImagesDir = path.join(inputDir, 'ref_images');
const logsDir = path.join(jobDir, 'logs');
fs.mkdirSync(inputDir, { recursive: true });
fs.mkdirSync(refImagesDir, { recursive: true });
fs.mkdirSync(logsDir, { recursive: true });
const modelFile = req.files.model[0];
const modelPath = path.join(inputDir, modelFile.originalname);
fs.writeFileSync(modelPath, modelFile.buffer);
if (req.files.ref_images) {
for (const img of req.files.ref_images) {
const imgPath = path.join(refImagesDir, img.originalname);
fs.writeFileSync(imgPath, img.buffer);
}
}
}
// Optional flags
const parameters = {
model_id: parseInt(model_id, 10),
version,
platform,
enable_evaluate: req.body.enable_evaluate === 'true',
enable_sim_fp: req.body.enable_sim_fp === 'true',
enable_sim_fixed: req.body.enable_sim_fixed === 'true',
enable_sim_hw: req.body.enable_sim_hw === 'true',
};
// Create job record
const job = {
job_id: jobId,
created_at: new Date().toISOString(),
status: 'ONNX',
stage: 'onnx',
progress: 0,
updated_at: new Date().toISOString(),
parameters,
output: { bie_path: null, nef_path: null },
error: null,
};
await setJob(jobId, job);
// Enqueue to first stage
await enqueueStage('onnx', job);
res.status(201).json({
job_id: jobId,
status: 'ONNX',
message: 'Job created and queued',
});
} catch (err) {
console.error('[Scheduler] POST /jobs error:', err);
res.status(500).json({ error: err.message });
}
});
// GET /jobs/:jobId — Query job status
app.get('/jobs/:jobId', async (req, res) => {
const job = await getJob(req.params.jobId);
if (!job) {
return res.status(404).json({ error: 'JOB_NOT_FOUND' });
}
res.json(job);
});
// GET /jobs — List all jobs
app.get('/jobs', async (req, res) => {
try {
const keys = await redis.keys('job:*');
const jobs = [];
for (const key of keys) {
const raw = await redis.get(key);
if (raw) jobs.push(JSON.parse(raw));
}
// Sort by created_at descending
jobs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
res.json(jobs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /jobs/:jobId/events — SSE stream
app.get('/jobs/:jobId/events', async (req, res) => {
const jobId = req.params.jobId;
const job = await getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'JOB_NOT_FOUND' });
}
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// Send current state immediately
res.write(`data: ${JSON.stringify(job)}\n\n`);
// Register client
if (!sseClients.has(jobId)) {
sseClients.set(jobId, new Set());
}
sseClients.get(jobId).add(res);
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
// Cleanup on disconnect
req.on('close', () => {
clearInterval(heartbeat);
const clients = sseClients.get(jobId);
if (clients) {
clients.delete(res);
if (clients.size === 0) sseClients.delete(jobId);
}
});
});
// GET /jobs/:jobId/download/:filename — Download result file
app.get('/jobs/:jobId/download/:filename', async (req, res) => {
const { jobId, filename } = req.params;
const job = await getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'JOB_NOT_FOUND' });
}
if (minio) {
// MinIO mode: fetch from MinIO and send
const minioKey = `jobs/${jobId}/${filename}`;
try {
const result = await getFromMinIO(minioKey);
if (!result) {
return res.status(404).json({ error: 'FILE_NOT_FOUND' });
}
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', result.body.length);
res.send(result.body);
} catch (err) {
if (err.name === 'NoSuchKey') {
return res.status(404).json({ error: 'FILE_NOT_FOUND' });
}
console.error('[Scheduler] Download error:', err);
res.status(500).json({ error: 'Download failed' });
}
} else {
// Local mode: serve from filesystem
const filePath = path.join(JOB_DATA_DIR, jobId, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'FILE_NOT_FOUND' });
}
res.download(filePath);
}
});
// GET /queues/stats — Queue monitoring stats
app.get('/queues/stats', async (req, res) => {
try {
const queues = ['queue:onnx', 'queue:bie', 'queue:nef', 'queue:done'];
const groupNames = {
'queue:onnx': 'onnx-workers',
'queue:bie': 'bie-workers',
'queue:nef': 'nef-workers',
'queue:done': 'scheduler',
};
const stats = {};
for (const queue of queues) {
const length = await redis.xlen(queue);
let consumers = [];
let pending = 0;
let lag = 0;
const group = groupNames[queue];
if (group) {
try {
const groups = await redis.xinfo('GROUPS', queue);
// xinfo GROUPS returns flat array: [name, val, name, val, ...]
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
// Each group is a flat array of key-value pairs
const info = {};
for (let j = 0; j < g.length; j += 2) {
info[g[j]] = g[j + 1];
}
if (info.name === group) {
pending = parseInt(info.pending || '0', 10);
lag = parseInt(info.lag || '0', 10);
// Get consumers in this group
try {
const consumerList = await redis.xinfo('CONSUMERS', queue, group);
consumers = consumerList.map((c) => {
const ci = {};
for (let j = 0; j < c.length; j += 2) {
ci[c[j]] = c[j + 1];
}
return {
name: ci.name,
pending: parseInt(ci.pending || '0', 10),
idle: parseInt(ci.idle || '0', 10),
};
});
} catch { /* no consumers yet */ }
break;
}
}
} catch { /* group may not exist yet */ }
}
stats[queue] = { length, pending, lag, consumers };
}
// Also get job summary
const keys = await redis.keys('job:*');
const jobSummary = { total: keys.length, ONNX: 0, BIE: 0, NEF: 0, COMPLETED: 0, FAILED: 0 };
for (const key of keys) {
const raw = await redis.get(key);
if (raw) {
const job = JSON.parse(raw);
if (jobSummary[job.status] !== undefined) {
jobSummary[job.status]++;
}
}
}
res.json({
timestamp: new Date().toISOString(),
queues: stats,
jobs: jobSummary,
});
} catch (err) {
console.error('[Scheduler] GET /queues/stats error:', err);
res.status(500).json({ error: err.message });
}
});
// Error handling
app.use((err, req, res, next) => {
console.error('[Scheduler] Server error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// 404
app.use('*', (req, res) => {
res.status(404).json({ error: 'Endpoint not found' });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
async function start() {
// Ensure all consumer groups exist
await ensureWorkerGroups();
await ensureWorkerGroups(redis);
// Start listening for done events (background)
listenDoneQueue().catch((err) => {
console.error('[Scheduler] Done listener fatal error:', err);
process.exit(1);
});
// done queue listener背景
startListenDone({ redis, redisSub, jobService })
.start()
.catch((err) => {
console.error('[Scheduler] Done listener fatal error:', err);
process.exit(1);
});
// T8啟動 health background polling30s 一次,第一次立即觸發)
healthService.start();
// T8graceful shutdown — 收到 SIGTERM/SIGINT 時停 polling避免 process 卡住
const onShutdown = (signal) => {
console.log(`[Scheduler] Received ${signal}, stopping health polling`);
try {
healthService.stop();
} catch (err) {
console.error('[Scheduler] healthService.stop error:', err);
}
// 不在此 process.exit交由 Node 自然結束unref 過的 timer 不會擋 exit
};
process.once('SIGTERM', () => onShutdown('SIGTERM'));
process.once('SIGINT', () => onShutdown('SIGINT'));
app.listen(PORT, () => {
console.log(`[Scheduler] Running on port ${PORT}`);
console.log(`[Scheduler] Redis: ${REDIS_URL}`);
console.log(`[Scheduler] Job data dir: ${JOB_DATA_DIR}`);
console.log(`[Scheduler] Storage: ${STORAGE_BACKEND}${minio ? ` (${MINIO_ENDPOINT}/${MINIO_BUCKET})` : ''}`);
console.log(
`[Scheduler] Storage: ${STORAGE_BACKEND}${minio.client ? ` (${minio.endpoint}/${minio.bucket})` : ''}`
);
console.log(`[Scheduler] Stages: ${STAGES.join(' -> ')}`);
console.log(
`[Scheduler] Auth config OK: issuer=${config.memberCenter.issuer}, audience=${config.converter.audience}`
);
// T10印出 multipart / concurrency 配置,方便 ops 確認生效值(不含 secret
console.log(
`[Scheduler] Multipart limits: model=${config.multipart.modelMaxBytes}B, ` +
`ref_image=${config.multipart.refImageMaxBytes}B, ` +
`ref_images_count=${config.multipart.refImagesMaxCount}`
);
console.log(
`[Scheduler] Upload concurrency: max=${config.uploadConcurrency.maxConcurrent} ` +
`(503 retry-after=${config.uploadConcurrency.retryAfterSeconds}s when full)`
);
});
}

View File

@ -0,0 +1,205 @@
/**
* config.js 單元測試T10 D5multipart / uploadConcurrency env 串接
*
* 重點
* 1. 預設值正確不傳 env multipart / concurrency fallback 到合理預設
* 2. env override 真的會被讀取 stub process.env重新 require module
* 3. 非法值0 / 負數 throw
* 4. 既有必填 env 缺漏 fail-fast 不被本任務破壞
*
* 測試策略
* - 每個 case 進來前 backup process.env設好需要的變數呼叫 jest.resetModules()
* require('../config') 重新讀取 env結束後 restore env
* - 不依賴 .env 避免 dotenv 副作用干擾
*/
'use strict';
const ENV_KEYS_TO_BACKUP = [
// 必填(缺漏 throw— 測試前必須補齊
'MEMBER_CENTER_ISSUER',
'MEMBER_CENTER_JWKS_URL',
'MEMBER_CENTER_TOKEN_URL',
'KNERON_CONVERTER_AUDIENCE',
'KNERON_CONVERTER_CLIENT_ID',
'KNERON_CONVERTER_CLIENT_SECRET',
'FILE_ACCESS_AGENT_BASE_URL',
'FILE_ACCESS_AGENT_AUDIENCE',
// T10 新增
'MULTIPART_MODEL_MAX_BYTES',
'MULTIPART_REF_IMAGE_MAX_BYTES',
'MULTIPART_REF_IMAGES_MAX_COUNT',
'MAX_CONCURRENT_UPLOADS',
'UPLOAD_RETRY_AFTER_SECONDS',
// 其他 optional
'CONVERTER_TENANT_ID',
'CONVERTER_SCOPE_WRITE',
'CONVERTER_SCOPE_READ',
'PROMOTE_TIMEOUT_MS',
'NODE_ENV',
];
let backedUpEnv = {};
function backupEnv() {
backedUpEnv = {};
for (const k of ENV_KEYS_TO_BACKUP) {
backedUpEnv[k] = process.env[k];
delete process.env[k];
}
}
function restoreEnv() {
for (const k of ENV_KEYS_TO_BACKUP) {
if (backedUpEnv[k] === undefined) {
delete process.env[k];
} else {
process.env[k] = backedUpEnv[k];
}
}
}
function setMinimumValidEnv() {
// 滿足必填 — 用 .invalid placeholderDNS 不解析,安全)
process.env.MEMBER_CENTER_ISSUER = 'https://auth.test.invalid';
process.env.MEMBER_CENTER_JWKS_URL = 'https://auth.test.invalid/.well-known/jwks';
process.env.MEMBER_CENTER_TOKEN_URL = 'https://auth.test.invalid/oauth/token';
process.env.KNERON_CONVERTER_AUDIENCE = 'kneron_converter_api';
process.env.KNERON_CONVERTER_CLIENT_ID = 'kneron_converter_test';
process.env.KNERON_CONVERTER_CLIENT_SECRET = 'test-secret';
process.env.FILE_ACCESS_AGENT_BASE_URL = 'https://files.test.invalid';
process.env.FILE_ACCESS_AGENT_AUDIENCE = 'file_access_api';
}
function loadConfigFresh() {
// 確保拿到的是新 module不被 require cache 污染)
jest.resetModules();
// 不要讓 dotenv 蓋掉我們刻意設好的 env
const path = require.resolve('../config');
delete require.cache[path];
// dotenv 的 cache在 reset 後 require config 會再 require dotenv無 cache 影響)
return require('../config').loadConfig();
}
beforeEach(() => {
backupEnv();
setMinimumValidEnv();
});
afterEach(() => {
restoreEnv();
});
describe('config — multipart defaults', () => {
it('uses sane defaults when MULTIPART_* env not set', () => {
const cfg = loadConfigFresh();
expect(cfg.multipart.modelMaxBytes).toBe(500 * 1024 * 1024);
expect(cfg.multipart.refImageMaxBytes).toBe(10 * 1024 * 1024);
expect(cfg.multipart.refImagesMaxCount).toBe(100);
});
});
describe('config — multipart env overrides', () => {
it('reads MULTIPART_MODEL_MAX_BYTES from env', () => {
process.env.MULTIPART_MODEL_MAX_BYTES = String(200 * 1024 * 1024);
const cfg = loadConfigFresh();
expect(cfg.multipart.modelMaxBytes).toBe(200 * 1024 * 1024);
});
it('reads MULTIPART_REF_IMAGE_MAX_BYTES from env', () => {
process.env.MULTIPART_REF_IMAGE_MAX_BYTES = String(5 * 1024 * 1024);
const cfg = loadConfigFresh();
expect(cfg.multipart.refImageMaxBytes).toBe(5 * 1024 * 1024);
});
it('reads MULTIPART_REF_IMAGES_MAX_COUNT from env', () => {
process.env.MULTIPART_REF_IMAGES_MAX_COUNT = '50';
const cfg = loadConfigFresh();
expect(cfg.multipart.refImagesMaxCount).toBe(50);
});
it('throws when MULTIPART_MODEL_MAX_BYTES <= 0', () => {
process.env.MULTIPART_MODEL_MAX_BYTES = '0';
expect(() => loadConfigFresh()).toThrow(/MULTIPART_MODEL_MAX_BYTES/);
});
it('throws when MULTIPART_REF_IMAGE_MAX_BYTES <= 0', () => {
process.env.MULTIPART_REF_IMAGE_MAX_BYTES = '-1';
expect(() => loadConfigFresh()).toThrow(/MULTIPART_REF_IMAGE_MAX_BYTES/);
});
it('throws when MULTIPART_REF_IMAGES_MAX_COUNT <= 0', () => {
process.env.MULTIPART_REF_IMAGES_MAX_COUNT = '0';
expect(() => loadConfigFresh()).toThrow(/MULTIPART_REF_IMAGES_MAX_COUNT/);
});
it('throws when MULTIPART_MODEL_MAX_BYTES is not an integer', () => {
process.env.MULTIPART_MODEL_MAX_BYTES = 'not-a-number';
expect(() => loadConfigFresh()).toThrow(/integer/);
});
});
describe('config — uploadConcurrency defaults', () => {
it('uses sane defaults when MAX_CONCURRENT_UPLOADS env not set', () => {
const cfg = loadConfigFresh();
expect(cfg.uploadConcurrency.maxConcurrent).toBe(5);
expect(cfg.uploadConcurrency.retryAfterSeconds).toBe(30);
});
});
describe('config — uploadConcurrency env overrides', () => {
it('reads MAX_CONCURRENT_UPLOADS from env', () => {
process.env.MAX_CONCURRENT_UPLOADS = '10';
const cfg = loadConfigFresh();
expect(cfg.uploadConcurrency.maxConcurrent).toBe(10);
});
it('reads UPLOAD_RETRY_AFTER_SECONDS from env', () => {
process.env.UPLOAD_RETRY_AFTER_SECONDS = '60';
const cfg = loadConfigFresh();
expect(cfg.uploadConcurrency.retryAfterSeconds).toBe(60);
});
it('throws when MAX_CONCURRENT_UPLOADS <= 0', () => {
process.env.MAX_CONCURRENT_UPLOADS = '0';
expect(() => loadConfigFresh()).toThrow(/MAX_CONCURRENT_UPLOADS/);
});
it('throws when UPLOAD_RETRY_AFTER_SECONDS <= 0', () => {
process.env.UPLOAD_RETRY_AFTER_SECONDS = '-30';
expect(() => loadConfigFresh()).toThrow(/UPLOAD_RETRY_AFTER_SECONDS/);
});
});
describe('config — multipart object is frozen', () => {
it('does not allow mutation of multipart sub-object', () => {
const cfg = loadConfigFresh();
expect(() => {
cfg.multipart.modelMaxBytes = 999;
}).toThrow(TypeError);
});
it('does not allow mutation of uploadConcurrency sub-object', () => {
const cfg = loadConfigFresh();
expect(() => {
cfg.uploadConcurrency.maxConcurrent = 999;
}).toThrow(TypeError);
});
});
describe('config — fail fast on missing required env (regression check)', () => {
it('throws when MEMBER_CENTER_ISSUER missing', () => {
delete process.env.MEMBER_CENTER_ISSUER;
expect(() => loadConfigFresh()).toThrow(/MEMBER_CENTER_ISSUER/);
});
it('throws when KNERON_CONVERTER_CLIENT_SECRET missing', () => {
delete process.env.KNERON_CONVERTER_CLIENT_SECRET;
expect(() => loadConfigFresh()).toThrow(/KNERON_CONVERTER_CLIENT_SECRET/);
});
it('throws when FILE_ACCESS_AGENT_BASE_URL missing', () => {
delete process.env.FILE_ACCESS_AGENT_BASE_URL;
expect(() => loadConfigFresh()).toThrow(/FILE_ACCESS_AGENT_BASE_URL/);
});
});

View File

@ -0,0 +1,458 @@
/**
* Integration tests /health 升級T8
*
* 涵蓋場景
* 1. 預設 healthyRedis ready + MC/FAA reachable 200
* 2. Redis disconnectedstatus='connecting' unhealthy + 503
* 3. MC 不可達fetch reject degraded + 200
* 4. FAA 不可達fetch 5xx degraded + 200
* 5. 第一次啟動 cache 未填 MC/FAA = pending + degraded + 200
* 部署 readiness probe 仍視為可用
* 6. /health 永遠不阻塞fetch 卡住 30s /health 立即回 cached 結果
* 7. 向後相容response 仍含 service / timestamp / 頂層 redis 欄位
* 8. 不洩漏內部 endpoint URL
*
* 此測試起 app.listen(0) fetch 真打 HTTP legacy.integration.test.js 風格一致
*
* 命名約定
* - `httpFetch`= globalThis.fetch 用來打 testing server
* - `probeMock`jest.fn 注入給 healthService 探測 MC / FAA
* 兩者刻意分開避免 mock 把真實 HTTP 也攔截掉
*/
'use strict';
const httpFetch = globalThis.fetch;
const { createSseService } = require('../services/sseService');
const { createJobService } = require('../services/jobService');
const { createUploader } = require('../middleware/upload');
const { createHealthService } = require('../services/healthService');
const { createApp } = require('../app');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeFakeRedis(status = 'ready') {
const store = new Map();
return {
status,
store,
ping: jest.fn(async () => 'PONG'),
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
keys: jest.fn(async () => []),
xadd: jest.fn(async () => '1-0'),
xlen: jest.fn(async () => 0),
xinfo: jest.fn(async () => {
throw new Error('NOGROUP');
}),
};
}
function makeFakeMinio() {
return {
client: { _fake: true },
bucket: 'test-bucket',
endpoint: 'http://nope',
uploadToMinIO: jest.fn(async () => undefined),
getFromMinIO: jest.fn(async () => null),
};
}
/**
* 建立 healthService 用的探測 fetch mock
* 注意這個只給 healthService 不影響真實的 httpFetch
*/
function makeProbeMock(handlers = {}) {
return jest.fn(async (url, opts) => {
const handler = handlers[url];
if (!handler) return { status: 200, ok: true };
if (handler instanceof Error) throw handler;
if (typeof handler === 'function') return handler(url, opts);
return handler;
});
}
async function startApp(deps, opts) {
const app = createApp(deps, opts);
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
// 抑制 console 噪音
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GET /health — T8 upgrade', () => {
it('returns 200 healthy when all deps OK (Redis ready + MC/FAA reachable)', async () => {
const MC_URL = 'https://mc-test/.well-known/jwks';
const FAA_URL = 'https://faa-test/health';
const probeMock = makeProbeMock({
[MC_URL]: { status: 200, ok: true },
[FAA_URL]: { status: 200, ok: true },
});
const redis = makeFakeRedis('ready');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: MC_URL,
fileAccessAgentProbeUrl: FAA_URL,
fetch: probeMock,
probeTimeoutMs: 200,
});
await healthService._runOnce();
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual(
expect.objectContaining({
service: 'task-scheduler', // 向後相容
status: 'healthy',
timestamp: expect.any(String),
redis: 'connected', // 向後相容(頂層)
version: '1.0.0',
dependencies: {
redis: 'connected',
member_center: 'reachable',
file_access_agent: 'reachable',
},
})
);
} finally {
await ctx.close();
healthService.stop();
}
});
it('returns 503 unhealthy when Redis status is not ready', async () => {
const probeMock = makeProbeMock();
const redis = makeFakeRedis('connecting'); // 模擬連線中
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: 'https://mc/jwks',
fileAccessAgentProbeUrl: 'https://faa/health',
fetch: probeMock,
probeTimeoutMs: 200,
});
await healthService._runOnce();
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(503);
const body = await res.json();
expect(body.status).toBe('unhealthy');
expect(body.dependencies.redis).toBe('disconnected');
expect(body.redis).toBe('disconnected'); // 向後相容
} finally {
await ctx.close();
healthService.stop();
}
});
it('returns 200 degraded when Member Center fetch rejects', async () => {
const MC_URL = 'https://mc-bad/.well-known/jwks';
const FAA_URL = 'https://faa-good/health';
const probeMock = makeProbeMock({
[MC_URL]: new Error('ECONNREFUSED'),
[FAA_URL]: { status: 200, ok: true },
});
const redis = makeFakeRedis('ready');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: MC_URL,
fileAccessAgentProbeUrl: FAA_URL,
fetch: probeMock,
probeTimeoutMs: 200,
});
await healthService._runOnce();
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(200); // degraded 是 200
const body = await res.json();
expect(body.status).toBe('degraded');
expect(body.dependencies.member_center).toBe('unreachable');
expect(body.dependencies.file_access_agent).toBe('reachable');
expect(body.dependencies.redis).toBe('connected');
} finally {
await ctx.close();
healthService.stop();
}
});
it('returns 200 degraded when File Access Agent returns 5xx', async () => {
const MC_URL = 'https://mc-good/.well-known/jwks';
const FAA_URL = 'https://faa-bad/health';
const probeMock = makeProbeMock({
[MC_URL]: { status: 200, ok: true },
[FAA_URL]: { status: 503, ok: false },
});
const redis = makeFakeRedis('ready');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: MC_URL,
fileAccessAgentProbeUrl: FAA_URL,
fetch: probeMock,
probeTimeoutMs: 200,
});
await healthService._runOnce();
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('degraded');
expect(body.dependencies.file_access_agent).toBe('unreachable');
expect(body.dependencies.member_center).toBe('reachable');
} finally {
await ctx.close();
healthService.stop();
}
});
it('returns degraded with pending deps before first poll completes', async () => {
// 不呼叫 _runOnce模擬 process 剛啟動還沒拿到 first poll 結果
const redis = makeFakeRedis('ready');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: 'https://mc/jwks',
fileAccessAgentProbeUrl: 'https://faa/health',
fetch: makeProbeMock(), // 沒人會用
probeTimeoutMs: 200,
});
// ★ 故意不呼叫 healthService.start() / _runOnce(),模擬第一個 polling 完成前的狀態
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
// 部署 readiness仍回 200避免 Kubernetes 在啟動初期就把 pod 標 not ready
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('degraded');
expect(body.dependencies.member_center).toBe('pending');
expect(body.dependencies.file_access_agent).toBe('pending');
expect(body.dependencies.redis).toBe('connected');
} finally {
await ctx.close();
healthService.stop();
}
});
it('does NOT block: even when probes hang for seconds, /health responds immediately', async () => {
// 模擬 probe fetch 永遠不 resolve除非被 abort
const probeMock = jest.fn(
(_url, opts) =>
new Promise((_resolve, reject) => {
if (opts && opts.signal) {
opts.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}
})
);
const redis = makeFakeRedis('ready');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: 'https://mc-hang/jwks',
fileAccessAgentProbeUrl: 'https://faa-hang/health',
fetch: probeMock,
probeTimeoutMs: 30 * 1000, // 30s — 模擬「卡很久」
});
// ★ 啟動 polling 但不等它完成
healthService.start();
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const start = Date.now();
const res = await httpFetch(`${ctx.baseUrl}/health`);
const elapsed = Date.now() - start;
// 即使 background polling hang 住 30s/health 仍應 < 200ms 回應
expect(elapsed).toBeLessThan(200);
expect(res.status).toBe(200); // pending → degraded → 200
const body = await res.json();
expect(['pending', 'reachable', 'unreachable']).toContain(body.dependencies.member_center);
expect(['pending', 'reachable', 'unreachable']).toContain(
body.dependencies.file_access_agent
);
} finally {
await ctx.close();
// ★ 必須先 stop否則 background fetch 永不結束、process 就退不出去
healthService.stop();
}
});
it('does not leak internal endpoint URLs in response or logs', async () => {
const SECRET_MC = 'https://internal-mc-secret-host.example/.well-known/jwks';
const SECRET_FAA = 'https://internal-faa-secret-host.example:9876/health';
const probeMock = makeProbeMock({
[SECRET_MC]: { status: 503, ok: false },
[SECRET_FAA]: new Error('Connection refused to internal-faa-secret-host.example:9876'),
});
const redis = makeFakeRedis('ready');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const healthService = createHealthService({
redis,
memberCenterProbeUrl: SECRET_MC,
fileAccessAgentProbeUrl: SECRET_FAA,
fetch: probeMock,
probeTimeoutMs: 200,
});
await healthService._runOnce();
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
healthService,
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
const text = await res.text();
expect(text).not.toContain('internal-mc-secret-host');
expect(text).not.toContain('internal-faa-secret-host');
expect(text).not.toContain('9876');
} finally {
await ctx.close();
healthService.stop();
}
});
it('falls back to legacy Redis ping when healthService is not provided', async () => {
// 確保 backwards compatibilitydeps 沒帶 healthService 時,行為 = 既有 server.js
const redis = makeFakeRedis('ready');
redis.ping = jest.fn(async () => 'PONG');
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
// ★ 故意不傳 healthService
});
try {
const res = await httpFetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(200);
const body = await res.json();
// 舊格式(不含 dependencies / version 鍵)
expect(body).toMatchObject({
service: 'task-scheduler',
status: 'healthy',
redis: 'connected',
});
expect(redis.ping).toHaveBeenCalled();
} finally {
await ctx.close();
}
});
});

View File

@ -0,0 +1,650 @@
/**
* Legacy 路由整合測試T4 smoke test
*
* 目標 mock Redis + mock MinIO 啟動實際的 Express app逐一打 7 legacy
* 端點驗證行為與 server.js 既有版本對齊
* - GET /healthhealthy + Redis fail 時的 503
* - POST /jobsmultipartdriver MinIO mode
* - GET /jobs/:jobId找到 / 不存在
* - GET /jobslist
* - GET /jobs/:jobId/eventsSSE 觀察 headers + initial payload
* - GET /jobs/:jobId/download/:filenameMinIO mode
* - GET /queues/stats
*
* 不打真 Redis / MinIO fake objects 注入
*
* 此測試的設計風格與 T1 middleware Integration 區塊一致 app.listen(0)
* fetch() 真打 HTTP
*/
'use strict';
const http = require('http');
const { createSseService } = require('../services/sseService');
const { createJobService } = require('../services/jobService');
const { createUploader } = require('../middleware/upload');
const { createApp } = require('../app');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeFakeRedis() {
const store = new Map();
const xaddCalls = [];
return {
store,
xaddCalls,
pingFails: false,
keysImpl: null, // optional override
ping: jest.fn(async function () {
if (this.pingFails) throw new Error('ping failed');
return 'PONG';
}),
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
keys: jest.fn(async (pattern) => {
const re = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
return [...store.keys()].filter((k) => re.test(k));
}),
xadd: jest.fn(async (queue, _id, _f, value) => {
xaddCalls.push([queue, value]);
return '1-0';
}),
xlen: jest.fn(async () => 0),
xinfo: jest.fn(async () => {
// 模擬 group 不存在 — 拋錯讓 legacy 走 catch
throw new Error('NOGROUP');
}),
};
}
function makeFakeMinio({ mode = 'minio', getObjectImpl } = {}) {
const uploaded = [];
if (mode !== 'minio') {
return {
client: null,
bucket: 'test-bucket',
endpoint: 'http://nope',
uploadToMinIO: jest.fn(async () => undefined),
getFromMinIO: jest.fn(async () => null),
_uploaded: uploaded,
};
}
return {
client: { _fake: true }, // truthy
bucket: 'test-bucket',
endpoint: 'http://localhost:9999',
uploadToMinIO: jest.fn(async (key, body, contentType) => {
uploaded.push({ key, size: body.length, contentType });
}),
getFromMinIO: jest.fn(getObjectImpl || (async () => null)),
_uploaded: uploaded,
};
}
async function startApp(deps, opts) {
const app = createApp(deps, opts);
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${port}`,
close: () =>
new Promise((r) => {
server.close(() => r());
}),
});
});
});
}
// 抑制 console.log 雜訊
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
describe('legacy /health', () => {
it('returns 200 healthy when Redis ping succeeds', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
const uploader = createUploader();
const ctx = await startApp(
{ redis, jobService, sseService, minio, uploader },
{ frontendUrl: 'http://localhost:3000' }
);
try {
const res = await fetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
service: 'task-scheduler',
status: 'healthy',
redis: 'connected',
});
expect(typeof body.timestamp).toBe('string');
} finally {
await ctx.close();
}
});
it('returns 503 unhealthy when Redis ping throws', async () => {
const redis = makeFakeRedis();
redis.pingFails = true;
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(503);
const body = await res.json();
expect(body).toMatchObject({
service: 'task-scheduler',
status: 'unhealthy',
redis: 'disconnected',
});
} finally {
await ctx.close();
}
});
});
describe('legacy POST /jobs (MinIO mode)', () => {
it('rejects when required fields are missing', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
// 缺 model_id, version, platform — 但仍要 multipart否則會直接被
// multer 跳過進到 handler
const fd = new FormData();
fd.append('model', new Blob(['fake-onnx'], { type: 'application/octet-stream' }), 'm.onnx');
const res = await fetch(`${ctx.baseUrl}/jobs`, {
method: 'POST',
body: fd,
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/model_id, version, platform/);
} finally {
await ctx.close();
}
});
it('rejects when model file is missing', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const fd = new FormData();
fd.append('model_id', '1001');
fd.append('version', '0001');
fd.append('platform', '520');
const res = await fetch(`${ctx.baseUrl}/jobs`, { method: 'POST', body: fd });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/model file is required/);
} finally {
await ctx.close();
}
});
it('returns 201 + writes job to Redis + uploads to MinIO + enqueues onnx', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio({ mode: 'minio' });
const sseService = createSseService();
const jobService = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const fd = new FormData();
fd.append(
'model',
new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'application/octet-stream' }),
'mymodel.onnx'
);
fd.append('model_id', '1001');
fd.append('version', '0001');
fd.append('platform', '520');
fd.append('enable_evaluate', 'true');
// 一張 ref image
fd.append(
'ref_images',
new Blob([new Uint8Array([9, 9])], { type: 'image/jpeg' }),
'ref0.jpg'
);
const res = await fetch(`${ctx.baseUrl}/jobs`, { method: 'POST', body: fd });
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
status: 'ONNX',
message: 'Job created and queued',
});
expect(typeof body.job_id).toBe('string');
expect(body.job_id).toMatch(/^[0-9a-f-]{36}$/i);
// Redis 上應該有 job 記錄
const stored = JSON.parse(redis.store.get(`job:${body.job_id}`));
expect(stored).toMatchObject({
job_id: body.job_id,
status: 'ONNX',
stage: 'onnx',
progress: 0,
parameters: {
model_id: 1001,
version: '0001',
platform: '520',
enable_evaluate: true,
enable_sim_fp: false,
enable_sim_fixed: false,
enable_sim_hw: false,
},
output: { bie_path: null, nef_path: null },
error: null,
});
expect(stored.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(stored.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// MinIO 應有 2 次上傳model + ref0
expect(minio._uploaded.length).toBe(2);
const keys = minio._uploaded.map((u) => u.key);
expect(keys).toContain(`jobs/${body.job_id}/input/mymodel.onnx`);
expect(keys).toContain(`jobs/${body.job_id}/input/ref_images/ref0.jpg`);
// 已 enqueue 到 queue:onnx
expect(redis.xaddCalls.length).toBe(1);
expect(redis.xaddCalls[0][0]).toBe('queue:onnx');
const msg = JSON.parse(redis.xaddCalls[0][1]);
expect(msg.job_id).toBe(body.job_id);
expect(msg.parameters).toEqual(stored.parameters);
} finally {
await ctx.close();
}
});
});
describe('legacy GET /jobs/:jobId', () => {
it('returns the job when it exists', async () => {
const redis = makeFakeRedis();
redis.store.set(
'job:abc',
JSON.stringify({ job_id: 'abc', status: 'ONNX', stage: 'onnx' })
);
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs/abc`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ job_id: 'abc', status: 'ONNX', stage: 'onnx' });
} finally {
await ctx.close();
}
});
it('returns 404 JOB_NOT_FOUND when missing', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs/nonexistent`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({ error: 'JOB_NOT_FOUND' });
} finally {
await ctx.close();
}
});
});
describe('legacy GET /jobs (list)', () => {
it('returns all jobs sorted by created_at desc', async () => {
const redis = makeFakeRedis();
redis.store.set(
'job:a',
JSON.stringify({ job_id: 'a', created_at: '2026-04-25T00:00:00Z' })
);
redis.store.set(
'job:b',
JSON.stringify({ job_id: 'b', created_at: '2026-04-26T00:00:00Z' })
);
redis.store.set(
'job:c',
JSON.stringify({ job_id: 'c', created_at: '2026-04-24T00:00:00Z' })
);
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.map((j) => j.job_id)).toEqual(['b', 'a', 'c']);
} finally {
await ctx.close();
}
});
});
describe('legacy GET /jobs/:jobId/events (SSE)', () => {
it('returns 404 when job does not exist', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs/missing/events`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({ error: 'JOB_NOT_FOUND' });
} finally {
await ctx.close();
}
});
it('streams SSE headers and initial state on existing job', async () => {
const redis = makeFakeRedis();
redis.store.set(
'job:s',
JSON.stringify({ job_id: 's', status: 'ONNX', stage: 'onnx' })
);
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
// 用低階 http 比 fetch 易於控制abort 後關閉連線觸發 close
const headers = await new Promise((resolve, reject) => {
const url = new URL(`${ctx.baseUrl}/jobs/s/events`);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
},
(res) => {
let firstChunk = '';
res.on('data', (chunk) => {
firstChunk += chunk.toString();
if (firstChunk.includes('\n\n')) {
resolve({ statusCode: res.statusCode, headers: res.headers, firstChunk });
req.destroy();
}
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
expect(headers.statusCode).toBe(200);
expect(headers.headers['content-type']).toMatch(/text\/event-stream/);
expect(headers.headers['cache-control']).toMatch(/no-cache/);
expect(headers.firstChunk.startsWith('data: ')).toBe(true);
const json = JSON.parse(headers.firstChunk.slice(6, headers.firstChunk.indexOf('\n\n')));
expect(json).toMatchObject({ job_id: 's', status: 'ONNX' });
} finally {
await ctx.close();
}
});
});
describe('legacy GET /jobs/:jobId/download/:filename (MinIO mode)', () => {
it('returns 404 when job is missing', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs/missing/download/file.bin`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({ error: 'JOB_NOT_FOUND' });
} finally {
await ctx.close();
}
});
it('serves file body when MinIO returns content', async () => {
const redis = makeFakeRedis();
redis.store.set('job:x', JSON.stringify({ job_id: 'x', status: 'COMPLETED' }));
// legacy code 把 minioKey 拼成 `jobs/${jobId}/${filename}`(單段 filename
// 因 Express path pattern :filename 不允許斜線。本測試對齊此既有限制。
const minio = makeFakeMinio({
mode: 'minio',
getObjectImpl: async (key) => {
if (key === 'jobs/x/out.nef') {
return { body: Buffer.from('FAKE_NEF_BYTES'), contentLength: 14 };
}
return null;
},
});
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs/x/download/out.nef`);
expect(res.status).toBe(200);
expect(res.headers.get('content-disposition')).toMatch(/attachment/);
expect(res.headers.get('content-length')).toBe('14');
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.toString()).toBe('FAKE_NEF_BYTES');
} finally {
await ctx.close();
}
});
it('returns 404 FILE_NOT_FOUND when MinIO returns null', async () => {
const redis = makeFakeRedis();
redis.store.set('job:y', JSON.stringify({ job_id: 'y' }));
const minio = makeFakeMinio({
mode: 'minio',
getObjectImpl: async () => null,
});
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/jobs/y/download/missing.bin`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({ error: 'FILE_NOT_FOUND' });
} finally {
await ctx.close();
}
});
});
describe('legacy GET /queues/stats', () => {
it('returns shape with timestamp / queues / jobs summary', async () => {
const redis = makeFakeRedis();
redis.store.set(
'job:x',
JSON.stringify({ job_id: 'x', status: 'ONNX' })
);
redis.store.set(
'job:y',
JSON.stringify({ job_id: 'y', status: 'COMPLETED' })
);
redis.store.set(
'job:z',
JSON.stringify({ job_id: 'z', status: 'FAILED' })
);
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/queues/stats`);
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.timestamp).toBe('string');
expect(body.queues).toEqual({
'queue:onnx': { length: 0, pending: 0, lag: 0, consumers: [] },
'queue:bie': { length: 0, pending: 0, lag: 0, consumers: [] },
'queue:nef': { length: 0, pending: 0, lag: 0, consumers: [] },
'queue:done': { length: 0, pending: 0, lag: 0, consumers: [] },
});
expect(body.jobs).toEqual({
total: 3,
ONNX: 1,
BIE: 0,
NEF: 0,
COMPLETED: 1,
FAILED: 1,
});
} finally {
await ctx.close();
}
});
});
describe('app — 404 handling', () => {
it('returns 404 with legacy error shape', async () => {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService });
const ctx = await startApp({
redis,
jobService,
sseService,
minio,
uploader: createUploader(),
});
try {
const res = await fetch(`${ctx.baseUrl}/no/such/path`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({ error: 'Endpoint not found' });
} finally {
await ctx.close();
}
});
});

View File

@ -0,0 +1,83 @@
/**
* src/redis.js 單元測試T4
*
* 重點
* 1. ensureConsumerGroupBUSYGROUP 視為正常其他 error rethrow
* 2. getDefaultRedisUrl process.env.REDIS_URL 缺時 fallback
* 3. attachErrorLoggererror event 會印 console.error label
*
* 不測 createClients因為它真的會嘗試連線 ioredis其行為簡單依賴測試會
* 在整合層或啟動時驗證
*/
'use strict';
const { ensureConsumerGroup, _internals } = require('../redis');
describe('redis._internals.getDefaultRedisUrl', () => {
const orig = process.env.REDIS_URL;
afterEach(() => {
if (orig === undefined) delete process.env.REDIS_URL;
else process.env.REDIS_URL = orig;
});
it('uses process.env.REDIS_URL when set', () => {
process.env.REDIS_URL = 'redis://example:6379/0';
expect(_internals.getDefaultRedisUrl()).toBe('redis://example:6379/0');
});
it('falls back to redis://localhost:6379 when env is missing', () => {
delete process.env.REDIS_URL;
expect(_internals.getDefaultRedisUrl()).toBe('redis://localhost:6379');
});
});
describe('redis._internals.attachErrorLogger', () => {
it('logs error event via console.error with label prefix', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const fakeClient = {
handlers: {},
on(event, fn) {
this.handlers[event] = fn;
},
};
_internals.attachErrorLogger(fakeClient, 'TEST');
const err = new Error('boom');
fakeClient.handlers.error(err);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toBe('TEST:');
expect(spy.mock.calls[0][1]).toBe(err);
spy.mockRestore();
});
});
describe('ensureConsumerGroup', () => {
it('calls XGROUP CREATE with MKSTREAM', async () => {
const xgroup = jest.fn(async () => 'OK');
await ensureConsumerGroup({ xgroup }, 'queue:test', 'group-A');
expect(xgroup).toHaveBeenCalledWith('CREATE', 'queue:test', 'group-A', '0', 'MKSTREAM');
});
it('swallows BUSYGROUP error (group already exists)', async () => {
const xgroup = jest.fn(async () => {
const e = new Error('BUSYGROUP Consumer Group name already exists');
throw e;
});
await expect(
ensureConsumerGroup({ xgroup }, 'q', 'g')
).resolves.toBeUndefined();
expect(xgroup).toHaveBeenCalledTimes(1);
});
it('rethrows other errors', async () => {
const xgroup = jest.fn(async () => {
throw new Error('connection refused');
});
await expect(
ensureConsumerGroup({ xgroup }, 'q', 'g')
).rejects.toThrow(/connection refused/);
});
});

View File

@ -0,0 +1,173 @@
/**
* Express app 組裝T4 重構自 server.js L105-126L609-618
*
* 職責
* 1. 套用 middlewarehelmet / requestId / compression / morgan / cors / json
* 2. 套用 rate limiter legacy 相同作用於 `/api`
* 3. mount /api/v1/* 路由T3
* 4. mount legacy 路由
* 5. 全域 error handler 404
*
* 行為對齊重構不改行為
* - middleware 順序與 server.js L107-120 完全一致除新增 requestId
* - rate limiter 配置windowMs: 15min, max: 200, message: ...對齊 L112-117
* - cors origin 仍從 process.env.FRONTEND_URL fallback `http://localhost:3000`
* - express.json / urlencoded 上限 10mbL119-120
*
* T3 新增
* - requestId middleware **全域掛**legacy + v1 都會有 req.requestId D4 修復必要
* - v1 router 掛在 `/api/v1`含內部 errorHandler 提供 v1 錯誤格式不影響 legacy
*
* 設計取捨
* - factory `createApp(deps)`deps 帶入 redis / jobService / sseService /
* minio / uploader 本檔不直接 require 任何 service module
* - v1 router T3 階段是純骨架501 端點認證 / rate limit 留到 T5/T6/T7
*/
'use strict';
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const morgan = require('morgan');
const compression = require('compression');
const { createLegacyRouter } = require('./routes/legacy');
const { createV1Router } = require('./routes/v1');
const { requestIdMiddleware } = require('./middleware/requestId');
const {
createUploadConcurrencyLimiter,
} = require('./middleware/uploadConcurrency');
const { createFaaClient } = require('./fileAccessAgent/client');
const oauthClient = require('./auth/oauthClient');
/**
* @param {object} deps
* @param {import('ioredis').Redis} deps.redis
* @param {ReturnType<typeof import('./services/jobService').createJobService>} deps.jobService
* @param {{ sendSSE: Function, registerSseClient: Function }} deps.sseService
* @param {ReturnType<typeof import('./storage/minio').createMinioFacade>} deps.minio
* @param {import('multer').Multer} deps.uploader
* @param {ReturnType<typeof import('./services/healthService').createHealthService>} [deps.healthService]
* T8選填若提供則 /health background-cached snapshot若缺漏則退回 Redis ping
* @param {object} [opts]
* @param {string} [opts.frontendUrl] - CORS origin
* @returns {import('express').Express}
*/
function createApp(deps, opts) {
const frontendUrl = (opts && opts.frontendUrl) || process.env.FRONTEND_URL || 'http://localhost:3000';
const app = express();
app.use(helmet());
// T3requestId 必須早於所有需要 log 或回 error response 的 middleware
// 確保 morgan / errorHandler / requireAuth 都能拿到 req.requestId。
app.use(requestIdMiddleware);
app.use(compression());
app.use(morgan('short'));
app.use(cors({ origin: frontendUrl, credentials: true }));
// 既有 rate limiter — 與 server.js L112-117 完全一致(作用於 /api 前綴)
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
message: 'Too many requests, please try again later.',
});
app.use('/api', limiter);
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// T5v1 router 需要 jobService / uploader / config 等 dep若 deps 缺漏(單元
// 測試常見情境handler 會自動 fallback 到 501不會 Crash。
// 為什麼從 deps 透傳:
// - 保持 server entry → app → router 的注入鏈,避免 router 內部直接 require
// config會在測試中需要設環境變數
// - opts.config / opts.rateLimit / opts.storageBackend 給整合測試覆寫用
//
// T7promote 需要 faaClient若呼叫端傳了 deps.faaClient測試 mock就用
// 否則只有當 opts.config 存在時才 lazy-build singleton避免測試/缺config時 Crash
// 透傳 timeoutMs優先用 config.fileAccessAgent.promoteTimeoutMsloadConfig 已從
// env PROMOTE_TIMEOUT_MS 讀取,預設 300s缺漏時 fallback 讀 process.env 再 client 的 default。
let faaClient = deps.faaClient;
if (!faaClient && opts && opts.config && opts.config.fileAccessAgent && opts.config.fileAccessAgent.baseUrl) {
const cfgTimeoutMs =
typeof opts.config.fileAccessAgent.promoteTimeoutMs === 'number'
? opts.config.fileAccessAgent.promoteTimeoutMs
: null;
const envTimeoutRaw = process.env.PROMOTE_TIMEOUT_MS;
const envTimeoutMs =
envTimeoutRaw && /^\d+$/.test(envTimeoutRaw) && Number.parseInt(envTimeoutRaw, 10) > 0
? Number.parseInt(envTimeoutRaw, 10)
: null;
const effectiveTimeoutMs = cfgTimeoutMs || envTimeoutMs || undefined;
faaClient = createFaaClient({
oauthClient,
config: { baseUrl: opts.config.fileAccessAgent.baseUrl },
...(effectiveTimeoutMs ? { timeoutMs: effectiveTimeoutMs } : {}),
});
}
// T10建立 upload concurrency limiterper-process semaphore防 OOM
// 從 opts.config.uploadConcurrency 取上限;缺漏時用 limiter 的內建預設
// 為什麼建在這裡app 是 instance scope不該把 limiter 的 in-process state
// 拉到模組 top-level測試會互相污染
let uploadConcurrencyLimiter = null;
if (opts && opts.config && opts.config.uploadConcurrency) {
const ucCfg = opts.config.uploadConcurrency;
const lim = createUploadConcurrencyLimiter({
maxConcurrent: ucCfg.maxConcurrent,
retryAfterSeconds: ucCfg.retryAfterSeconds,
});
uploadConcurrencyLimiter = lim.middleware;
} else if (
opts &&
typeof opts.uploadConcurrency === 'object' &&
opts.uploadConcurrency
) {
// 測試友善:允許 opts 直接覆寫 concurrency 設定(不需完整 config
const lim = createUploadConcurrencyLimiter(opts.uploadConcurrency);
uploadConcurrencyLimiter = lim.middleware;
}
const v1Deps = {
jobService: deps.jobService,
uploader: deps.uploader,
minio: deps.minio,
faaClient: faaClient || null,
config: opts && opts.config ? opts.config : undefined,
rateLimit: opts && opts.rateLimit ? opts.rateLimit : undefined,
storageBackend:
opts && opts.storageBackend
? opts.storageBackend
: process.env.STORAGE_BACKEND || 'local',
uploadConcurrencyLimiter,
};
// T3mount /api/v1 路由 — **必須**在 legacy `/` 之前,避免被 legacy 的
// 全域 catch-all雖然 legacy 沒有 catch-all但保持「specific before generic」原則
const v1Router = createV1Router(v1Deps);
app.use('/api/v1', v1Router);
// mount legacy 路由(含 /health, /jobs, /queues/stats
// T8deps.healthService 經由 createLegacyRouter 透傳給 /health handler
const legacyRouter = createLegacyRouter(deps);
app.use('/', legacyRouter);
// 全域 error handler — 對齊 server.js L610-613
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
// eslint-disable-next-line no-console
console.error('[Scheduler] Server error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// 404 — 對齊 server.js L615-618
app.use('*', (req, res) => {
res.status(404).json({ error: 'Endpoint not found' });
});
return app;
}
module.exports = { createApp };

View File

@ -0,0 +1,285 @@
/**
* Unit + Integration tests for src/auth/jwks.js
*
* 測試策略
* - jose Node CJS 環境下用 node:http / node:https 直接抓 JWKS不走 global.fetch
* 所以這份測試啟動一個本機 http server 提供 JWKS endpoint再讓 jose 真實抓取
* - 涵蓋正常驗證過期issuer audience 簽章錯 tokenalg=none 等情境
* - 驗證 RemoteJWKSet 的模組層級 cache 命中_resetForTests
*/
'use strict';
const http = require('http');
const { generateKeyPair, exportJWK, SignJWT } = require('jose');
const jwksModule = require('../jwks');
const TEST_ISSUER = 'https://auth.test.local';
const TEST_AUDIENCE = 'kneron_converter_api';
/**
* 啟動一個本機 http server提供 GET /.well-known/jwks JWK Set
*
* @param {Array<object>} jwks - JWK 陣列 kid / alg / use
* @returns {Promise<{server: import('http').Server, url: string}>}
*/
async function startJwksServer(jwks) {
const server = http.createServer((req, res) => {
if (req.url === '/.well-known/jwks') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ keys: jwks }));
return;
}
res.writeHead(404);
res.end();
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const addr = server.address();
return { server, url: `http://127.0.0.1:${addr.port}/.well-known/jwks` };
}
async function signTestJwt(privateKey, kid, payload, expirationTime) {
const now = Math.floor(Date.now() / 1000);
const exp = expirationTime !== undefined ? expirationTime : now + 300;
return new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256', kid })
.setIssuedAt(now)
.setExpirationTime(exp)
.setIssuer(TEST_ISSUER)
.setAudience(TEST_AUDIENCE)
.sign(privateKey);
}
describe('src/auth/jwks', () => {
let privateKey;
let publicJwk;
const KID = 'test-key-1';
let jwksServer;
let jwksUrl;
beforeAll(async () => {
const { privateKey: priv, publicKey: pub } = await generateKeyPair('RS256', {
modulusLength: 2048,
});
privateKey = priv;
publicJwk = await exportJWK(pub);
const started = await startJwksServer([
{ ...publicJwk, kid: KID, use: 'sig', alg: 'RS256' },
]);
jwksServer = started.server;
jwksUrl = started.url;
});
afterAll(async () => {
if (jwksServer) {
await new Promise((resolve) => jwksServer.close(resolve));
}
});
beforeEach(() => {
// 每次測試重置模組層級 cache避免互相影響
jwksModule._resetForTests();
});
describe('getJWKS', () => {
it('should require jwksUrl', () => {
expect(() => jwksModule.getJWKS('')).toThrow(/jwksUrl is required/);
expect(() => jwksModule.getJWKS(null)).toThrow(/jwksUrl is required/);
});
it('should throw on invalid URL', () => {
expect(() => jwksModule.getJWKS('not-a-url')).toThrow(/Invalid JWKS URL/);
});
it('should return the same instance for the same URL (module-level cache)', () => {
const a = jwksModule.getJWKS(jwksUrl);
const b = jwksModule.getJWKS(jwksUrl);
expect(a).toBe(b);
});
it('should return different instances for different URLs', () => {
const a = jwksModule.getJWKS(jwksUrl);
const b = jwksModule.getJWKS('http://127.0.0.1:1/other-jwks');
expect(a).not.toBe(b);
});
});
describe('verifyToken', () => {
let baseOpts;
beforeAll(() => {
baseOpts = {
jwksUrl,
issuer: TEST_ISSUER,
audience: TEST_AUDIENCE,
clockToleranceSec: 60,
};
});
it('should verify a valid token', async () => {
const token = await signTestJwt(privateKey, KID, {
sub: 'user-1',
client_id: 'client-1',
scope: 'converter:job.write',
});
const result = await jwksModule.verifyToken(token, baseOpts);
expect(result).toBeDefined();
expect(result.payload.sub).toBe('user-1');
expect(result.payload.client_id).toBe('client-1');
expect(result.payload.scope).toBe('converter:job.write');
});
it('should throw ERR_JWT_EXPIRED for expired token', async () => {
// 過期 1 小時,超過 clockTolerance60 秒)
const expired = Math.floor(Date.now() / 1000) - 3600;
const token = await signTestJwt(
privateKey,
KID,
{ sub: 'user-1', scope: 'converter:job.write' },
expired
);
await expect(jwksModule.verifyToken(token, baseOpts)).rejects.toMatchObject({
code: 'ERR_JWT_EXPIRED',
});
});
it('should throw on wrong issuer', async () => {
const token = await new SignJWT({ sub: 'user-1', scope: 'converter:job.write' })
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuedAt()
.setExpirationTime('5m')
.setIssuer('https://wrong.issuer.example')
.setAudience(TEST_AUDIENCE)
.sign(privateKey);
await expect(jwksModule.verifyToken(token, baseOpts)).rejects.toThrow();
});
it('should throw on wrong audience', async () => {
const token = await new SignJWT({ sub: 'user-1', scope: 'converter:job.write' })
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuedAt()
.setExpirationTime('5m')
.setIssuer(TEST_ISSUER)
.setAudience('wrong-audience')
.sign(privateKey);
await expect(jwksModule.verifyToken(token, baseOpts)).rejects.toThrow();
});
it('should throw on signature mismatch (different signing key, same kid)', async () => {
const { privateKey: otherPriv } = await generateKeyPair('RS256', {
modulusLength: 2048,
});
const token = await signTestJwt(otherPriv, KID, {
sub: 'user-1',
scope: 'converter:job.write',
});
await expect(jwksModule.verifyToken(token, baseOpts)).rejects.toThrow();
});
it('should throw on missing kid (no matching key in JWKS)', async () => {
const token = await signTestJwt(privateKey, 'unknown-kid', {
sub: 'user-1',
scope: 'converter:job.write',
});
await expect(jwksModule.verifyToken(token, baseOpts)).rejects.toThrow();
});
it('should reject empty token', async () => {
await expect(jwksModule.verifyToken('', baseOpts)).rejects.toMatchObject({
code: 'ERR_JWS_INVALID',
});
});
it('should reject malformed token (not a JWT)', async () => {
await expect(
jwksModule.verifyToken('not-a-real-jwt', baseOpts)
).rejects.toThrow();
});
it('should require options.issuer', async () => {
await expect(
jwksModule.verifyToken('x.y.z', { jwksUrl, audience: TEST_AUDIENCE })
).rejects.toThrow(/issuer is required/);
});
it('should require options.audience', async () => {
await expect(
jwksModule.verifyToken('x.y.z', { jwksUrl, issuer: TEST_ISSUER })
).rejects.toThrow(/audience is required/);
});
it('should reject alg=none token', async () => {
const header = Buffer.from(JSON.stringify({ alg: 'none', kid: KID })).toString(
'base64url'
);
const payload = Buffer.from(
JSON.stringify({
sub: 'user-1',
iss: TEST_ISSUER,
aud: TEST_AUDIENCE,
exp: Math.floor(Date.now() / 1000) + 300,
scope: 'converter:job.write',
})
).toString('base64url');
const unsignedToken = `${header}.${payload}.`;
await expect(jwksModule.verifyToken(unsignedToken, baseOpts)).rejects.toThrow();
});
// Sec m3HMAC 演算法應被拒絕(混淆攻擊防禦)
it('should reject HMAC alg=HS256 token (Sec m3 algorithms pin)', async () => {
// 即便 attacker 用 JWKS 的 RSA public key 當 HMAC secret 簽 token
// 因為 algorithms pin 為 RSA/ECDSAjose 會直接 reject 拋錯。
const fakeSecret = new TextEncoder().encode('fake-hmac-secret-32-bytes-long-x');
const token = await new SignJWT({
sub: 'user-1',
scope: 'converter:job.write',
})
.setProtectedHeader({ alg: 'HS256', kid: KID })
.setIssuedAt()
.setExpirationTime('5m')
.setIssuer(TEST_ISSUER)
.setAudience(TEST_AUDIENCE)
.sign(fakeSecret);
await expect(
jwksModule.verifyToken(token, baseOpts)
).rejects.toThrow();
});
it('should expose ALLOWED_JWT_ALGS list (Sec m3)', () => {
const algs = jwksModule.ALLOWED_JWT_ALGS;
expect(Array.isArray(algs)).toBe(true);
expect(algs).toContain('RS256');
expect(algs).toContain('ES256');
expect(algs).toContain('PS256');
expect(algs).not.toContain('HS256');
expect(algs).not.toContain('none');
});
it('should accept token within clock skew tolerance', async () => {
// 設一個剛過期 30 秒的 token但 clockTolerance = 60 秒應該還能通過
const justExpired = Math.floor(Date.now() / 1000) - 30;
const token = await new SignJWT({
sub: 'user-1',
scope: 'converter:job.write',
})
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuedAt(justExpired - 600)
.setExpirationTime(justExpired)
.setIssuer(TEST_ISSUER)
.setAudience(TEST_AUDIENCE)
.sign(privateKey);
const result = await jwksModule.verifyToken(token, baseOpts);
expect(result.payload.sub).toBe('user-1');
});
});
});

View File

@ -0,0 +1,763 @@
/**
* Unit + Integration tests for src/auth/middleware.js
*
* 測試重點
* 1. 各種驗證失敗路徑 header / 簽章錯 / issuer / audience / 過期 / scope 不夠 / tenant 不符
* 2. M2每次失敗都必須
* - `Connection: close` header
* - res 'finish' destroy req.socket
* 3. 成功路徑req.auth 設好 + next() 被呼叫
* 4. Integration supertest + Express 真打一次確認 socket 真的被斷
*/
'use strict';
const express = require('express');
const http = require('http');
const { generateKeyPair, exportJWK, SignJWT } = require('jose');
// 注意:這份 test 用 jest.resetModules + 注入版 verify不依賴真實 config
const middlewareModule = require('../middleware');
// ----------------------------------------------------------------------------
// 共用 fixture
// ----------------------------------------------------------------------------
const TEST_CONFIG = {
memberCenter: {
issuer: 'https://auth.test.local',
jwksUrl: 'https://auth.test.local/.well-known/jwks',
tokenUrl: '',
},
converter: {
audience: 'kneron_converter_api',
clientId: '',
clientSecret: '',
tenantId: '',
scopeWrite: 'converter:job.write',
scopeRead: 'converter:job.read',
},
fileAccessAgent: { baseUrl: '', audience: 'file_access_api' },
jwks: { cacheMaxAgeMs: 600000, cooldownMs: 30000, clockToleranceSec: 60 },
};
const TEST_CONFIG_WITH_TENANT = {
...TEST_CONFIG,
converter: { ...TEST_CONFIG.converter, tenantId: 'tenant-A' },
};
let privateKey;
let publicJwk;
const KID = 'test-key-1';
beforeAll(async () => {
const { privateKey: priv, publicKey: pub } = await generateKeyPair('RS256', {
modulusLength: 2048,
});
privateKey = priv;
publicJwk = await exportJWK(pub);
});
// 抑制驗證失敗時 middleware 的 warn log避免測試輸出被結構化 log 蓋掉)
// 這些 warn 是「驗證失敗時必輸出」的正常行為,已由斷言驗證 status / code
// log 內容不是斷言對象。
let _origWarn;
beforeAll(() => {
_origWarn = console.warn;
console.warn = () => {};
});
afterAll(() => {
console.warn = _origWarn;
});
/**
* 簽一個測試 JWT
*/
async function makeToken(overrides = {}, opts = {}) {
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: 'user-1',
client_id: 'client-1',
scope: 'converter:job.write',
...overrides,
};
const expirationTime =
opts.expirationTime !== undefined ? opts.expirationTime : now + 300;
const signKey = opts.signKey || privateKey;
const kid = opts.kid || KID;
const issuer = opts.issuer || TEST_CONFIG.memberCenter.issuer;
const audience = opts.audience || TEST_CONFIG.converter.audience;
return new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256', kid })
.setIssuedAt(now)
.setExpirationTime(expirationTime)
.setIssuer(issuer)
.setAudience(audience)
.sign(signKey);
}
/**
* 假的 verify function注入版 直接用 jose.jwtVerify 但不打網路
* 用內建的 JWKSet ( publicJwk )
*/
function makeInjectedVerify() {
// 動態 import jwtVerify 與 createLocalJWKSetjose v5+
const { jwtVerify, createLocalJWKSet } = require('jose');
const localJwks = createLocalJWKSet({
keys: [{ ...publicJwk, kid: KID, use: 'sig', alg: 'RS256' }],
});
return async function injectedVerify(token, options) {
return jwtVerify(token, localJwks, {
issuer: options.issuer,
audience: options.audience,
clockTolerance: options.clockToleranceSec,
});
};
}
/**
* 建立一組假的 req / res / next內含 spy socket.destroy
*/
function makeReqResNext(authHeader) {
const socket = {
destroyed: false,
destroy: jest.fn(function destroyImpl() {
socket.destroyed = true;
}),
};
const req = {
headers: authHeader === undefined ? {} : { authorization: authHeader },
socket,
requestId: 'req-test-001',
};
// 簡化版 res只關心 setHeader / status / json / on('finish') / headersSent
const headers = {};
const finishListeners = [];
const res = {
headersSent: false,
statusCode: 200,
body: null,
setHeader: jest.fn((k, v) => {
headers[k] = v;
}),
getHeader: (k) => headers[k],
status: jest.fn(function statusImpl(code) {
res.statusCode = code;
return res;
}),
json: jest.fn(function jsonImpl(body) {
res.body = body;
res.headersSent = true;
// 模擬 'finish' 事件async下個 microtask 觸發)
Promise.resolve().then(() => {
for (const l of finishListeners.splice(0)) {
try {
l();
} catch (_) {
/* noop */
}
}
});
return res;
}),
once: jest.fn((evt, cb) => {
if (evt === 'finish') finishListeners.push(cb);
}),
on: jest.fn((evt, cb) => {
if (evt === 'finish') finishListeners.push(cb);
}),
_flush: () =>
new Promise((resolve) =>
// 等下個 microtask
setImmediate(resolve)
),
};
const next = jest.fn();
return { req, res, next, socket, headers };
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
describe('requireAuth — 驗證失敗路徑', () => {
let verify;
beforeAll(() => {
verify = makeInjectedVerify();
});
it('should 401 + destroy when Authorization header missing', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket, headers } = makeReqResNext(undefined);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('invalid_token');
expect(res.body.error.request_id).toBe('req-test-001');
expect(headers['Connection']).toBe('close');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 401 + destroy when Authorization header malformed (not Bearer)', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket, headers } = makeReqResNext('Basic abc123');
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('invalid_token');
expect(headers['Connection']).toBe('close');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 401 + destroy when token is empty after Bearer', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext('Bearer ');
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('invalid_token');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 401 token_expired + destroy when token is expired', async () => {
const expiredToken = await makeToken({}, { expirationTime: 100 }); // 1970 早就過期
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket, headers } = makeReqResNext(`Bearer ${expiredToken}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('token_expired');
expect(headers['Connection']).toBe('close');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 401 invalid_token + destroy when issuer is wrong', async () => {
const token = await makeToken({}, { issuer: 'https://evil.example.com' });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('invalid_token');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 401 invalid_token + destroy when audience is wrong', async () => {
const token = await makeToken({}, { audience: 'wrong-audience' });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('invalid_token');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 401 invalid_token + destroy when signature is wrong', async () => {
const { privateKey: otherPriv } = await generateKeyPair('RS256', {
modulusLength: 2048,
});
// 用 KID 對得上但簽章對不上
const token = await makeToken({}, { signKey: otherPriv });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('invalid_token');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 403 insufficient_scope + destroy when scope is missing', async () => {
const token = await makeToken({ scope: 'converter:job.read' }); // 沒有 .write
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket, headers } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(403);
expect(res.body.error.code).toBe('insufficient_scope');
expect(res.body.error.details).toEqual({
required_scope: 'converter:job.write',
provided_scopes: ['converter:job.read'],
});
expect(headers['Connection']).toBe('close');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 403 insufficient_scope when scope claim is empty', async () => {
const token = await makeToken({ scope: '' });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(403);
expect(res.body.error.code).toBe('insufficient_scope');
expect(res.body.error.details.provided_scopes).toEqual([]);
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should 403 tenant_mismatch + destroy when tenant_id differs', async () => {
const token = await makeToken({ tenant_id: 'tenant-B' });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG_WITH_TENANT,
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(res.statusCode).toBe(403);
expect(res.body.error.code).toBe('tenant_mismatch');
expect(res.body.error.details.expected_tenant).toBe('tenant-A');
// 不洩漏 token 的 tenant_id
expect(res.body.error.details).not.toHaveProperty('actual_tenant');
expect(socket.destroy).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});
it('should not check tenant when config.tenantId is empty', async () => {
const token = await makeToken({ tenant_id: 'any-tenant' });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG, // tenantId = ''
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
await res._flush();
expect(next).toHaveBeenCalledTimes(1);
expect(socket.destroy).not.toHaveBeenCalled();
});
});
describe('requireAuth — 驗證成功路徑', () => {
let verify;
beforeAll(() => {
verify = makeInjectedVerify();
});
it('should call next() and set req.auth on valid token with correct scope', async () => {
const token = await makeToken({
sub: 'user-99',
client_id: 'visionA-backend',
scope: 'converter:job.write converter:job.read',
tenant_id: 'tenant-A',
});
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(socket.destroy).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(req.auth).toBeDefined();
expect(req.auth.sub).toBe('user-99');
expect(req.auth.clientId).toBe('visionA-backend');
expect(req.auth.tenantId).toBe('tenant-A');
expect(req.auth.scopes).toEqual(['converter:job.write', 'converter:job.read']);
expect(req.auth.raw).toBeDefined();
expect(req.auth.raw.sub).toBe('user-99');
});
it('should support scp array claim (instead of scope string)', async () => {
const token = await makeToken({
sub: 'user-1',
scope: undefined,
scp: ['converter:job.write', 'converter:job.read'],
});
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(req.auth.scopes).toEqual(['converter:job.write', 'converter:job.read']);
});
it('should fall back clientId to sub when client_id is absent', async () => {
const token = await makeToken({
sub: 'user-only',
client_id: undefined,
scope: 'converter:job.write',
});
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next } = makeReqResNext(`Bearer ${token}`);
await middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(req.auth.clientId).toBe('user-only');
});
it('should accept lowercase "bearer" prefix', async () => {
const token = await makeToken({ scope: 'converter:job.write' });
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next } = makeReqResNext(`bearer ${token}`);
await middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
});
describe('requireAuth — M2 destroy 連線行為(單元層)', () => {
let verify;
beforeAll(() => {
verify = makeInjectedVerify();
});
it('should set Connection: close header BEFORE writing body', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, headers } = makeReqResNext(undefined);
await middleware(req, res, next);
await res._flush();
// 確認 setHeader 在 res.status 之前被呼叫
const setHeaderOrder = res.setHeader.mock.invocationCallOrder[0];
const statusOrder = res.status.mock.invocationCallOrder[0];
expect(setHeaderOrder).toBeLessThan(statusOrder);
expect(headers['Connection']).toBe('close');
});
it('should destroy socket only AFTER res finish event (not before)', async () => {
// 自製一個「不會自動觸發 finish」的 res讓我們能精確控制觸發時機
const socket = { destroyed: false, destroy: jest.fn(() => { socket.destroyed = true; }) };
const finishListeners = [];
const headers = {};
const res = {
headersSent: false,
statusCode: 200,
body: null,
setHeader: jest.fn((k, v) => { headers[k] = v; }),
status: jest.fn(function s(code) { this.statusCode = code; return this; }),
json: jest.fn(function j(b) { this.body = b; this.headersSent = true; return this; }),
// 注意:這個 once 只把 listener 推進陣列,不自動觸發 finish
once: jest.fn((evt, cb) => { if (evt === 'finish') finishListeners.push(cb); }),
on: jest.fn((evt, cb) => { if (evt === 'finish') finishListeners.push(cb); }),
};
const req = { headers: {}, socket, requestId: 'req-test-001' };
const next = jest.fn();
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
await middleware(req, res, next);
// 此時 res.status / res.json 都已執行,但 'finish' 事件還沒被觸發
expect(res.json).toHaveBeenCalledTimes(1);
expect(socket.destroy).not.toHaveBeenCalled();
// 手動觸發 finish 事件(模擬 Node 真實行為response 寫入完畢後才會觸發)
for (const cb of finishListeners.splice(0)) cb();
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('should use res.once not res.on (to avoid duplicate destroy on retries)', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next } = makeReqResNext(undefined);
await middleware(req, res, next);
expect(res.once).toHaveBeenCalledWith('finish', expect.any(Function));
});
it('should not throw if socket is already destroyed', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next, socket } = makeReqResNext(undefined);
// 預先把 socket 設為 destroyed
socket.destroyed = true;
await middleware(req, res, next);
await res._flush();
// 因為 destroyed=true不應該再呼叫 destroy()
expect(socket.destroy).not.toHaveBeenCalled();
});
it('should handle missing req.socket gracefully', async () => {
const middleware = middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
});
const { req, res, next } = makeReqResNext(undefined);
delete req.socket;
// 不應 throw
await expect(middleware(req, res, next)).resolves.not.toThrow();
await res._flush();
});
});
// ----------------------------------------------------------------------------
// Integration test用 supertest內建 http server驗證真連線被斷
// ----------------------------------------------------------------------------
describe('requireAuth — Integration真實 Express + http server', () => {
let verify;
let app;
let server;
let baseUrl;
beforeAll(async () => {
verify = makeInjectedVerify();
});
beforeEach(async () => {
app = express();
// 一個極簡的 requestId middleware模擬 T3 行為
app.use((req, _res, n) => {
req.requestId = req.headers['x-request-id'] || 'req-int-001';
n();
});
app.get(
'/protected',
middlewareModule.requireAuth('converter:job.write', {
config: TEST_CONFIG,
verify,
}),
(req, res) => {
res.status(200).json({ ok: true, sub: req.auth.sub });
}
);
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', resolve);
});
const addr = server.address();
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
if (server) {
await new Promise((resolve) => server.close(resolve));
server = null;
}
});
it('should return 401 with Connection: close and close the connection on missing token', async () => {
const res = await fetch(`${baseUrl}/protected`);
const body = await res.json();
expect(res.status).toBe(401);
expect(res.headers.get('connection')).toBe('close');
expect(body.error.code).toBe('invalid_token');
expect(body.error.request_id).toBe('req-int-001');
});
it('should return 200 + payload on valid token', async () => {
const token = await makeToken({ sub: 'user-int-1', scope: 'converter:job.write' });
const res = await fetch(`${baseUrl}/protected`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.sub).toBe('user-int-1');
});
it('should return 403 insufficient_scope with correct details on integration path', async () => {
const token = await makeToken({ scope: 'converter:job.read' });
const res = await fetch(`${baseUrl}/protected`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
expect(res.status).toBe(403);
expect(res.headers.get('connection')).toBe('close');
expect(body.error.code).toBe('insufficient_scope');
expect(body.error.details.required_scope).toBe('converter:job.write');
});
it('should detect socket close from client side after 401', async () => {
// 用低階 http 模組實際觀察 socket close 事件
await new Promise((resolve, reject) => {
const url = new URL(`${baseUrl}/protected`);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
},
(res) => {
let raw = '';
res.on('data', (c) => {
raw += c.toString();
});
res.on('end', () => {
try {
expect(res.statusCode).toBe(401);
expect(res.headers.connection).toBe('close');
const body = JSON.parse(raw);
expect(body.error.code).toBe('invalid_token');
resolve();
} catch (e) {
reject(e);
}
});
}
);
req.on('error', reject);
req.end();
});
});
});
// ----------------------------------------------------------------------------
// Helper / internals tests
// ----------------------------------------------------------------------------
describe('internals.extractBearerToken', () => {
const { extractBearerToken } = middlewareModule._internals;
it('returns null on undefined / empty', () => {
expect(extractBearerToken(undefined)).toBeNull();
expect(extractBearerToken('')).toBeNull();
expect(extractBearerToken(null)).toBeNull();
});
it('returns null on non-Bearer scheme', () => {
expect(extractBearerToken('Basic abc')).toBeNull();
expect(extractBearerToken('Token abc')).toBeNull();
});
it('returns trimmed token on valid Bearer', () => {
expect(extractBearerToken('Bearer xyz123')).toBe('xyz123');
expect(extractBearerToken('Bearer xyz123 ')).toBe('xyz123');
expect(extractBearerToken('bearer xyz123')).toBe('xyz123');
});
it('returns null when token portion is empty', () => {
expect(extractBearerToken('Bearer ')).toBeNull();
expect(extractBearerToken('Bearer ')).toBeNull();
});
});
describe('internals.extractScopes', () => {
const { extractScopes } = middlewareModule._internals;
it('parses space-separated scope string', () => {
expect(extractScopes({ scope: 'a b c' })).toEqual(['a', 'b', 'c']);
});
it('parses scp array', () => {
expect(extractScopes({ scp: ['a', 'b'] })).toEqual(['a', 'b']);
});
it('handles array scope claim', () => {
expect(extractScopes({ scope: ['a', 'b'] })).toEqual(['a', 'b']);
});
it('returns empty array when neither present', () => {
expect(extractScopes({})).toEqual([]);
});
it('strips empty string entries', () => {
expect(extractScopes({ scope: 'a b' })).toEqual(['a', 'b']);
expect(extractScopes({ scp: ['', 'a'] })).toEqual(['a']);
});
});
describe('internals.sendAuthError — edge cases', () => {
const { sendAuthError } = middlewareModule._internals;
it('does not double-write when headersSent already', () => {
const { req, res, socket } = makeReqResNext(undefined);
res.headersSent = true;
sendAuthError(req, res, 401, 'invalid_token', 'msg');
// 不該再 setHeader 或 status / json
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
// 但仍嘗試 destroy保險
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,952 @@
/**
* Unit + Integration tests for src/auth/oauthClient.js
*
* 測試重點
* 1. cache hit / miss / 過期 refresh
* 2. invalidate 後重取
* 3. in-flight Promise dedup scope 並發只發一次
* 4. 不同 scope 各自獨立發 request
* 5. 4xx OAuthClientError5xx OAuthServerErrortimeout OAuthTimeoutError
* 6. response 缺欄位 OAuthServerError
* 7. **secret 不洩漏**spy console驗證 log 不含 client_secret 字串不含 access_token
* 8. Integration用真實 http server 模擬 Member Center token endpoint
*/
'use strict';
const http = require('http');
const oauthModule = require('../oauthClient');
const {
OAuthClient,
OAuthClientError,
OAuthServerError,
OAuthTimeoutError,
_internals,
} = oauthModule;
// ----------------------------------------------------------------------------
// 共用 fixture / helpers
// ----------------------------------------------------------------------------
const TEST_CLIENT_ID = 'kneron_converter';
// 用一個故意「特殊」的 secret方便 grep 全部 log 確認沒洩漏
const TEST_CLIENT_SECRET = 'super-secret-XYZ-123-must-not-appear-in-logs';
const TEST_FAA_AUDIENCE = 'file_access_api';
const TEST_TOKEN_URL = 'http://127.0.0.1:0/oauth/token'; // 0 在 fetch 不會用,測試會注入 fetch
function makeTestConfig(overrides = {}) {
return {
memberCenter: {
issuer: 'https://auth.test.local',
jwksUrl: 'https://auth.test.local/.well-known/jwks',
tokenUrl: TEST_TOKEN_URL,
},
converter: {
audience: 'kneron_converter_api',
clientId: TEST_CLIENT_ID,
clientSecret: TEST_CLIENT_SECRET,
tenantId: '',
scopeWrite: 'converter:job.write',
scopeRead: 'converter:job.read',
},
fileAccessAgent: {
baseUrl: '',
audience: TEST_FAA_AUDIENCE,
},
jwks: { cacheMaxAgeMs: 600000, cooldownMs: 30000, clockToleranceSec: 60 },
oauthClient: {
refreshSkewMs: 60 * 1000,
timeoutMs: 10 * 1000,
},
...overrides,
};
}
/**
* 製造一個假 fetch sequence 取資料回應可記錄被呼叫的次數 / arg
*/
function makeMockFetch(handlers) {
const calls = [];
let idx = 0;
const fn = jest.fn(async (url, init) => {
calls.push({ url, init, at: Date.now() });
let handler;
if (typeof handlers === 'function') {
handler = handlers;
} else if (Array.isArray(handlers)) {
handler = handlers[Math.min(idx, handlers.length - 1)];
idx += 1;
} else {
throw new Error('handlers must be array or function');
}
return handler(url, init, idx);
});
fn.calls = calls;
return fn;
}
/**
* 建一個成功回應access_token / token_type / expires_in可加 overrides
*/
function tokenSuccessBody(overrides = {}) {
return {
access_token: 'mock-access-token-' + Math.random().toString(36).slice(2),
token_type: 'Bearer',
expires_in: 3600,
...overrides,
};
}
function makeJsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function makeTextResponse(status, text) {
return new Response(text, {
status,
headers: { 'Content-Type': 'text/plain' },
});
}
/**
* 製造一個 controllable now() advance time
*/
function makeFakeClock(initialMs = 1_700_000_000_000) {
let cur = initialMs;
const now = () => cur;
now.advance = (ms) => {
cur += ms;
};
now.set = (ms) => {
cur = ms;
};
return now;
}
// ----------------------------------------------------------------------------
// 全域 silence INFO 級別 log避免 jest 輸出被結構化 log 蓋掉)。
// 但保留 spy 物件供「secret 不洩漏」測試使用。
// ----------------------------------------------------------------------------
let logSpy;
let warnSpy;
let errorSpy;
beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
});
/** 把所有 spy 收到的 string 收集成一個大陣列,用來 substring search。 */
function collectAllLoggedStrings() {
const acc = [];
for (const spy of [logSpy, warnSpy, errorSpy]) {
for (const call of spy.mock.calls) {
for (const arg of call) {
if (typeof arg === 'string') acc.push(arg);
else acc.push(JSON.stringify(arg));
}
}
}
return acc;
}
// ----------------------------------------------------------------------------
// 1. 基本 happy path + cache 行為
// ----------------------------------------------------------------------------
describe('getServiceToken — happy path & cache', () => {
it('first call fetches token and caches it', async () => {
const body = tokenSuccessBody();
const fetch = makeMockFetch(() => makeJsonResponse(200, body));
const clock = makeFakeClock();
const client = new OAuthClient({
fetch,
now: clock,
loadConfig: () => makeTestConfig(),
});
const t1 = await client.getServiceToken('files:upload.write');
expect(t1).toBe(body.access_token);
expect(fetch).toHaveBeenCalledTimes(1);
// 第二次呼叫 — cache hit不打 endpoint
const t2 = await client.getServiceToken('files:upload.write');
expect(t2).toBe(body.access_token);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('uses HTTP Basic auth header (not body) for client credentials', async () => {
const fetch = makeMockFetch(() => makeJsonResponse(200, tokenSuccessBody()));
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await client.getServiceToken('files:upload.write');
const init = fetch.calls[0].init;
expect(init.method).toBe('POST');
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(init.headers.Authorization).toMatch(/^Basic /);
const expected = Buffer.from(
`${TEST_CLIENT_ID}:${TEST_CLIENT_SECRET}`,
'utf8'
).toString('base64');
expect(init.headers.Authorization).toBe(`Basic ${expected}`);
// body 必須不含 client_secret
expect(typeof init.body).toBe('string');
expect(init.body).not.toContain(TEST_CLIENT_SECRET);
expect(init.body).toContain('grant_type=client_credentials');
expect(init.body).toContain('scope=files%3Aupload.write');
expect(init.body).toContain(`audience=${TEST_FAA_AUDIENCE}`);
});
it('refreshes when cached token is within refreshSkewMs of expiry', async () => {
const body1 = tokenSuccessBody({ access_token: 'token-1', expires_in: 100 });
const body2 = tokenSuccessBody({ access_token: 'token-2', expires_in: 100 });
const fetch = makeMockFetch([
() => makeJsonResponse(200, body1),
() => makeJsonResponse(200, body2),
]);
const clock = makeFakeClock();
const client = new OAuthClient({
fetch,
now: clock,
// refreshSkewMs = 60stoken expires_in = 100s → cache 在 (100 - 60)s 後就視為過期
loadConfig: () => makeTestConfig({ oauthClient: { refreshSkewMs: 60_000, timeoutMs: 10_000 } }),
});
const t1 = await client.getServiceToken('files:upload.write');
expect(t1).toBe('token-1');
// 模擬經過 41 秒100 - 60 = 40此時已進入 refresh window
clock.advance(41_000);
const t2 = await client.getServiceToken('files:upload.write');
expect(t2).toBe('token-2');
expect(fetch).toHaveBeenCalledTimes(2);
});
it('keeps cached token within freshness window', async () => {
const body = tokenSuccessBody({ access_token: 'token-A', expires_in: 200 });
const fetch = makeMockFetch(() => makeJsonResponse(200, body));
const clock = makeFakeClock();
const client = new OAuthClient({
fetch,
now: clock,
loadConfig: () => makeTestConfig({ oauthClient: { refreshSkewMs: 60_000, timeoutMs: 10_000 } }),
});
await client.getServiceToken('files:upload.write');
// 經過 100s距離 200s 過期還有 100s > skew 60s → 仍 cache hit
clock.advance(100_000);
const t = await client.getServiceToken('files:upload.write');
expect(t).toBe('token-A');
expect(fetch).toHaveBeenCalledTimes(1);
});
});
// ----------------------------------------------------------------------------
// 2. invalidate
// ----------------------------------------------------------------------------
describe('invalidate', () => {
it('forces next call to fetch a new token', async () => {
const fetch = makeMockFetch([
() => makeJsonResponse(200, tokenSuccessBody({ access_token: 'before' })),
() => makeJsonResponse(200, tokenSuccessBody({ access_token: 'after' })),
]);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
expect(await client.getServiceToken('files:upload.write')).toBe('before');
client.invalidate('files:upload.write');
expect(await client.getServiceToken('files:upload.write')).toBe('after');
expect(fetch).toHaveBeenCalledTimes(2);
});
it('is a noop for unknown scope', () => {
const client = new OAuthClient({
fetch: () => {
throw new Error('should not be called');
},
loadConfig: () => makeTestConfig(),
});
expect(() => client.invalidate('not-cached')).not.toThrow();
expect(() => client.invalidate('')).not.toThrow();
expect(() => client.invalidate(undefined)).not.toThrow();
});
});
// ----------------------------------------------------------------------------
// 3. 並發保護in-flight Promise dedup
// ----------------------------------------------------------------------------
describe('in-flight dedup', () => {
it('coalesces concurrent calls for same scope into single request', async () => {
let resolveOnce;
const pending = new Promise((r) => {
resolveOnce = r;
});
const body = tokenSuccessBody({ access_token: 'shared-token' });
const fetch = makeMockFetch(async () => {
await pending; // 卡住第一次 request
return makeJsonResponse(200, body);
});
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
// 同時起 10 個 caller
const promises = Array.from({ length: 10 }, () =>
client.getServiceToken('files:upload.write')
);
// 此時 fetch 只應被呼叫一次in-flight dedup
expect(fetch).toHaveBeenCalledTimes(1);
// 放行 fetch
resolveOnce(true);
const tokens = await Promise.all(promises);
expect(tokens).toEqual(Array(10).fill('shared-token'));
expect(fetch).toHaveBeenCalledTimes(1);
});
it('issues separate requests for different scopes concurrently', async () => {
const fetch = makeMockFetch((url, init) => {
// 從 body 反查 scope
const params = new URLSearchParams(init.body);
const scope = params.get('scope');
return makeJsonResponse(
200,
tokenSuccessBody({ access_token: `token-for-${scope}` })
);
});
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
const [a, b] = await Promise.all([
client.getServiceToken('files:upload.write'),
client.getServiceToken('something:else.read'),
]);
expect(a).toBe('token-for-files:upload.write');
expect(b).toBe('token-for-something:else.read');
expect(fetch).toHaveBeenCalledTimes(2);
});
it('clears in-flight on failure so next call can retry', async () => {
let attempt = 0;
const fetch = makeMockFetch(async () => {
attempt += 1;
if (attempt === 1) return makeJsonResponse(500, { error: 'server_error' });
return makeJsonResponse(200, tokenSuccessBody({ access_token: 'recovered' }));
});
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toBeInstanceOf(OAuthServerError);
// 即使第一次失敗,第二次應能正常發 requestin-flight 已清)
const t = await client.getServiceToken('files:upload.write');
expect(t).toBe('recovered');
expect(fetch).toHaveBeenCalledTimes(2);
});
});
// ----------------------------------------------------------------------------
// 4. 錯誤分類
// ----------------------------------------------------------------------------
describe('error classification', () => {
it('throws OAuthClientError on 400 invalid_client', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(400, {
error: 'invalid_client',
error_description: 'Client authentication failed',
})
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
let caught;
try {
await client.getServiceToken('files:upload.write');
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(OAuthClientError);
expect(caught.status).toBe(400);
expect(caught.errorCode).toBe('invalid_client');
expect(caught.retryable).toBe(false);
// message 應提及 status不應提及 client_secret
expect(caught.message).toContain('400');
expect(caught.message).not.toContain(TEST_CLIENT_SECRET);
});
it('throws OAuthClientError on 401 invalid_grant', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(401, { error: 'invalid_grant' })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthClientError',
status: 401,
errorCode: 'invalid_grant',
retryable: false,
});
});
it('throws OAuthClientError on 4xx with non-JSON body', async () => {
const fetch = makeMockFetch(() => makeTextResponse(403, 'Forbidden'));
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthClientError',
status: 403,
retryable: false,
});
});
it('throws OAuthServerError on 500', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(500, { error: 'server_error' })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
status: 500,
retryable: true,
});
});
it('throws OAuthServerError on 503', async () => {
const fetch = makeMockFetch(() => makeTextResponse(503, 'Service Unavailable'));
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
status: 503,
});
});
it('throws OAuthTimeoutError when fetch is aborted by AbortController', async () => {
// 模擬「fetch 永遠不回」→ AbortController 觸發
const fetch = jest.fn(
(url, init) =>
new Promise((_, reject) => {
init.signal.addEventListener('abort', () => {
const err = new Error('The operation was aborted.');
err.name = 'AbortError';
reject(err);
});
})
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig({ oauthClient: { refreshSkewMs: 60_000, timeoutMs: 50 } }),
});
let caught;
try {
await client.getServiceToken('files:upload.write');
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(OAuthTimeoutError);
expect(caught.retryable).toBe(true);
expect(caught.message).toContain('50ms');
});
it('throws OAuthTimeoutError on generic network error', async () => {
const fetch = jest.fn(async () => {
const err = new Error('ECONNREFUSED 127.0.0.1:8080');
err.code = 'ECONNREFUSED';
throw err;
});
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthTimeoutError',
retryable: true,
});
});
});
// ----------------------------------------------------------------------------
// 5. response shape 驗證
// ----------------------------------------------------------------------------
describe('response shape validation', () => {
it('throws OAuthServerError when access_token is missing', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(200, { token_type: 'Bearer', expires_in: 3600 })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
});
});
it('throws OAuthServerError when token_type is missing', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(200, { access_token: 'x', expires_in: 3600 })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
});
});
it('throws OAuthServerError when expires_in is missing', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(200, { access_token: 'x', token_type: 'Bearer' })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
});
});
it('throws OAuthServerError when expires_in is negative', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(200, { access_token: 'x', token_type: 'Bearer', expires_in: -1 })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
});
});
it('throws OAuthServerError when JSON parse fails', async () => {
const fetch = makeMockFetch(
() =>
new Response('not json {', {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(
client.getServiceToken('files:upload.write')
).rejects.toMatchObject({
name: 'OAuthServerError',
});
});
it('accepts expires_in as numeric string', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(200, { access_token: 'tok', token_type: 'Bearer', expires_in: '3600' })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
const t = await client.getServiceToken('files:upload.write');
expect(t).toBe('tok');
});
});
// ----------------------------------------------------------------------------
// 6. 輸入驗證
// ----------------------------------------------------------------------------
describe('input validation', () => {
it('rejects non-string scope', async () => {
const client = new OAuthClient({
fetch: () => {
throw new Error('should not be reached');
},
loadConfig: () => makeTestConfig(),
});
await expect(client.getServiceToken(undefined)).rejects.toThrow(/scope is required/);
await expect(client.getServiceToken(null)).rejects.toThrow(/scope is required/);
await expect(client.getServiceToken('')).rejects.toThrow(/scope is required/);
await expect(client.getServiceToken(' ')).rejects.toThrow(/scope is required/);
});
});
// ----------------------------------------------------------------------------
// 7. **CRITICAL: secret 不洩漏到 log**
// ----------------------------------------------------------------------------
describe('SECURITY: client_secret never appears in any log', () => {
/**
* client 跑過所有 log 的路徑一輪最後 grep 全部 log 字串確認沒洩漏
*
* 觸發的 log 路徑
* - oauth.token_obtained成功
* - oauth.token_invalidated成功
* - oauth.token_endpoint_error4xx / 5xx
* - oauth.token_fetch_failedtimeout / network
* - oauth.token_response_parse_failedJSON 解析失敗
*/
it('does not log client_secret on success path', async () => {
const fetch = makeMockFetch(() => makeJsonResponse(200, tokenSuccessBody()));
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await client.getServiceToken('files:upload.write');
client.invalidate('files:upload.write');
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_CLIENT_SECRET);
// 額外保險:也不應包含完整的 Basic auth header
expect(line).not.toMatch(/Basic [A-Za-z0-9+/=]{20,}/);
}
});
it('does not log client_secret on 4xx error path', async () => {
const fetch = makeMockFetch(() =>
makeJsonResponse(400, { error: 'invalid_client', error_description: 'auth failed' })
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(client.getServiceToken('files:upload.write')).rejects.toBeDefined();
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_CLIENT_SECRET);
}
});
it('does not log client_secret on 5xx error path', async () => {
const fetch = makeMockFetch(() => makeTextResponse(503, 'unavailable'));
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(client.getServiceToken('files:upload.write')).rejects.toBeDefined();
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_CLIENT_SECRET);
}
});
it('does not log client_secret on timeout path', async () => {
const fetch = jest.fn(
(url, init) =>
new Promise((_, reject) => {
init.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
})
);
const client = new OAuthClient({
fetch,
loadConfig: () =>
makeTestConfig({ oauthClient: { refreshSkewMs: 60_000, timeoutMs: 30 } }),
});
await expect(client.getServiceToken('files:upload.write')).rejects.toBeDefined();
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_CLIENT_SECRET);
}
});
it('does not log access_token contents on success', async () => {
const SECRET_TOKEN = 'do-not-log-me-' + Math.random().toString(36).slice(2);
const fetch = makeMockFetch(() =>
makeJsonResponse(200, tokenSuccessBody({ access_token: SECRET_TOKEN }))
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await client.getServiceToken('files:upload.write');
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(SECRET_TOKEN);
}
});
it('does not log token even when JSON parse fails (bad body)', async () => {
const SECRET = 'leaky-token-' + Math.random().toString(36).slice(2);
// 雖然 body 解析失敗會丟 OAuthServerError但實作的 catch 只應 log error.message
// 不應 log res.text() 內容。
const fetch = makeMockFetch(
() =>
new Response(`{"access_token":"${SECRET}",bad json`, {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
const client = new OAuthClient({
fetch,
loadConfig: () => makeTestConfig(),
});
await expect(client.getServiceToken('files:upload.write')).rejects.toBeDefined();
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(SECRET);
}
});
});
// ----------------------------------------------------------------------------
// 8. _internals helpers
// ----------------------------------------------------------------------------
describe('_internals helpers', () => {
it('buildBasicAuthHeader produces RFC 7617 base64 form', () => {
const h = _internals.buildBasicAuthHeader('alice', 'open sesame');
// base64 of "alice:open sesame" = "YWxpY2U6b3BlbiBzZXNhbWU="
expect(h).toBe('Basic YWxpY2U6b3BlbiBzZXNhbWU=');
});
it('parseTokenResponse handles minimal valid payload', () => {
const p = _internals.parseTokenResponse({
access_token: 'a',
token_type: 'Bearer',
expires_in: 60,
});
expect(p).toEqual({ accessToken: 'a', tokenType: 'Bearer', expiresInSec: 60 });
});
it('parseTokenResponse rejects non-object', () => {
expect(() => _internals.parseTokenResponse(null)).toThrow();
expect(() => _internals.parseTokenResponse('str')).toThrow();
expect(() => _internals.parseTokenResponse(123)).toThrow();
});
it('parseTokenResponse floors fractional expires_in', () => {
const p = _internals.parseTokenResponse({
access_token: 'a',
token_type: 'Bearer',
expires_in: 60.7,
});
expect(p.expiresInSec).toBe(60);
});
});
// ----------------------------------------------------------------------------
// 9. Integration: 真 http server 模擬 Member Center token endpoint
// ----------------------------------------------------------------------------
describe('integration with real HTTP server', () => {
let server;
let serverUrl;
/** @type {(req: import('http').IncomingMessage, body: string) => { status: number, body: any }} */
let handler;
beforeAll(async () => {
server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => {
raw += c.toString('utf8');
});
req.on('end', () => {
try {
const result = handler(req, raw);
res.writeHead(result.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'test_handler_error', message: err.message }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const addr = server.address();
serverUrl = `http://127.0.0.1:${addr.port}/oauth/token`;
});
afterAll(async () => {
if (server) {
await new Promise((resolve) => server.close(resolve));
}
});
function makeIntegrationClient(extraConfig = {}) {
return new OAuthClient({
// 使用 globalThis.fetchNode 20 內建)
loadConfig: () =>
makeTestConfig({
memberCenter: {
issuer: 'https://auth.test.local',
jwksUrl: 'https://auth.test.local/.well-known/jwks',
tokenUrl: serverUrl,
},
...extraConfig,
}),
});
}
it('sends correct request and parses response (real fetch + http server)', async () => {
/** @type {{ headers: any, body: string } | null} */
let captured = null;
handler = (req, body) => {
captured = { headers: req.headers, body };
return {
status: 200,
body: { access_token: 'integration-token', token_type: 'Bearer', expires_in: 3600 },
};
};
const client = makeIntegrationClient();
const tok = await client.getServiceToken('files:upload.write');
expect(tok).toBe('integration-token');
expect(captured).not.toBeNull();
expect(captured.headers['content-type']).toBe('application/x-www-form-urlencoded');
expect(captured.headers.authorization).toMatch(/^Basic /);
const expectedBasic = Buffer.from(
`${TEST_CLIENT_ID}:${TEST_CLIENT_SECRET}`,
'utf8'
).toString('base64');
expect(captured.headers.authorization).toBe(`Basic ${expectedBasic}`);
// body 內不能含 client_secret
expect(captured.body).not.toContain(TEST_CLIENT_SECRET);
const params = new URLSearchParams(captured.body);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('scope')).toBe('files:upload.write');
expect(params.get('audience')).toBe(TEST_FAA_AUDIENCE);
});
it('handles real 4xx response', async () => {
handler = () => ({ status: 401, body: { error: 'invalid_client' } });
const client = makeIntegrationClient();
await expect(client.getServiceToken('files:upload.write')).rejects.toMatchObject({
name: 'OAuthClientError',
status: 401,
errorCode: 'invalid_client',
});
});
it('handles real timeout (small server delay > timeoutMs)', async () => {
handler = (req, body) => {
// 故意延遲 200ms
const start = Date.now();
while (Date.now() - start < 200) {
// busy wait — 模擬 server 卡住
}
return {
status: 200,
body: { access_token: 'should-not-receive', token_type: 'Bearer', expires_in: 3600 },
};
};
const client = makeIntegrationClient({
oauthClient: { refreshSkewMs: 60_000, timeoutMs: 50 },
});
await expect(client.getServiceToken('files:upload.write')).rejects.toBeInstanceOf(
OAuthTimeoutError
);
});
});
// ----------------------------------------------------------------------------
// 10. Singleton wrappers (對外 export 的便利介面)
// ----------------------------------------------------------------------------
describe('module-level singleton wrappers', () => {
afterEach(() => {
_internals.singleton._resetForTests();
});
it('exports getServiceToken / invalidate as functions', () => {
expect(typeof oauthModule.getServiceToken).toBe('function');
expect(typeof oauthModule.invalidate).toBe('function');
});
it('error classes are exposed', () => {
expect(oauthModule.OAuthClientError).toBe(OAuthClientError);
expect(oauthModule.OAuthServerError).toBe(OAuthServerError);
expect(oauthModule.OAuthTimeoutError).toBe(OAuthTimeoutError);
});
});

View File

@ -0,0 +1,155 @@
/**
* JWKS cache JWT 驗證封裝
*
* 採用 `jose` 套件的 `createRemoteJWKSet`內建
* - TTL cachecacheMaxAge預設 10 分鐘
* - 失敗冷卻cooldownDuration預設 30 避免 thundering herd
* - 自動 stale-while-revalidate
* - 拒絕 alg=nonejose 預設
* - cache 大小有上限jose 預設
*
* 範圍T1
* - 暴露 `getJWKS()` middleware
* - 暴露 `verifyToken(token, opts)` 一站式驗證
* - 不負責 scope / tenant 檢查middleware 處理
*
* 安全注意
* - 絕對不在 log 中印出 token 內容或 payload
* - 不接受 alg=nonejose 預設
* - 不允許自帶的 key set防止JWKS poisoning
*/
'use strict';
const { createRemoteJWKSet, jwtVerify } = require('jose');
/**
* 模組層級 cache jwksUrl key 共用一個 RemoteJWKSet 實例
*
* 為什麼用模組層級 cache 而非每次 new
* - `createRemoteJWKSet` 內建 TTL cache cooldown重複 new 會破壞 cache 命中率
* - 同一個 process 內所有 middleware 共用同一個 JWKSet
*
* 暴露 `_resetForTests()` 讓測試重置
*/
const _jwksByUrl = new Map();
/**
* 允許的 JWT 簽章演算法白名單Sec m3 修正
*
* 為什麼明確 pin
* - 雖然 jose 預設拒絕 alg=none但保留了 HMAC`HS256`/`HS384`/`HS512`作為
* 合法選項HMAC 簽章用對稱金鑰attacker 拿到 JWKS 公鑰後可能用同一個 key
* HMAC 偽造演算法混淆攻擊
* - 明確 pin 為非對稱演算法攻擊面收窄
*
* 選擇的 algs
* - `RS256`RSA SHA-256OAuth 2.0 / OIDC 業界標準Member Center 預期主用
* - `ES256`ECDSA P-256 SHA-256新興 OIDC provider 常用Auth0Okta
* - `PS256`RSA-PSS SHA-256 RS256 更安全的 RSA 變體
*/
const ALLOWED_JWT_ALGS = Object.freeze(['RS256', 'ES256', 'PS256']);
/**
* 取得或建立對應 jwksUrl RemoteJWKSet
*
* @param {string} jwksUrl - JWKS endpoint URL
* @param {{ cacheMaxAgeMs?: number, cooldownMs?: number }} [options]
* @returns {Function} - jose RemoteJWKSet可作為 jwtVerify 的第二參數
*/
function getJWKS(jwksUrl, options = {}) {
if (typeof jwksUrl !== 'string' || jwksUrl.trim() === '') {
throw new Error('[jwks] jwksUrl is required');
}
const cached = _jwksByUrl.get(jwksUrl);
if (cached) {
return cached;
}
const cacheMaxAgeMs = options.cacheMaxAgeMs ?? 10 * 60 * 1000;
const cooldownMs = options.cooldownMs ?? 30 * 1000;
let url;
try {
url = new URL(jwksUrl);
} catch (err) {
throw new Error(`[jwks] Invalid JWKS URL: ${jwksUrl} (${err.message})`);
}
const jwks = createRemoteJWKSet(url, {
cacheMaxAge: cacheMaxAgeMs,
cooldownDuration: cooldownMs,
});
_jwksByUrl.set(jwksUrl, jwks);
return jwks;
}
/**
* 驗證 JWT token簽章issueraudience過期
*
* 不檢查 scope / tenant middleware 層處理
*
* @param {string} token - JWT compact token
* @param {{
* jwksUrl: string,
* issuer: string,
* audience: string,
* clockToleranceSec?: number,
* cacheMaxAgeMs?: number,
* cooldownMs?: number,
* }} options
* @returns {Promise<{ payload: object, protectedHeader: object }>}
*
* @throws {Error} - jose JOSEError 子類呼叫端應檢查 `err.code`
* - `ERR_JWT_EXPIRED` token 過期
* - `ERR_JWS_INVALID` 簽章錯
* - `ERR_JWS_SIGNATURE_VERIFICATION_FAILED` 簽章驗證失敗
* - `ERR_JWKS_NO_MATCHING_KEY` JWKS 找不到 kid
* - `ERR_JWT_CLAIM_VALIDATION_FAILED` issuer / audience 不符
*/
async function verifyToken(token, options) {
if (typeof token !== 'string' || token === '') {
const err = new Error('Token is empty');
err.code = 'ERR_JWS_INVALID';
throw err;
}
if (!options || typeof options !== 'object') {
throw new Error('[jwks] verifyToken requires options');
}
const { jwksUrl, issuer, audience, clockToleranceSec = 60 } = options;
if (!issuer) throw new Error('[jwks] options.issuer is required');
if (!audience) throw new Error('[jwks] options.audience is required');
const jwks = getJWKS(jwksUrl, {
cacheMaxAgeMs: options.cacheMaxAgeMs,
cooldownMs: options.cooldownMs,
});
// jose.jwtVerify 預設拒絕 alg=none、會驗 signature、exp、nbf。
// Sec m3明確 pin algorithms 白名單,避免 HMAC 演算法混淆攻擊。
return jwtVerify(token, jwks, {
issuer,
audience,
clockTolerance: clockToleranceSec,
algorithms: ALLOWED_JWT_ALGS,
});
}
/**
* 測試用清空模組層級 cache
* 生產環境不應呼叫
*/
function _resetForTests() {
_jwksByUrl.clear();
}
module.exports = {
getJWKS,
verifyToken,
ALLOWED_JWT_ALGS,
_resetForTests,
};

View File

@ -0,0 +1,286 @@
/**
* `requireAuth(scope)` Express middleware
*
* 職責
* 1. 驗證 `Authorization: Bearer <JWT>`
* 2. 透過 joseJWKS issuer / audience / 簽章 / 過期
* 3. 檢查 token 是否含 requiredScope
* 4. config 有設 tenantId檢查 token tenant_id 是否吻合
* 5. 驗證成功 把解析好的 auth 資訊掛到 req.auth呼叫 next()
* 6. 驗證失敗 統一錯誤格式回 401/403**主動斷線**M2
*
* M2Review m2 落實
* Express `res.status(401).json(...)` 不會主動關閉底層 socket攻擊者若已
* 開始上傳 500MB bodyNode 會繼續往 socket buffer 灌資料吃記憶體與頻寬
* 為此 sendAuthError response 完整送出後`res.on('finish')` destroy
* socket確保
* (a) client 收得到 401/403 JSON
* (b) 後續的 body bytes 不會繼續被 Node 接收
*
* **這只是盡力而為**實際大檔護欄靠 Nginx `client_max_body_size 600M`
* TDD §7.1DevOps 任務這層只是減輕應用層的負擔
*
* 已知限制
* - `res.on('finish')` 之前Node read buffer 仍可能累積一些 bytes
* 通常為 `highWaterMark`預設 16KB
* - 若用戶端用 HTTP/2 keep-alivedestroy socket 也會中斷該連線上的其他
* pipelined requestT1 範圍內可接受v1 端點目前只有 jobs/promote
*/
'use strict';
const { verifyToken } = require('./jwks');
/**
* 統一的錯誤回應 helperM2 destroy 連線
*
* 嚴格順序**勿改**
* 1. `Connection: close` header 告訴 client 不要 reuse 連線
* 2. `res.status().json()` 401/403 JSON 寫出
* 3. 監聽 `res.on('finish')` response 已寫完且發送完畢後
* destroy underlying socket client 沒辦法繼續灌 body
*
* 為什麼不能直接 `req.socket.destroy()` send response 之前
* 會在 response 還沒寫完就斷線client 收不到 401 訊息看到的是
* ECONNRESET無法判斷是被 reject 還是 server 異常
*
* 為什麼用 `req.socket` 而非 `res.socket`
* 兩者通常是同一個 underlying socket req.socket 可避免 res 在某些
* 狀況下已被釋放的情境例如 res detach
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {number} status - HTTP status code401 / 403
* @param {string} code - error.code 'invalid_token'
* @param {string} message - 對外訊息zh-TW
* @param {object} [details] - error.details可選
*/
function sendAuthError(req, res, status, code, message, details) {
// 雙重保險:若 response 已送過(不該發生但保險),不要 double send
if (res.headersSent) {
// 仍嘗試 destroy避免 client 繼續灌 body
try {
if (req.socket && !req.socket.destroyed) {
req.socket.destroy();
}
} catch (_) {
/* noop */
}
return;
}
res.setHeader('Connection', 'close');
const body = {
error: {
code,
message,
// request_id 由 T3 的 requestId middleware 提供T1 階段尚未掛上時
// 會是 undefined這裡保持一致格式即使 undefined 也輸出 key 以
// 利下游解析)—— 但 JSON.stringify 會 omit undefined value。
// T3 接管後會自動有值。
request_id: req.requestId || null,
},
};
if (details !== undefined) {
body.error.details = details;
}
res.status(status).json(body);
// 在 response 完整送出finish 事件)後 destroy socket。
// - finishresponse 寫完且 OS buffer 已 flush
// - 此時可安全 destroyclient 已收到完整 401/403
// 用 once 避免多次觸發;包 try/catch 防止 socket 已被別處 destroy。
res.once('finish', () => {
try {
if (req.socket && !req.socket.destroyed) {
req.socket.destroy();
}
} catch (_) {
/* noop — socket 可能已被 client 主動關閉或 Node 內部釋放 */
}
});
}
/**
* 解析 `Authorization: Bearer <token>` header
*
* @param {string|undefined} headerValue
* @returns {string|null} - 成功時回 token格式錯或缺值回 null
*/
function extractBearerToken(headerValue) {
if (typeof headerValue !== 'string' || headerValue.length === 0) {
return null;
}
// 嚴格匹配 'Bearer ' 開頭(大小寫敏感對齊 RFC 6750多數 client 用大寫)
// 允許大小寫不敏感以提高互操作性
const match = headerValue.match(/^Bearer\s+(.+)$/i);
if (!match) {
return null;
}
const token = match[1].trim();
if (token === '') return null;
return token;
}
/**
* token claims 中取出 scopes 陣列
*
* RFC 8693 / OAuth 2 `scope` claim 空白分隔字串
* 部分授權伺服器使用 `scp` claim陣列本函數兩者都支援
*
* @param {object} claims
* @returns {string[]}
*/
function extractScopes(claims) {
if (Array.isArray(claims.scp)) {
return claims.scp.filter((s) => typeof s === 'string' && s.length > 0);
}
if (typeof claims.scope === 'string') {
return claims.scope.split(/\s+/).filter(Boolean);
}
if (Array.isArray(claims.scope)) {
return claims.scope.filter((s) => typeof s === 'string' && s.length > 0);
}
return [];
}
/**
* 建立一個 requireAuth middleware
*
* 用法
* const auth = require('./middleware');
* app.post('/api/v1/jobs', auth.requireAuth(config.converter.scopeWrite), handler);
*
* @param {string} requiredScope - 此端點要求的 scope 'converter:job.write'
* @param {object} [deps] - 依賴注入測試用
* @param {object} [deps.config] - 完整 config object config.loadConfig()
* @param {Function} [deps.verify] - 注入版的 verifyToken測試用
* @returns {import('express').RequestHandler}
*/
function requireAuth(requiredScope, deps = {}) {
if (typeof requiredScope !== 'string' || requiredScope === '') {
throw new Error('[requireAuth] requiredScope is required and must be a string');
}
// Lazy-load config讓測試能在 require 階段不需設環境變數
let config = deps.config;
const verify = deps.verify || verifyToken;
return async function authMiddleware(req, res, next) {
try {
if (!config) {
// 第一次呼叫才載入,避免測試時 import middleware 即觸發 config check
config = require('../config').loadConfig();
}
// 1. 取出 Bearer token
const token = extractBearerToken(req.headers && req.headers.authorization);
if (!token) {
return sendAuthError(
req,
res,
401,
'invalid_token',
'缺少或格式錯誤的 Authorization header需為 Bearer <token>'
);
}
// 2. 透過 JWKS 驗 issuer / audience / 簽章 / 過期
let result;
try {
result = await verify(token, {
jwksUrl: config.memberCenter.jwksUrl,
issuer: config.memberCenter.issuer,
audience: config.converter.audience,
clockToleranceSec: config.jwks.clockToleranceSec,
cacheMaxAgeMs: config.jwks.cacheMaxAgeMs,
cooldownMs: config.jwks.cooldownMs,
});
} catch (err) {
// jose 的 error.code 對映到對外錯誤碼
const errCode = err && err.code ? String(err.code) : '';
if (errCode === 'ERR_JWT_EXPIRED') {
return sendAuthError(req, res, 401, 'token_expired', 'Token 已過期');
}
// 簽章 / kid / 任何驗證失敗統一回 invalid_token避免洩漏內部資訊
// 安全考量不告訴攻擊者「issuer 對了但 audience 錯了」這類細節)
// 注意:這裡也涵蓋了 issuer / audience 不符ERR_JWT_CLAIM_VALIDATION_FAILED
// 這是刻意的對外只需知道「token 不被接受」即可。
// log 細節給 ops 看(不含 token 內容)。
// eslint-disable-next-line no-console
console.warn(
JSON.stringify({
level: 'WARN',
action: 'auth.verify_failed',
error_code: errCode || 'unknown',
message: err && err.message ? err.message : 'verify failed',
timestamp: new Date().toISOString(),
})
);
return sendAuthError(req, res, 401, 'invalid_token', 'Token 驗證失敗');
}
const claims = result.payload;
// 3. 檢查 scope
const scopes = extractScopes(claims);
if (!scopes.includes(requiredScope)) {
return sendAuthError(req, res, 403, 'insufficient_scope', 'Token 缺少必要權限', {
required_scope: requiredScope,
provided_scopes: scopes,
});
}
// 4. 檢查 tenant若 config.converter.tenantId 為空字串則跳過)
// TDD §5.1:「若有,等於 CONVERTER_TENANT_IDPhase 1 可先 warn-only
// 本實作採嚴格策略config 設了就一定要對;空字串時不檢查。
if (config.converter.tenantId) {
const claimTenant = claims.tenant_id;
if (claimTenant !== config.converter.tenantId) {
return sendAuthError(req, res, 403, 'tenant_mismatch', '租戶不符', {
expected_tenant: config.converter.tenantId,
// 不回傳 token 中真正的 tenant_id避免資訊洩露
});
}
}
// 5. 掛 req.auth 給下游使用
req.auth = {
sub: claims.sub || null,
clientId: claims.client_id || claims.sub || null,
tenantId: claims.tenant_id || null,
scopes,
// 完整 claims 物件給需要的 handler 用;不暴露 token 字串
raw: claims,
};
return next();
} catch (err) {
// 兜底:理論上不該走到這裡
// eslint-disable-next-line no-console
console.error(
JSON.stringify({
level: 'ERROR',
action: 'auth.middleware_unexpected_error',
message: err && err.message ? err.message : 'unknown',
timestamp: new Date().toISOString(),
})
);
return sendAuthError(req, res, 401, 'invalid_token', 'Token 驗證失敗');
}
};
}
module.exports = {
requireAuth,
// 測試 / 內部用
_internals: {
sendAuthError,
extractBearerToken,
extractScopes,
},
};

View File

@ -0,0 +1,464 @@
/**
* Converter 作為 OAuth Client取得 Member Center 簽發的 service token
* promote 階段呼叫 File Access Agent 使用Phase 1 僅用 `files:upload.write`
*
* 對外介面
* const oauthClient = require('./oauthClient');
* const token = await oauthClient.getServiceToken('files:upload.write');
* oauthClient.invalidate('files:upload.write'); // 401 時呼叫
*
* 設計重點
* 1. 每個 scope 一個 cache entryper-scope cache
* 2. 主動 refresh距離 expiresAt < refreshSkewMs預設 60s即視為過期
* 3. 並發保護 scope 的多個 caller 共享一個 in-flight Promise避免 thundering herd
* 4. 不同 scope 各自獨立發 request
* 5. AbortController timeout預設 10s
* 6. 錯誤分類OAuthClientError / OAuthServerError / OAuthTimeoutError
* 7. **絕不** client_secret / token 內容寫入 log
*
* 通信規格對齊 TDD §2.4 / §5.2 / RFC 6749 §4.4 + §2.3.1
* - 使用 HTTP Basic auth header `Authorization: Basic base64(client_id:client_secret)`
* RFC 6749 §2.3.1 推薦 body secret 安全token endpoint 通常都接受
* - body: `application/x-www-form-urlencoded` `grant_type=client_credentials`
* `scope=<scope>``audience=<aud>`Auth0 / 多數 IdP 慣例
* - 預期回應 JSON`{ access_token, token_type, expires_in }`
*
* 安全注意
* - 任何 log 都不得包含 `client_secret`Authorization header 內容access_token
* - 錯誤訊息只揭露 status + 標準 error_code `invalid_client`不揭露 server 端細節
*/
'use strict';
/* eslint-disable no-console */
// ----------------------------------------------------------------------------
// 錯誤類別
// ----------------------------------------------------------------------------
/**
* OAuth client 共用基類子類用 `name` 區分
*
* 注意constructor 不接受任何含 secret 的欄位message 也不該帶 secret
*/
class OAuthError extends Error {
/**
* @param {string} name
* @param {string} message
* @param {{ status?: number, errorCode?: string, retryable?: boolean }} [meta]
*/
constructor(name, message, meta = {}) {
super(message);
this.name = name;
this.status = meta.status ?? null;
this.errorCode = meta.errorCode ?? null; // OAuth 標準 error code如 'invalid_client'
this.retryable = meta.retryable ?? false;
}
}
/** 4xx — client 端錯誤(如 invalid_client、invalid_scope。不可重試。 */
class OAuthClientError extends OAuthError {
constructor(message, meta) {
super('OAuthClientError', message, { ...meta, retryable: false });
}
}
/** 5xx — server 端錯誤Member Center 故障)。可重試。 */
class OAuthServerError extends OAuthError {
constructor(message, meta) {
super('OAuthServerError', message, { ...meta, retryable: true });
}
}
/** 網路 / timeout — 連線層錯誤。可重試。 */
class OAuthTimeoutError extends OAuthError {
constructor(message, meta) {
super('OAuthTimeoutError', message, { ...meta, retryable: true });
}
}
// ----------------------------------------------------------------------------
// 內部 helpers
// ----------------------------------------------------------------------------
/**
* client_id / client_secret 編碼成 Basic auth header value
*
* @param {string} clientId
* @param {string} clientSecret
* @returns {string} - `Basic <base64>`
*/
function buildBasicAuthHeader(clientId, clientSecret) {
const raw = `${clientId}:${clientSecret}`;
// Buffer.from(...).toString('base64') 是 Node 標準做法;不依賴 deprecated `btoa`
return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
}
/**
* fetch Response 嘗試解析 OAuth 標準錯誤 JSON
* `{ "error": "invalid_client", "error_description": "..." }`
*
* 解析失敗時回 null不影響主流程僅缺少額外 metadata
*
* @param {Response} res
* @returns {Promise<{ error?: string, error_description?: string } | null>}
*/
async function tryParseOauthErrorBody(res) {
try {
// 先試 json失敗則 fallback text
const ctype = res.headers.get('content-type') || '';
if (ctype.includes('application/json')) {
return await res.json();
}
const txt = await res.text();
return txt ? { error_description: txt.slice(0, 200) } : null;
} catch (_) {
return null;
}
}
/**
* 結構化 log 一筆 OAuth 事件**絕不** log secret / token / authorization
*
* @param {'INFO'|'WARN'|'ERROR'} level
* @param {string} action
* @param {object} fields - 額外結構化欄位不可含 secret / token
*/
function logEvent(level, action, fields = {}) {
const line = JSON.stringify({
level,
service: 'oauth-client',
action,
timestamp: new Date().toISOString(),
...fields,
});
if (level === 'ERROR') {
console.error(line);
} else if (level === 'WARN') {
console.warn(line);
} else {
// INFO 也走 console.warn 在 jest silent 模式較不嘈雜;但 production 會走 stdout。
// 統一 INFO 用 console.log下游可由 log shipper 撈。
console.log(line);
}
}
/**
* 驗證 token endpoint 回傳的 JSON 格式
*
* @param {unknown} data
* @returns {{ accessToken: string, tokenType: string, expiresInSec: number }}
* @throws {OAuthServerError} - 格式錯視為 server bug可重試
*/
function parseTokenResponse(data) {
if (data === null || typeof data !== 'object') {
throw new OAuthServerError('Invalid token response: not a JSON object');
}
const obj = /** @type {Record<string, unknown>} */ (data);
if (typeof obj.access_token !== 'string' || obj.access_token.length === 0) {
throw new OAuthServerError('Invalid token response: missing access_token');
}
if (typeof obj.token_type !== 'string' || obj.token_type.length === 0) {
throw new OAuthServerError('Invalid token response: missing token_type');
}
// RFC 6749 §5.1expires_in 為 OPTIONAL但實務上 promote 場景沒它就無法管理 cache視為 required
const expiresInRaw = obj.expires_in;
let expiresInSec;
if (typeof expiresInRaw === 'number' && Number.isFinite(expiresInRaw)) {
expiresInSec = Math.floor(expiresInRaw);
} else if (typeof expiresInRaw === 'string' && /^\d+$/.test(expiresInRaw)) {
expiresInSec = Number.parseInt(expiresInRaw, 10);
} else {
throw new OAuthServerError('Invalid token response: missing or invalid expires_in');
}
if (expiresInSec <= 0) {
throw new OAuthServerError('Invalid token response: non-positive expires_in');
}
return {
accessToken: obj.access_token,
tokenType: obj.token_type,
expiresInSec,
};
}
// ----------------------------------------------------------------------------
// OAuthClient class
// ----------------------------------------------------------------------------
/**
* 一個簡單的 OAuth Client client_credentials grantper-scope cache
*
* 預期使用方式取一個 singleton見檔尾 export
*
* @typedef {Object} CacheEntry
* @property {string} accessToken - JWT access token
* @property {number} expiresAtMs - epoch mstoken 真正過期時間
*
* @typedef {Object} OAuthClientDeps
* @property {Function} [fetch] - 注入用 fetch測試用 mock
* @property {Function} [now] - 注入用 Date.now測試用
* @property {Function} [loadConfig] - 注入用 loadConfig測試用避免讀真環境變數
*
* @typedef {Object} OAuthClientConfig
* @property {string} tokenUrl
* @property {string} clientId
* @property {string} clientSecret
* @property {string} faaAudience
* @property {number} refreshSkewMs
* @property {number} timeoutMs
*/
class OAuthClient {
/**
* @param {OAuthClientDeps} [deps]
*/
constructor(deps = {}) {
/** @type {Map<string, CacheEntry>} */
this._cache = new Map();
/** @type {Map<string, Promise<string>>} */
this._inflight = new Map();
this._fetch = deps.fetch || globalThis.fetch;
this._now = deps.now || (() => Date.now());
this._loadConfig = deps.loadConfig || null;
/** @type {OAuthClientConfig|null} */
this._config = null;
}
/**
* Lazy-load config middleware 同模式方便測試
*
* @returns {OAuthClientConfig}
*/
_getConfig() {
if (this._config) return this._config;
const fullConfig = this._loadConfig
? this._loadConfig()
: require('../config').loadConfig();
this._config = {
tokenUrl: fullConfig.memberCenter.tokenUrl,
clientId: fullConfig.converter.clientId,
clientSecret: fullConfig.converter.clientSecret,
faaAudience: fullConfig.fileAccessAgent.audience,
refreshSkewMs: fullConfig.oauthClient.refreshSkewMs,
timeoutMs: fullConfig.oauthClient.timeoutMs,
};
return this._config;
}
/**
* 取得指定 scope service token
*
* 行為
* 1. cache hit token 距離過期還有 > refreshSkewMs 直接回 cached token
* 2. cache miss / 即將過期 request 取新 token
* 3. 同一 scope 同時多個 caller expired token 共享同一個 in-flight Promise
*
* @param {string} scope - 'files:upload.write'
* @returns {Promise<string>} - access token 字串
* @throws {OAuthClientError|OAuthServerError|OAuthTimeoutError}
*/
async getServiceToken(scope) {
if (typeof scope !== 'string' || scope.trim() === '') {
throw new TypeError('[oauthClient] scope is required (non-empty string)');
}
// 1. cache hit 且仍新鮮
const cached = this._cache.get(scope);
const config = this._getConfig();
const nowMs = this._now();
if (cached && cached.expiresAtMs - config.refreshSkewMs > nowMs) {
return cached.accessToken;
}
// 2. in-flight Promise dedup同 scope 並發只發一次 request
const existing = this._inflight.get(scope);
if (existing) {
return existing;
}
// 3. 發新 request
const promise = this._fetchToken(scope, config).finally(() => {
// 不論成功失敗,都要清掉 in-flight 旗標,後續 caller 才有機會再試
this._inflight.delete(scope);
});
this._inflight.set(scope, promise);
return promise;
}
/**
* Member Center 取一個新 token成功時寫 cache
*
* @param {string} scope
* @param {OAuthClientConfig} config
* @returns {Promise<string>}
*/
async _fetchToken(scope, config) {
const body = new URLSearchParams({
grant_type: 'client_credentials',
scope,
audience: config.faaAudience,
}).toString();
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
Authorization: buildBasicAuthHeader(config.clientId, config.clientSecret),
};
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), config.timeoutMs);
let res;
try {
res = await this._fetch(config.tokenUrl, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
} catch (err) {
// AbortError → timeout
const isAbort =
(err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) ||
controller.signal.aborted;
logEvent('WARN', 'oauth.token_fetch_failed', {
scope,
reason: isAbort ? 'timeout' : 'network_error',
// 注意err.message 不會含 secret但保險起見只取訊息開頭
error_message: (err && err.message ? String(err.message) : 'unknown').slice(0, 200),
});
if (isAbort) {
throw new OAuthTimeoutError(
`Token endpoint timed out after ${config.timeoutMs}ms`,
{ retryable: true }
);
}
throw new OAuthTimeoutError(
`Network error contacting token endpoint: ${err && err.message ? err.message.slice(0, 100) : 'unknown'}`,
{ retryable: true }
);
} finally {
clearTimeout(timeoutHandle);
}
// 解析錯誤 / 成功
if (!res.ok) {
const status = res.status;
const errBody = await tryParseOauthErrorBody(res);
const errorCode = errBody && typeof errBody.error === 'string' ? errBody.error : null;
// 不把 errBody.error_description 寫入 log極端 IdP 可能在裡面塞 client_id / requestId 等)
logEvent('WARN', 'oauth.token_endpoint_error', {
scope,
status,
error_code: errorCode || 'unknown',
});
if (status >= 400 && status < 500) {
throw new OAuthClientError(
`Token endpoint returned ${status}${errorCode ? ` (${errorCode})` : ''}`,
{ status, errorCode }
);
}
// 5xx 或其他
throw new OAuthServerError(
`Token endpoint returned ${status}${errorCode ? ` (${errorCode})` : ''}`,
{ status, errorCode }
);
}
let data;
try {
data = await res.json();
} catch (err) {
logEvent('ERROR', 'oauth.token_response_parse_failed', {
scope,
// 不 log raw body可能含 token只 log 解析失敗的 message
error_message: (err && err.message ? String(err.message) : 'unknown').slice(0, 100),
});
throw new OAuthServerError('Failed to parse token response as JSON');
}
const parsed = parseTokenResponse(data); // throws OAuthServerError on shape mismatch
const expiresAtMs = this._now() + parsed.expiresInSec * 1000;
/** @type {CacheEntry} */
const entry = {
accessToken: parsed.accessToken,
expiresAtMs,
};
this._cache.set(scope, entry);
logEvent('INFO', 'oauth.token_obtained', {
scope,
token_type: parsed.tokenType,
expires_in_sec: parsed.expiresInSec,
// 注意:不 log access_token只 log 它的長度(除錯用)
access_token_length: parsed.accessToken.length,
});
return parsed.accessToken;
}
/**
* 強制讓某個 scope cache 失效下一次 `getServiceToken(scope)` 會重新取 token
*
* 使用情境 FAA 401token 已被 revoke server 重啟呼叫端應
* invalidate retry 一次
*
* @param {string} scope
* @returns {void}
*/
invalidate(scope) {
if (typeof scope !== 'string' || scope === '') return;
const had = this._cache.delete(scope);
if (had) {
logEvent('INFO', 'oauth.token_invalidated', { scope });
}
}
/**
* 測試用清空所有 statecache + in-flight
* 生產環境不應呼叫
*
* @returns {void}
*/
_resetForTests() {
this._cache.clear();
this._inflight.clear();
this._config = null;
}
}
// ----------------------------------------------------------------------------
// Module exports
// ----------------------------------------------------------------------------
/**
* Singleton生產用lazy-load config第一次呼叫 `getServiceToken` 才檢查環境變數
*/
const singleton = new OAuthClient();
module.exports = {
// 對外推薦的介面
getServiceToken: (scope) => singleton.getServiceToken(scope),
invalidate: (scope) => singleton.invalidate(scope),
// class 本體(測試 / 進階用法可直接 new
OAuthClient,
// 錯誤類別
OAuthError,
OAuthClientError,
OAuthServerError,
OAuthTimeoutError,
// 測試用內部
_internals: {
buildBasicAuthHeader,
parseTokenResponse,
tryParseOauthErrorBody,
singleton,
},
};

View File

@ -0,0 +1,279 @@
/**
* 集中讀取所有環境變數啟動時 fail fast
*
* 範圍T1/T2 讀取 OAuth / JWKS / Converter 身份 / OAuth Client 相關欄位
* 其他既有欄位PORT, REDIS_URL, MINIO_*, JOB_DATA_DIR 暫時沿用 server.js
* 既有讀法 T4 重構時再合併進來
*
* 設計原則
* - 必填變數缺漏 立刻 throw避免進到 runtime 才爆炸
* - 不在 log 印出任何 secret這個檔不負責 log
* - 對外 export 一個凍結 object避免被改動
*
* 變更歷程
* - T1先把 token URL / client id / client secret optional T1 沒呼叫 token endpoint
* - T2本任務實作 OAuth client TDD §9 將上述三項收緊為必填 D1/D2
* - T10新增 multipart uploadConcurrency D5所有 multipart limit
* per-process upload concurrency 上限由 env 控制避免改原始碼才能調整
*/
'use strict';
require('dotenv').config();
/**
* 讀取必填字串環境變數缺漏即 throw
*
* @param {string} name
* @returns {string}
*/
function requireEnv(name) {
const value = process.env[name];
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(
`[config] Missing required environment variable: ${name}. ` +
`Set it in .env or your deployment environment before starting the service.`
);
}
return value.trim();
}
/**
* 讀取選填字串環境變數可給預設值
*
* @param {string} name
* @param {string} [defaultValue='']
* @returns {string}
*/
function optionalEnv(name, defaultValue = '') {
const value = process.env[name];
if (typeof value !== 'string' || value.trim() === '') {
return defaultValue;
}
return value.trim();
}
/**
* 讀取整數環境變數可給預設值解析失敗即 throw
*
* @param {string} name
* @param {number} defaultValue
* @returns {number}
*/
function optionalIntEnv(name, defaultValue) {
const raw = process.env[name];
if (raw === undefined || raw === null || raw === '') {
return defaultValue;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
throw new Error(
`[config] Environment variable ${name} must be an integer, got: ${JSON.stringify(raw)}`
);
}
return parsed;
}
/**
* 載入並驗證 config回傳凍結 object
*
* 失敗時 throw 呼叫端server entry應在 require 階段就拋出
* process 直接 exitfail fast
*
* @returns {Readonly<{
* memberCenter: { issuer: string, jwksUrl: string, tokenUrl: string },
* converter: {
* audience: string,
* clientId: string,
* clientSecret: string,
* tenantId: string,
* scopeWrite: string,
* scopeRead: string,
* },
* fileAccessAgent: { baseUrl: string, audience: string, promoteTimeoutMs: number },
* jwks: { cacheMaxAgeMs: number, cooldownMs: number, clockToleranceSec: number },
* oauthClient: { refreshSkewMs: number, timeoutMs: number },
* multipart: { modelMaxBytes: number, refImageMaxBytes: number, refImagesMaxCount: number },
* uploadConcurrency: { maxConcurrent: number, retryAfterSeconds: number },
* }>}
*/
function loadConfig() {
// === Member CenterOAuth Authorization Server ===
const mcIssuer = requireEnv('MEMBER_CENTER_ISSUER');
const mcJwksUrl = requireEnv('MEMBER_CENTER_JWKS_URL');
// T2對齊 TDD §9 改為必填。OAuth Client 取 token 必用此 endpoint。
const mcTokenUrl = requireEnv('MEMBER_CENTER_TOKEN_URL');
// === Converter as Resource Server接收他人 token ===
const audience = requireEnv('KNERON_CONVERTER_AUDIENCE');
// === Converter as OAuth Client呼叫 File Access Agent僅 promote 用) ===
// T2對齊 TDD §9 將 client_id / client_secret 收緊為必填。兩者必須成對出現。
const clientId = requireEnv('KNERON_CONVERTER_CLIENT_ID');
const clientSecret = requireEnv('KNERON_CONVERTER_CLIENT_SECRET');
// === Tenant 隔離(可選) ===
const tenantId = optionalEnv('CONVERTER_TENANT_ID', '');
// === Scope 命名(可覆寫,預設值對齊 TDD §8 ===
const scopeWrite = optionalEnv('CONVERTER_SCOPE_WRITE', 'converter:job.write');
const scopeRead = optionalEnv('CONVERTER_SCOPE_READ', 'converter:job.read');
// === File Access AgentT7 起為必填)===
// T7promote 流程已上線FAA URL / audience 必須在啟動時驗證;少了就 fail-fast。
// - URL 必須是合法 http(s) URLNODE_ENV=production 強制 https傳輸保護
// - dev 用 placeholder如 https://REPLACE-ME.invalid也是合法 URL不影響本地啟動
const faaBaseUrl = requireEnv('FILE_ACCESS_AGENT_BASE_URL');
const faaAudience = requireEnv('FILE_ACCESS_AGENT_AUDIENCE');
let faaParsedUrl;
try {
faaParsedUrl = new URL(faaBaseUrl);
} catch (_err) {
throw new Error(
`[config] FILE_ACCESS_AGENT_BASE_URL must be a valid URL, got: ${JSON.stringify(faaBaseUrl)}`
);
}
if (faaParsedUrl.protocol !== 'http:' && faaParsedUrl.protocol !== 'https:') {
throw new Error(
`[config] FILE_ACCESS_AGENT_BASE_URL must use http(s) scheme, got protocol: ${faaParsedUrl.protocol}`
);
}
if (process.env.NODE_ENV === 'production' && faaParsedUrl.protocol !== 'https:') {
throw new Error(
'[config] FILE_ACCESS_AGENT_BASE_URL must use HTTPS in production (NODE_ENV=production)'
);
}
// === Promote 行為T7 用) ===
// 單檔 PUT timeout預設 300s500MB @ 5MB/s 下界),對齊 TDD §6.4。
const promoteTimeoutMs = optionalIntEnv('PROMOTE_TIMEOUT_MS', 300 * 1000);
// === JWKS cache 行為 ===
const jwksCacheMaxAgeMs = optionalIntEnv('JWKS_CACHE_MAX_AGE_MS', 10 * 60 * 1000); // 10 分鐘
const jwksCooldownMs = optionalIntEnv('JWKS_COOLDOWN_MS', 30 * 1000); // 30 秒
const jwtClockToleranceSec = optionalIntEnv('JWT_CLOCK_TOLERANCE_SEC', 60); // 60 秒
// === OAuth Client取 token 用T2===
// refresh skewcache 內 token 距離 expiresAt 還有多少 ms 時就主動 refresh。
// 預設 60s避免 race condition取 token 時剛好過期)。
const oauthRefreshSkewMs = optionalIntEnv('OAUTH_TOKEN_REFRESH_SKEW_MS', 60 * 1000);
// 取 token 的 timeout含網路 RTT + Member Center 處理時間)。
// 預設 10s避免 promote 流程因 token endpoint 慢回應而 hang。
const oauthTimeoutMs = optionalIntEnv('OAUTH_TOKEN_TIMEOUT_MS', 10 * 1000);
// === Multipart 上傳上限T10 修 D5===
// 為什麼用 env不同部署環境記憶體配額差異大dev 容器 2GB / 8 vCPU prod
// 可能 16GB固定的 500MB 不夠彈性。dev / staging 可調降避免 OOM。
//
// - MULTIPART_MODEL_MAX_BYTESmulter 的 per-file fileSize 上限(也作用在 model
// 檔案大小檢查)。預設 500MB對齊 TDD §1.4.2 與 PRD F-01 上限)。
// - MULTIPART_REF_IMAGE_MAX_BYTES單張 ref_image 上限validator 邏輯multer
// 的 fileSize 是「per-file」整體上限無法只限 ref_images。預設 10MB。
// - MULTIPART_REF_IMAGES_MAX_COUNTref_images 張數上限multer fields maxCount
// 參數)。預設 100。
//
// 安全:所有值都做下限檢查(必須 > 0避免 0 / 負數造成 multer reject 全部請求。
const modelMaxBytes = optionalIntEnv(
'MULTIPART_MODEL_MAX_BYTES',
500 * 1024 * 1024
);
if (modelMaxBytes <= 0) {
throw new Error(
`[config] MULTIPART_MODEL_MAX_BYTES must be > 0, got: ${modelMaxBytes}`
);
}
const refImageMaxBytes = optionalIntEnv(
'MULTIPART_REF_IMAGE_MAX_BYTES',
10 * 1024 * 1024
);
if (refImageMaxBytes <= 0) {
throw new Error(
`[config] MULTIPART_REF_IMAGE_MAX_BYTES must be > 0, got: ${refImageMaxBytes}`
);
}
const refImagesMaxCount = optionalIntEnv(
'MULTIPART_REF_IMAGES_MAX_COUNT',
100
);
if (refImagesMaxCount <= 0) {
throw new Error(
`[config] MULTIPART_REF_IMAGES_MAX_COUNT must be > 0, got: ${refImagesMaxCount}`
);
}
// === Upload concurrencyT10 修 D5 second part===
// 為什麼需要 per-process semaphore
// multer 用 memoryStorage每個並發 upload 都會吃 model size 的記憶體;
// 若 5 個並發 × 500MB = 2.5GB heap容器若只有 4GB 立刻 OOM kill。
// per-process counter 限制同時間進行中的 upload 數量。
//
// - MAX_CONCURRENT_UPLOADS同時間最多進行幾個 upload。預設 5保守值覆蓋
// 2.5GB / 5 並發 = 500MB peak heap容器 ≥ 4GB 安全)。
// - UPLOAD_RETRY_AFTER_SECONDS超過時 503 response 帶的 Retry-After 秒數。
// 預設 30s給 client 一個合理的 backoff 起點)。
//
// 為什麼選 503 + Retry-After 而非 queue
// queue 會 hold connection 不確定多久(可能秒級也可能分鐘級),對 client 來說
// timeout 行為不可預期。直接 503 + Retry-After 讓 client 主動 retry符合 12-Factor
// stateless 原則,也更友善。
const maxConcurrentUploads = optionalIntEnv('MAX_CONCURRENT_UPLOADS', 5);
if (maxConcurrentUploads <= 0) {
throw new Error(
`[config] MAX_CONCURRENT_UPLOADS must be > 0, got: ${maxConcurrentUploads}`
);
}
const uploadRetryAfterSeconds = optionalIntEnv(
'UPLOAD_RETRY_AFTER_SECONDS',
30
);
if (uploadRetryAfterSeconds <= 0) {
throw new Error(
`[config] UPLOAD_RETRY_AFTER_SECONDS must be > 0, got: ${uploadRetryAfterSeconds}`
);
}
return Object.freeze({
memberCenter: Object.freeze({
issuer: mcIssuer,
jwksUrl: mcJwksUrl,
tokenUrl: mcTokenUrl,
}),
converter: Object.freeze({
audience,
clientId,
clientSecret,
tenantId,
scopeWrite,
scopeRead,
}),
fileAccessAgent: Object.freeze({
baseUrl: faaBaseUrl,
audience: faaAudience,
promoteTimeoutMs,
}),
jwks: Object.freeze({
cacheMaxAgeMs: jwksCacheMaxAgeMs,
cooldownMs: jwksCooldownMs,
clockToleranceSec: jwtClockToleranceSec,
}),
oauthClient: Object.freeze({
refreshSkewMs: oauthRefreshSkewMs,
timeoutMs: oauthTimeoutMs,
}),
multipart: Object.freeze({
modelMaxBytes,
refImageMaxBytes,
refImagesMaxCount,
}),
uploadConcurrency: Object.freeze({
maxConcurrent: maxConcurrentUploads,
retryAfterSeconds: uploadRetryAfterSeconds,
}),
});
}
module.exports = {
loadConfig,
// 暴露 helpers 供其他 module 重用 / 測試
_internals: { requireEnv, optionalEnv, optionalIntEnv },
};

View File

@ -0,0 +1,879 @@
/**
* File Access Agent client (T7) 單元測試
*
* 範圍對齊 tasks-phase1.md §3.7 驗收
* - 200 happy path單次 PUT 成功 + 解析 etag / size_bytes
* - 4xx 401 FAAClientError不重試
* - 401 invalidate + 重取 token + 重試一次
* - 401 重試後仍 401 FAAUnauthorizedError
* - 5xx 指數退避 500ms / 2000ms 重試最多 2
* - timeout AbortError 視同 5xx 重試
* - network error timeout 路徑
* - streamFactory 每次 attempt 都呼叫重試時拿新 stream
* - URL 組合base + /files/{key}+ encodeURI 行為
* - SECURITYlog 不洩 token / Authorizationerror message 不含 FAA 內部細節
* - 不同 contentType / contentLength header 設置正確
*
* 測試風格與 oauthClient.test.js 一致
* - 依賴注入fetch / setTimeout / oauthClient
* - 不依賴環境變數透過 deps.config 直接傳
*/
'use strict';
const { Readable } = require('stream');
const {
createFaaClient,
DEFAULT_SCOPE,
DEFAULT_TIMEOUT_MS,
RETRY_BACKOFFS_MS,
_internals,
} = require('../client');
const {
FAAClientError,
FAAUnauthorizedError,
FAAServerError,
FAATimeoutError,
} = require('../errors');
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
const TEST_BASE_URL = 'https://files.test.local';
const TEST_TOKEN_1 = 'test-bearer-token-VERY-FIRST-must-stay-private';
const TEST_TOKEN_2 = 'test-bearer-token-SECOND-after-invalidate-private';
/**
* Mock OAuth client可控制每次 getServiceToken 回什麼並紀錄呼叫
*/
function makeMockOauthClient(tokens = [TEST_TOKEN_1]) {
let callCount = 0;
return {
getServiceToken: jest.fn(async () => {
const t = tokens[Math.min(callCount, tokens.length - 1)];
callCount += 1;
return t;
}),
invalidate: jest.fn(),
_callCount: () => callCount,
};
}
/**
* Mock fetchhandlers fn(url, init) Response | { status, body } | throw
*
* 維持與 oauthClient.test.js 相同風格便於跨 testfile 比對
*/
function makeMockFetch(handlers) {
let i = 0;
const calls = [];
const fn = jest.fn(async (url, init) => {
calls.push({ url, init });
const handler = Array.isArray(handlers) ? handlers[i] : handlers;
if (Array.isArray(handlers)) i += 1;
if (typeof handler === 'function') {
return handler(url, init);
}
if (handler instanceof Error) throw handler;
if (handler && typeof handler === 'object' && 'status' in handler) {
return makeMockResponse(handler);
}
throw new Error(`No handler at index ${i - 1}`);
});
fn._calls = calls;
return fn;
}
/**
* 產生一個 fetch 回的 Response-like 物件
*
* @param {{ status: number, body?: object|string|null, headers?: Record<string, string> }} opts
*/
function makeMockResponse({ status, body = null, headers = {} }) {
const lowerHeaders = {};
for (const [k, v] of Object.entries(headers)) {
lowerHeaders[k.toLowerCase()] = String(v);
}
// 預設 content-type
if (body && typeof body === 'object' && !lowerHeaders['content-type']) {
lowerHeaders['content-type'] = 'application/json';
} else if (typeof body === 'string' && !lowerHeaders['content-type']) {
lowerHeaders['content-type'] = 'text/plain';
}
let bodyConsumed = false;
return {
ok: status >= 200 && status < 300,
status,
headers: {
get(name) {
return lowerHeaders[name.toLowerCase()] || null;
},
},
async json() {
if (bodyConsumed) throw new Error('body already consumed');
bodyConsumed = true;
if (body && typeof body === 'object') return body;
throw new Error('not json');
},
async text() {
if (bodyConsumed) throw new Error('body already consumed');
bodyConsumed = true;
if (typeof body === 'string') return body;
if (body && typeof body === 'object') return JSON.stringify(body);
return '';
},
};
}
/**
* 立即執行的 fake setTimeout 不真實等待但會記錄延遲時間以驗證 backoff
*
* 為什麼自寫而非用 jest.useFakeTimers
* - 我們的 client 內部有 sleep 也有 fetch timeout 兩種 setTimeout 用法
* - jest fake timer async 容易打結自寫立即執行 + delay 紀錄較單純
*
* 注意fake timer fn 立即執行所以不會真等 500ms測試比對 delays array
*
* @returns {{ fn: Function, delays: number[] }}
*/
function makeFakeSetTimeout() {
const delays = [];
const fn = jest.fn((cb, ms) => {
delays.push(ms);
// 不立即執行 cb避免 abort 立即被觸發);返回一個 dummy handle
return { _fake: true, _ms: ms, _cb: cb };
});
return { fn, delays };
}
/**
* sleep-only 用的 fake setTimeout立即執行 cb 不需要等真實時間的測試
*/
function makeImmediateSetTimeout() {
const delays = [];
const fn = jest.fn((cb, ms) => {
delays.push(ms);
// 立即觸發(同步)— 對於 sleep cb 是 resolve(),對 fetch abort 不會觸發因為在 finally 已 clear
Promise.resolve().then(cb);
return { _fake: true };
});
return { fn, delays };
}
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ===========================================================================
// 1. Happy path & URL 組合
// ===========================================================================
describe('faaClient.putFile — happy path', () => {
it('PUTs to {baseUrl}/files/{key} with Authorization Bearer header', async () => {
const fetchMock = makeMockFetch([
{ status: 200, body: { etag: 'mock-etag', size_bytes: 1234 } },
]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const stream = Readable.from(['chunk1', 'chunk2']);
const result = await client.putFile(
'visionA/models/u1/m1/v1/out.nef',
async () => stream,
{ contentLength: 1234, contentType: 'application/octet-stream' }
);
expect(result).toEqual({ etag: 'mock-etag', sizeBytes: 1234 });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`${TEST_BASE_URL}/files/visionA/models/u1/m1/v1/out.nef`);
expect(init.method).toBe('PUT');
expect(init.headers.Authorization).toBe(`Bearer ${TEST_TOKEN_1}`);
expect(init.headers['Content-Type']).toBe('application/octet-stream');
expect(init.headers['Content-Length']).toBe('1234');
expect(init.duplex).toBe('half');
expect(oauth.getServiceToken).toHaveBeenCalledWith(DEFAULT_SCOPE);
});
it('encodes URI special chars in object key but preserves slashes', async () => {
const fetchMock = makeMockFetch([{ status: 200, body: {} }]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
await client.putFile(
'foo/bar/檔名 含空白.bin',
async () => Readable.from(['x']),
{ contentLength: 1 }
);
const url = fetchMock.mock.calls[0][0];
// / 應保留;空白應 encode 為 %20中文應 encode
expect(url).toContain('/foo/bar/');
expect(url).toContain('%20');
// .. 字元在 caller 端應已擋(這裡只測 encodeURI 不處理)
});
it('handles trailing slash in baseUrl correctly', async () => {
const fetchMock = makeMockFetch([{ status: 200, body: {} }]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: 'https://files.test.local/' }, // 多斜線
fetch: fetchMock,
});
await client.putFile('a/b.bin', async () => Readable.from(['x']), {
contentLength: 1,
});
expect(fetchMock.mock.calls[0][0]).toBe('https://files.test.local/files/a/b.bin');
});
it('falls back to ETag header + Content-Length when JSON body missing', async () => {
const fetchMock = makeMockFetch([
{
status: 200,
body: '',
headers: { etag: '"hdr-etag"', 'content-length': '5678' },
},
]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
const result = await client.putFile('a.bin', async () => Readable.from(['x']), {
contentLength: 5678,
});
expect(result.etag).toBe('hdr-etag'); // quote stripped
expect(result.sizeBytes).toBe(5678);
});
it('uses provided contentType, defaults to octet-stream', async () => {
const fetchMock = makeMockFetch([
{ status: 200, body: {} },
{ status: 200, body: {} },
]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
await client.putFile('a.bin', async () => Readable.from(['x']), {
contentLength: 1,
contentType: 'application/x-binary',
});
expect(fetchMock.mock.calls[0][1].headers['Content-Type']).toBe('application/x-binary');
await client.putFile('b.bin', async () => Readable.from(['x']), { contentLength: 1 });
expect(fetchMock.mock.calls[1][1].headers['Content-Type']).toBe('application/octet-stream');
});
});
// ===========================================================================
// 2. 4xx (非 401) — 不重試
// ===========================================================================
describe('faaClient.putFile — 4xx non-401', () => {
it('throws FAAClientError on 400 without retry', async () => {
const fetchMock = makeMockFetch([
{ status: 400, body: { error: 'invalid_object_key' } },
]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
).rejects.toBeInstanceOf(FAAClientError);
expect(fetchMock).toHaveBeenCalledTimes(1); // 不重試
// 沒有 backoff sleep
expect(setTimeout.delays.filter((d) => RETRY_BACKOFFS_MS.includes(d))).toHaveLength(0);
});
it('throws FAAClientError on 403 (insufficient_scope) without retry', async () => {
const fetchMock = makeMockFetch([{ status: 403, body: { error: 'insufficient_scope' } }]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
const error = await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch((e) => e);
expect(error).toBeInstanceOf(FAAClientError);
expect(error.status).toBe(403);
expect(error.errorCode).toBe('insufficient_scope');
expect(error.retryable).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('throws FAAClientError on 422 invalid_object_key', async () => {
const fetchMock = makeMockFetch([{ status: 422, body: { error: 'invalid_object_key' } }]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
).rejects.toBeInstanceOf(FAAClientError);
});
});
// ===========================================================================
// 3. 401 → invalidate + 重試一次
// ===========================================================================
describe('faaClient.putFile — 401 unauthorized', () => {
it('invalidates token and retries once on 401, then succeeds', async () => {
const fetchMock = makeMockFetch([
{ status: 401, body: { error: 'invalid_token' } },
{ status: 200, body: { etag: 'after-invalidate', size_bytes: 100 } },
]);
const oauth = makeMockOauthClient([TEST_TOKEN_1, TEST_TOKEN_2]);
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const result = await client.putFile(
'a.bin',
async () => Readable.from(['x']),
{ contentLength: 1 }
);
expect(result.etag).toBe('after-invalidate');
// 第一個 attempt: TEST_TOKEN_1
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(`Bearer ${TEST_TOKEN_1}`);
// 第二個 attempt: TEST_TOKEN_2已 invalidate + 重取)
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(`Bearer ${TEST_TOKEN_2}`);
expect(oauth.invalidate).toHaveBeenCalledTimes(1);
expect(oauth.invalidate).toHaveBeenCalledWith(DEFAULT_SCOPE);
expect(oauth.getServiceToken).toHaveBeenCalledTimes(2);
});
it('throws FAAUnauthorizedError when 401 retry also returns 401', async () => {
const fetchMock = makeMockFetch([
{ status: 401, body: { error: 'invalid_token' } },
{ status: 401, body: { error: 'invalid_token' } },
]);
const oauth = makeMockOauthClient([TEST_TOKEN_1, TEST_TOKEN_2]);
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
const error = await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch((e) => e);
expect(error).toBeInstanceOf(FAAUnauthorizedError);
expect(error.status).toBe(401);
expect(error.retryable).toBe(true); // class-level flag (caller 不再重試)
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(oauth.invalidate).toHaveBeenCalledTimes(1);
});
it('streamFactory called twice for 401 retry (new stream each attempt)', async () => {
const fetchMock = makeMockFetch([
{ status: 401, body: {} },
{ status: 200, body: {} },
]);
const oauth = makeMockOauthClient([TEST_TOKEN_1, TEST_TOKEN_2]);
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
const factory = jest.fn(async () => Readable.from(['data']));
await client.putFile('a.bin', factory, { contentLength: 4 });
expect(factory).toHaveBeenCalledTimes(2);
});
});
// ===========================================================================
// 4. 5xx — 指數退避重試最多 2 次
// ===========================================================================
describe('faaClient.putFile — 5xx server error', () => {
it('retries 5xx twice with backoffs 500ms / 2000ms then succeeds', async () => {
const fetchMock = makeMockFetch([
{ status: 503, body: 'maintenance' },
{ status: 502, body: 'bad gateway' },
{ status: 200, body: { etag: 'ok' } },
]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const result = await client.putFile('a.bin', async () => Readable.from(['x']), {
contentLength: 1,
});
expect(result.etag).toBe('ok');
expect(fetchMock).toHaveBeenCalledTimes(3);
// setTimeout 被呼叫 3 種用途retry sleeps + per-attempt timeout
// 過濾出剛好對應 RETRY_BACKOFFS_MS 的 delay 值500、2000
const backoffs = setTimeout.delays.filter((d) =>
RETRY_BACKOFFS_MS.includes(d)
);
expect(backoffs).toEqual([500, 2000]);
});
it('throws FAAServerError after all 5xx retries exhausted', async () => {
const fetchMock = makeMockFetch([
{ status: 500 },
{ status: 502 },
{ status: 503 },
]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const error = await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch((e) => e);
expect(error).toBeInstanceOf(FAAServerError);
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 + 2 retries
});
it('streamFactory called for each retry (3 times for 2x retry + initial)', async () => {
const fetchMock = makeMockFetch([
{ status: 500 },
{ status: 500 },
{ status: 200, body: {} },
]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const factory = jest.fn(async () => Readable.from(['data']));
await client.putFile('a.bin', factory, { contentLength: 4 });
expect(factory).toHaveBeenCalledTimes(3);
});
});
// ===========================================================================
// 5. timeout / network
// ===========================================================================
describe('faaClient.putFile — timeout / network', () => {
it('throws FAATimeoutError when fetch is aborted (timeout)', async () => {
// 自製一個會 throw AbortError 的 fetch
const fetchMock = jest.fn(async () => {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
});
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
// 為避免 retry 導致 promise stuck明確設只跑一次後續測試處理 retry
retryBackoffsMs: [],
});
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
).rejects.toBeInstanceOf(FAATimeoutError);
});
it('retries network errors (treated as timeout) up to 2 times', async () => {
const networkErr = new Error('ECONNREFUSED');
networkErr.code = 'ECONNREFUSED';
const fetchMock = jest.fn();
fetchMock
.mockImplementationOnce(async () => {
throw networkErr;
})
.mockImplementationOnce(async () => {
throw networkErr;
})
.mockImplementationOnce(async () => makeMockResponse({ status: 200, body: {} }));
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const result = await client.putFile('a.bin', async () => Readable.from(['x']), {
contentLength: 1,
});
expect(result).toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('does not leak token / hostname in error message', async () => {
const networkErr = new Error('connect ECONNREFUSED 192.168.99.99:443');
networkErr.code = 'ECONNREFUSED';
const fetchMock = jest.fn(async () => {
throw networkErr;
});
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
retryBackoffsMs: [], // 不重試,加速測試
});
const error = await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch((e) => e);
expect(error.message).not.toContain('192.168.99.99');
expect(error.message).not.toContain('ECONNREFUSED');
expect(error.message).not.toContain(TEST_TOKEN_1);
});
});
// ===========================================================================
// 6. Input validation
// ===========================================================================
describe('faaClient.putFile — input validation', () => {
it('throws TypeError when objectKey is empty', async () => {
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: jest.fn(),
});
await expect(
client.putFile('', async () => Readable.from(['x']), { contentLength: 1 })
).rejects.toBeInstanceOf(TypeError);
});
it('throws TypeError when streamFactory not a function', async () => {
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: jest.fn(),
});
await expect(
client.putFile('a.bin', null, { contentLength: 1 })
).rejects.toBeInstanceOf(TypeError);
});
it('throws TypeError when contentLength is missing or invalid', async () => {
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: jest.fn(),
});
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), {})
).rejects.toBeInstanceOf(TypeError);
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: -1 })
).rejects.toBeInstanceOf(TypeError);
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 'big' })
).rejects.toBeInstanceOf(TypeError);
});
it('throws when oauthClient missing in createFaaClient', () => {
expect(() => createFaaClient({})).toThrow(/oauthClient is required/);
});
it('throws when baseUrl missing at first call', async () => {
const oauth = makeMockOauthClient();
const fetchMock = jest.fn();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: '' }, // 空字串
fetch: fetchMock,
});
await expect(
client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
).rejects.toThrow(/FILE_ACCESS_AGENT_BASE_URL not configured/);
});
});
// ===========================================================================
// 7. SECURITY — token / Authorization header 不洩漏
// ===========================================================================
describe('faaClient.putFile — SECURITY (no secret leak)', () => {
/**
* 收集所有 spy log strings用於 grep 是否含 token
*/
function collectAllLoggedStrings() {
const allCalls = [
...console.log.mock.calls,
...console.warn.mock.calls,
...console.error.mock.calls,
];
return allCalls.flatMap((args) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))));
}
beforeEach(() => {
console.log.mockClear();
console.warn.mockClear();
console.error.mockClear();
});
it('does not log Authorization header / token on success', async () => {
const fetchMock = makeMockFetch([{ status: 200, body: { etag: 'x' } }]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
await client.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 });
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_TOKEN_1);
expect(line).not.toContain('Bearer');
expect(line).not.toContain('Authorization');
}
});
it('does not log token on 4xx error path', async () => {
const fetchMock = makeMockFetch([{ status: 403, body: { error: 'forbidden' } }]);
const oauth = makeMockOauthClient();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
});
await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch(() => {});
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_TOKEN_1);
}
});
it('does not log token on 5xx error path', async () => {
const fetchMock = makeMockFetch([{ status: 500 }, { status: 500 }, { status: 500 }]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch(() => {});
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(TEST_TOKEN_1);
}
});
it('does not log target_object_key on success or error (even though it is not a secret)', async () => {
// Phase 1 不 log key 內容(避免大量 PII / 內部 path 進 log只 log 長度
const fetchMock = makeMockFetch([{ status: 500 }, { status: 500 }, { status: 500 }]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
});
const sensitiveKey = 'visionA/internal-secret-path/file.bin';
await client
.putFile(sensitiveKey, async () => Readable.from(['x']), { contentLength: 1 })
.catch(() => {});
const allLogs = collectAllLoggedStrings();
for (const line of allLogs) {
expect(line).not.toContain(sensitiveKey);
}
});
it('error message does not include FAA response body content', async () => {
const sensitiveErrorBody = 'INTERNAL: connect to db at internal-db.faa.local:5432 failed';
const fetchMock = makeMockFetch([{ status: 500, body: sensitiveErrorBody }]);
const oauth = makeMockOauthClient();
const setTimeout = makeImmediateSetTimeout();
const client = createFaaClient({
oauthClient: oauth,
config: { baseUrl: TEST_BASE_URL },
fetch: fetchMock,
setTimeoutFn: setTimeout.fn,
retryBackoffsMs: [],
});
const error = await client
.putFile('a.bin', async () => Readable.from(['x']), { contentLength: 1 })
.catch((e) => e);
expect(error.message).not.toContain('internal-db.faa.local');
expect(error.message).not.toContain('5432');
// message 應該只含 status code如 'FAA returned 500'
expect(error.message).toMatch(/FAA returned 500/);
});
});
// ===========================================================================
// 8. _internals helpers
// ===========================================================================
describe('faaClient._internals', () => {
describe('readSuccessMeta', () => {
it('parses JSON etag + size_bytes', async () => {
const res = makeMockResponse({
status: 200,
body: { etag: 'json-etag', size_bytes: 100 },
});
const meta = await _internals.readSuccessMeta(res);
expect(meta).toEqual({ etag: 'json-etag', sizeBytes: 100 });
});
it('falls back to ETag header when JSON parse fails', async () => {
const res = makeMockResponse({
status: 200,
body: 'not json',
headers: { etag: '"hdr-etag"', 'content-length': '42' },
});
const meta = await _internals.readSuccessMeta(res);
expect(meta.etag).toBe('hdr-etag');
expect(meta.sizeBytes).toBe(42);
});
it('returns nulls when no metadata available', async () => {
const res = makeMockResponse({ status: 200, body: '' });
const meta = await _internals.readSuccessMeta(res);
expect(meta).toEqual({ etag: null, sizeBytes: null });
});
});
describe('isAbortLike', () => {
it('detects AbortError name', () => {
const err = new Error('abort');
err.name = 'AbortError';
const signal = { aborted: false };
expect(_internals.isAbortLike(err, signal)).toBe(true);
});
it('detects ABORT_ERR code', () => {
const err = new Error();
err.code = 'ABORT_ERR';
expect(_internals.isAbortLike(err, { aborted: false })).toBe(true);
});
it('detects via signal.aborted when err lacks markers', () => {
expect(_internals.isAbortLike(new Error('x'), { aborted: true })).toBe(true);
expect(_internals.isAbortLike(new Error('x'), { aborted: false })).toBe(false);
});
it('returns false for plain network err with aborted signal=false', () => {
const err = new Error('net');
err.code = 'ECONNREFUSED';
expect(_internals.isAbortLike(err, { aborted: false })).toBe(false);
});
});
describe('normalizeStreamBody', () => {
it('returns Node Readable converted to web ReadableStream', () => {
const node = Readable.from(['x']);
const result = _internals.normalizeStreamBody(node);
// Node 18+ Readable.toWeb 回 web ReadableStream
expect(result).toBeDefined();
expect(typeof result.getReader).toBe('function');
});
it('passes-through web ReadableStream', () => {
const web = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
const result = _internals.normalizeStreamBody(web);
expect(result).toBe(web);
});
});
});

View File

@ -0,0 +1,563 @@
/**
* File Access Agent (FAA) HTTP client Phase 1 PUT /files/{key}promote
*
* 對外介面
* const faa = createFaaClient({ config, oauthClient });
* const meta = await faa.putFile(targetObjectKey, stream, { contentLength, contentType });
*
* 設計原則對齊 TDD §2.5 / §6.3 / §6.5
*
* 1. **Stream-based body**
* - body stream 而非 buffer避免 1GB 大檔吃光記憶體
* - Node 18+ 原生 fetch 接受 stream body 但需要 `duplex: 'half'`
* - stream Node Readable `Readable.toWeb()` web streamfetch 需要
*
* 2. **重試矩陣**嚴格對齊 TDD §6.3 + tasks-phase1.md §2 T7
*
* | 觸發 | 行為 |
* |-----------------|----------------------------------------------------|
* | 4xx 401 | 不重試 throw FAAClientError |
* | 401 | invalidate(scope) + 重取 token + 重試 1 401 throw FAAUnauthorizedError |
* | 5xx | 指數退避 500ms / 2000ms 重試最多 2 全失敗 throw FAAServerError |
* | timeout / network| 5xx 處理 最後 throw FAATimeoutError |
*
* 3. **Stream 不可重試的限制**
* - HTTP body 一旦消費就無法 replay如果第一次 PUT 失敗5xx / network
* retry必須 caller retry **重新從 MinIO 取一次 stream**
* - 為了讓 retry 真的可行client 介面接受 `streamFactory: () => Promise<stream>`
* 而非 stream 本身每次 attempt 才呼叫 factory 取新 stream
* - 同樣的方式處理 401 重試
*
* 4. **Token 注入**
* - client 不直接讀 config.faaScope caller 透過 oauthClient 控制 scope
* - 預設 scope = 'files:upload.write'Phase 1 唯一
*
* 5. **Timeout**
* - PUT 單檔 timeout AbortController預設 300s500MB @ 最壞 5MB/sTDD §6.4 + tasks
* §2 T7 規定 `PROMOTE_TIMEOUT_MS=300000`
* - timeout 視同 5xx 重試
* - caller 透過 `deps.timeoutMs` 注入 / 覆寫server.js 端從 env `PROMOTE_TIMEOUT_MS`
* 讀取後透傳達成設定與程式碼分離
*
* 6. **SSRF 防護**
* - FAA URL 只從 config KNERON FILE_ACCESS_AGENT_BASE_URL不接受 client
* - target_object_key callerpromote handler sanity check `..` `\\`
*
* 7. **不洩露**
* - log 不含 token / Authorization / response body
* - error message 不含 FAA 內部錯誤細節caller 轉成 502 file_gateway_unavailable 給外部
*
* 對齊 OAuth client (T2) 的測試友善設計
* - 依賴注入 (`fetch` / `oauthClient` / `config` / `now` / `setTimeout`)
* - Lazy-load config 不在 require 階段炸環境變數
*/
'use strict';
/* eslint-disable no-console */
const { Readable } = require('stream');
const {
FAAClientError,
FAAUnauthorizedError,
FAAServerError,
FAATimeoutError,
} = require('./errors');
// ----------------------------------------------------------------------------
// 常數
// ----------------------------------------------------------------------------
/** Phase 1 唯一 scopeTDD §8.2)。 */
const DEFAULT_SCOPE = 'files:upload.write';
/**
* 預設 PUT timeout300s 對齊 TDD §6.4PUT /files/{key}依檔案大小動態預設 300s
* 500MB @ 最壞 5MB/s tasks-phase1.md §2 T7PROMOTE_TIMEOUT_MS=300000
*
* 上層 server.js 應從 env 讀取 `PROMOTE_TIMEOUT_MS` 並透過 `deps.timeoutMs` 覆寫此預設
*/
const DEFAULT_TIMEOUT_MS = 300 * 1000;
/** 5xx / timeout 的重試 backoffms— TDD §6.3500ms / 2000ms。 */
const RETRY_BACKOFFS_MS = [500, 2000];
// ----------------------------------------------------------------------------
// 內部 helpers
// ----------------------------------------------------------------------------
/**
* 結構化 log不洩露 token / body
*
* @param {'INFO'|'WARN'|'ERROR'} level
* @param {string} action
* @param {object} fields
*/
function logEvent(level, action, fields = {}) {
const line = JSON.stringify({
level,
service: 'faa-client',
action,
timestamp: new Date().toISOString(),
...fields,
});
if (level === 'ERROR') {
console.error(line);
} else if (level === 'WARN') {
console.warn(line);
} else {
console.log(line);
}
}
/**
* 簡易 sleep測試可注入 setTimeout
*
* @param {number} ms
* @param {Function} [setTimeoutFn]
*/
function sleep(ms, setTimeoutFn) {
const setTimeoutImpl = setTimeoutFn || globalThis.setTimeout;
return new Promise((resolve) => setTimeoutImpl(resolve, ms));
}
/**
* Node Readable web ReadableStream 統一成fetch 能接受的 body 型別
*
* Node 18+ 原生 fetch 可接受
* - web ReadableStream首選
* - Node Readable + `duplex: 'half'`部分版本
* - Buffer / string / FormData
*
* 為了相容性遇到 Node Readable 就轉 web streamReadable.toWeb
*
* @param {NodeJS.ReadableStream | ReadableStream | unknown} input
* @returns {ReadableStream | NodeJS.ReadableStream | unknown}
*/
function normalizeStreamBody(input) {
if (!input) return input;
// 已經是 web ReadableStream → 直接用
if (typeof input === 'object' && typeof input.getReader === 'function') {
return input;
}
// Node Readable → 轉 web
if (input instanceof Readable) {
return Readable.toWeb(input);
}
// 其他Buffer / string— fetch 自己處理
return input;
}
/**
* 嘗試從 FAA 200 response metadataetag / size
*
* FAA 規格 TDD §1.4.5 期望回 `file_access_agent_etag` size因此優先讀 JSON
* FAA 不回 JSON parse 失敗fallback ETag header也不影響主流程
*
* @param {Response} res
* @returns {Promise<{ etag: string|null, sizeBytes: number|null }>}
*/
async function readSuccessMeta(res) {
let etag = null;
let sizeBytes = null;
// 1. 優先嘗試 JSON body
try {
const ctype = res.headers.get('content-type') || '';
if (ctype.includes('application/json')) {
const data = await res.json();
if (data && typeof data === 'object') {
if (typeof data.etag === 'string') etag = data.etag;
if (typeof data.size_bytes === 'number') sizeBytes = data.size_bytes;
if (typeof data.size === 'number' && sizeBytes == null) sizeBytes = data.size;
}
}
} catch (_) {
/* fallback to header */
}
// 2. fallbackHTTP standard headers
if (!etag) {
const headerEtag = res.headers.get('etag');
if (headerEtag) etag = headerEtag.replace(/^"|"$/g, '');
}
if (sizeBytes == null) {
const cl = res.headers.get('content-length');
if (cl) {
const parsed = Number.parseInt(cl, 10);
if (Number.isFinite(parsed) && parsed >= 0) sizeBytes = parsed;
}
}
return { etag, sizeBytes };
}
/**
* fetch 異常判斷是否為 timeout / abort
*
* @param {unknown} err
* @param {AbortSignal} signal
*/
function isAbortLike(err, signal) {
if (!err) return Boolean(signal && signal.aborted);
if (typeof err !== 'object') return Boolean(signal && signal.aborted);
const e = /** @type {{ name?: string, code?: string }} */ (err);
if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return true;
return Boolean(signal && signal.aborted);
}
// ----------------------------------------------------------------------------
// FAA Client
// ----------------------------------------------------------------------------
/**
* 建立一個 FAA client instance
*
* @typedef {Object} FAAClientDeps
* @property {{ getServiceToken: (scope: string) => Promise<string>, invalidate: (scope: string) => void }} oauthClient
* @property {{ baseUrl: string }} [config] - 注入測試用 config覆寫環境變數
* @property {Function} [fetch] - 注入用 fetch測試用 mock
* @property {Function} [setTimeoutFn] - 注入用 setTimeout**僅供 retry sleep **
* attemptPut 內的 fetch timeout 一律用真實 setTimeout
* 以避免測試的 fake-timer 立即觸發 abort
* @property {Function} [now] - 注入用 Date.now
* @property {string} [scope] - 預設 scope覆寫 DEFAULT_SCOPE
* @property {number} [timeoutMs] - 預設 PUT timeout覆寫 DEFAULT_TIMEOUT_MS
* @property {number[]} [retryBackoffsMs] - 覆寫 5xx / timeout backoff 序列
*
* @param {FAAClientDeps} deps
* @returns {{ putFile: (objectKey: string, streamFactory: Function, opts: { contentLength: number, contentType?: string }) => Promise<{ etag: string|null, sizeBytes: number|null }> }}
*/
function createFaaClient(deps) {
if (!deps || !deps.oauthClient) {
throw new Error('[faaClient] deps.oauthClient is required');
}
const oauthClient = deps.oauthClient;
const fetchImpl = deps.fetch || globalThis.fetch;
const setTimeoutFn = deps.setTimeoutFn || globalThis.setTimeout;
const scope = deps.scope || DEFAULT_SCOPE;
const timeoutMs =
Number.isInteger(deps.timeoutMs) && deps.timeoutMs > 0
? deps.timeoutMs
: DEFAULT_TIMEOUT_MS;
// 允許 `[]` 代表「不重試」(測試常用);只有 undefined / 非陣列才 fallback 預設
const retryBackoffs = Array.isArray(deps.retryBackoffsMs)
? deps.retryBackoffsMs
: RETRY_BACKOFFS_MS;
// Lazy-load config測試/正式統一)
let cachedConfig = deps.config || null;
function getConfig() {
if (cachedConfig) return cachedConfig;
const fullConfig = require('../config').loadConfig();
cachedConfig = { baseUrl: fullConfig.fileAccessAgent.baseUrl };
return cachedConfig;
}
/**
* PUT URLbase + /files/{encodedKey}
*
* 為什麼用 encodeURI 而非 encodeURIComponent
* - target_object_key 預期含 `/`路徑分隔不該被 encode %2F
* - `..` `?` `#` 等危險字元 caller 端要先擋promote handler sanity check
* - encodeURI encode 空白 / 中文等但保留 `/` `:` `@` 等合法 path 字元
*
* @param {string} baseUrl
* @param {string} objectKey
*/
function buildUrl(baseUrl, objectKey) {
const trimmed = baseUrl.replace(/\/+$/, '');
// 不對 objectKey 做 leading slash 處理caller 已驗格式)
return `${trimmed}/files/${encodeURI(objectKey)}`;
}
/**
* 一次 PUT 嘗試不含重試邏輯
*
* @param {string} objectKey
* @param {Function} streamFactory
* @param {{ contentLength: number, contentType?: string }} opts
* @param {string} bearerToken
* @returns {Promise<Response>}
* @throws {FAATimeoutError} 網路 / timeout
*/
async function attemptPut(objectKey, streamFactory, opts, bearerToken) {
const config = getConfig();
if (!config.baseUrl) {
throw new Error(
'[faaClient] FILE_ACCESS_AGENT_BASE_URL not configured; cannot perform promote'
);
}
const url = buildUrl(config.baseUrl, objectKey);
const stream = await streamFactory();
const body = normalizeStreamBody(stream);
const controller = new AbortController();
// ★ 重要fetch 的 timeout 一律用真實 setTimeout不走注入版
// 為什麼:測試常用 fake setTimeout 立即觸發 cb若 attemptPut 內也走 fake
// 版本,每次呼叫一進去就被 abort根本走不到 fetch。
// 真實 setTimeout 在測試中也安全fetch mock 通常同步回 response
// 不會等到 timeoutMs30s才觸發 abort。
const timeoutHandle = globalThis.setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetchImpl(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': opts.contentType || 'application/octet-stream',
'Content-Length': String(opts.contentLength),
},
body,
// Node 18+ stream body 必要旗標
duplex: 'half',
signal: controller.signal,
});
} catch (err) {
const aborted = isAbortLike(err, controller.signal);
if (aborted) {
throw new FAATimeoutError(`PUT to FAA timed out after ${timeoutMs}ms`);
}
// network errorDNS、ECONNREFUSED 等)—— 視同 timeout 重試
// 不把 err.message 寫進 thrown 訊息,避免洩漏 FAA hostname / port
throw new FAATimeoutError('Network error contacting FAA');
} finally {
// 不論成功失敗都清 timer
try {
globalThis.clearTimeout(timeoutHandle);
} catch (_) {
/* noop */
}
}
return res;
}
/**
* 把非 OK response 轉成對應的 FAAError
*
* @param {Response} res
*/
async function classifyError(res) {
const status = res.status;
// 嘗試讀 body 給 log不放進 error message — 避免洩漏內部資訊給 v1 client
let bodyHint = null;
try {
const ctype = res.headers.get('content-type') || '';
if (ctype.includes('application/json')) {
const data = await res.json();
if (data && typeof data === 'object') {
if (typeof data.error === 'string') bodyHint = data.error;
else if (typeof data.code === 'string') bodyHint = data.code;
}
} else {
const txt = await res.text();
if (txt) bodyHint = txt.slice(0, 100);
}
} catch (_) {
/* parse 失敗就算了 */
}
if (status === 401) {
return new FAAUnauthorizedError(`FAA returned 401 (token rejected)`, {
status,
errorCode: bodyHint || null,
});
}
if (status >= 400 && status < 500) {
return new FAAClientError(`FAA returned ${status}`, {
status,
errorCode: bodyHint || null,
});
}
// 5xx 或其他
return new FAAServerError(`FAA returned ${status}`, {
status,
errorCode: bodyHint || null,
});
}
/**
* 把結果檔 PUT FAA含完整重試 / 401 invalidate / timeout 邏輯
*
* 重試邏輯總結
* - **5xx / timeout / network**消耗一次 attempt retryBackoffsMs 退避重試
* - **401**呼叫 `oauthClient.invalidate(scope)` + 重取 token + 重試 1
* 此次 401 重試**不消耗** attempt透過 `attempt -= 1` 抵銷迴圈遞增
*
* **最壞情況 attempt 次數**1 (initial) + 1 (401 retry) + 2 (5xx retries) = **4 PUT**
* - 例如attempt #1 401 invalidate + 重取 token attempt #2 5xx
* 退避 500ms attempt #3 5xx 退避 2000ms attempt #4 5xx throw FAAServerError
* - FAA 而言多 1 次大檔上傳是可接受的因為 401 再連續 5xx的機率極低
* 正常 401 的成因如 token rotation 不會同時造成 server 5xx
* - 若未來觀測到此 worst case FAA 帶寬有壓力可改為401 重試也消耗 attempt
*
* @param {string} objectKey - 目標 NAS object keycaller sanity check
* @param {() => Promise<NodeJS.ReadableStream | ReadableStream>} streamFactory
* 每次 attempt 才呼叫回傳新 streamHTTP body 不可 replay
* @param {{ contentLength: number, contentType?: string }} opts
* @returns {Promise<{ etag: string|null, sizeBytes: number|null }>}
* @throws {FAAClientError|FAAUnauthorizedError|FAAServerError|FAATimeoutError}
*/
async function putFile(objectKey, streamFactory, opts) {
if (typeof objectKey !== 'string' || objectKey === '') {
throw new TypeError('[faaClient.putFile] objectKey is required (non-empty string)');
}
if (typeof streamFactory !== 'function') {
throw new TypeError('[faaClient.putFile] streamFactory must be a function');
}
if (
!opts ||
typeof opts.contentLength !== 'number' ||
!Number.isFinite(opts.contentLength) ||
opts.contentLength < 0
) {
throw new TypeError('[faaClient.putFile] opts.contentLength must be a non-negative number');
}
let token = await oauthClient.getServiceToken(scope);
// 重試迴圈:最多 1 (initial) + retryBackoffs.length (5xx 重試) 次
// 401 重試是「獨立一次」(不消耗 5xx attempt 配額)。
//
// 因此最壞情況 PUT 總次數 = maxAttempts + 1401 重試)= 4 次:
// attempt #1 (401) → invalidate token → attempt #2 (5xx) → backoff →
// attempt #3 (5xx) → backoff → attempt #4 (5xx) → throw
//
// 詳見 putFile docblock 的「最壞情況 attempt 次數」說明。
let unauthorizedRetried = false;
const maxAttempts = 1 + retryBackoffs.length;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
let res;
try {
res = await attemptPut(objectKey, streamFactory, opts, token);
} catch (err) {
// network error / timeout已經是 FAATimeoutError
if (err instanceof FAATimeoutError) {
if (attempt < retryBackoffs.length) {
// 還能重試
logEvent('WARN', 'faa.put_failed_retry', {
object_key_length: objectKey.length, // 不 log key 本身
attempt: attempt + 1,
reason: 'timeout_or_network',
backoff_ms: retryBackoffs[attempt],
});
await sleep(retryBackoffs[attempt], setTimeoutFn);
continue;
}
// 用完重試 → throw
logEvent('ERROR', 'faa.put_failed_final', {
object_key_length: objectKey.length,
attempt: attempt + 1,
reason: 'timeout_or_network',
});
throw err;
}
// 其他類型例外(如 streamFactory 拋出)— 不重試,往上拋
throw err;
}
// 成功 path
if (res.ok) {
const meta = await readSuccessMeta(res);
logEvent('INFO', 'faa.put_success', {
object_key_length: objectKey.length,
status: res.status,
attempt: attempt + 1,
size_bytes: meta.sizeBytes,
});
return meta;
}
// 失敗 — 分類
const err = await classifyError(res);
// 401先 invalidate 再重試一次
if (err instanceof FAAUnauthorizedError) {
if (unauthorizedRetried) {
// 已重試過一次,仍 401 → 不再嘗試
logEvent('ERROR', 'faa.put_unauthorized_after_retry', {
object_key_length: objectKey.length,
status: 401,
attempt: attempt + 1,
});
throw err;
}
unauthorizedRetried = true;
logEvent('WARN', 'faa.put_unauthorized_invalidate', {
object_key_length: objectKey.length,
attempt: attempt + 1,
});
oauthClient.invalidate(scope);
token = await oauthClient.getServiceToken(scope);
// 不消耗 attempt 數401 重試獨立)
// ★ 副作用:若 401 後又遇 5xx5xx 重試仍會走完整 retryBackoffs 配額。
// 最壞情況 PUT 總次數 4 次(見 putFile docblock。對 FAA 多 1 次大檔上傳可接受,
// 因為「先 401 再連續 5xx」是極端罕見場景。
attempt -= 1;
continue;
}
// 4xx 非 401 — 不重試
if (err instanceof FAAClientError) {
logEvent('WARN', 'faa.put_client_error', {
object_key_length: objectKey.length,
status: err.status,
attempt: attempt + 1,
});
throw err;
}
// 5xx — 重試
if (err instanceof FAAServerError) {
if (attempt < retryBackoffs.length) {
logEvent('WARN', 'faa.put_failed_retry', {
object_key_length: objectKey.length,
attempt: attempt + 1,
reason: 'server_error',
status: err.status,
backoff_ms: retryBackoffs[attempt],
});
await sleep(retryBackoffs[attempt], setTimeoutFn);
continue;
}
// 用完重試 → throw
logEvent('ERROR', 'faa.put_failed_final', {
object_key_length: objectKey.length,
attempt: attempt + 1,
reason: 'server_error',
status: err.status,
});
throw err;
}
// fallback不該發生
throw err;
}
// 不該走到這裡(迴圈內必 return / throw
throw new FAAServerError('FAA putFile exhausted retries unexpectedly');
}
return { putFile };
}
module.exports = {
createFaaClient,
// 常數對外暴露便於測試 / 調整
DEFAULT_SCOPE,
DEFAULT_TIMEOUT_MS,
RETRY_BACKOFFS_MS,
// 測試暴露
_internals: {
normalizeStreamBody,
readSuccessMeta,
isAbortLike,
sleep,
},
};

View File

@ -0,0 +1,96 @@
/**
* File Access Agent (FAA) client 錯誤類別
*
* 對齊 OAuth client (T2) 的設計風格
* - 三類錯誤對應 TDD §6.3 的重試決策矩陣
* - `retryable` flag 強制覆寫呼叫端只看 `instanceof` `err.retryable` 即可
*
* 重試決策矩陣TDD §6.3
*
* | HTTP / 異常 | Error class | retryable |
* |------------|----------------------|-----------|
* | 4xx 401 | FAAClientError | false |
* | 401 | FAAUnauthorizedError | true (一次) token invalidate + 重試 |
* | 5xx | FAAServerError | true (兩次)|
* | timeout / network | FAATimeoutError | true (兩次)|
*
* 為什麼 401 獨立成一類而不是吞進 FAAClientError
* 401 的處理流程不同於其他 4xx 必須先 oauthClient.invalidate(scope) 拿新 token
* 再重試一次把它從 FAAClientError 拆開 client.js 可用 instanceof 精準分流
* 也讓 caller 一眼看出401 是一個特例
*
* 安全
* - message status / errorCode 都不應含 token / Authorization 內容
* - FAA 回傳的 response body可能含內部錯誤細節**不直接放進 message**只取
* 固定的 status code + 預設文案避免回給 visionA-backend 時洩露內部資訊
*/
'use strict';
/**
* FAA 共用基類所有 FAA 錯誤都應繼承自此類
*/
class FAAError extends Error {
/**
* @param {string} name
* @param {string} message
* @param {{ status?: number, errorCode?: string|null, retryable?: boolean }} [meta]
*/
constructor(name, message, meta = {}) {
super(message);
this.name = name;
this.status = typeof meta.status === 'number' ? meta.status : null;
this.errorCode = meta.errorCode || null;
this.retryable = meta.retryable === true;
}
}
/**
* 4xx 401 FAA 回的 client 錯誤**不可重試**
*
* 例如 target_object_key 不合法scope 不足等caller 應直接轉 502
* `file_gateway_unavailable` v1 client不洩漏 FAA 內部 error_code 細節
*/
class FAAClientError extends FAAError {
constructor(message, meta) {
super('FAAClientError', message, { ...meta, retryable: false });
}
}
/**
* 401 token 失效**可重試一次** oauthClient.invalidate(scope) 再重發
* 重試仍 401 caller 應轉 503 `auth_service_unavailable`
*/
class FAAUnauthorizedError extends FAAError {
constructor(message, meta) {
super('FAAUnauthorizedError', message, { ...meta, retryable: true });
}
}
/**
* 5xx FAA server 錯誤**可重試最多 2 **指數退避 500ms / 2000ms
* 全失敗 caller 應轉 502 `file_gateway_unavailable`
*/
class FAAServerError extends FAAError {
constructor(message, meta) {
super('FAAServerError', message, { ...meta, retryable: true });
}
}
/**
* 網路 / timeout 連線層錯誤**可重試最多 2 ** 5xx 處理
* 全失敗 caller 應轉 502 `file_gateway_unavailable`
*/
class FAATimeoutError extends FAAError {
constructor(message, meta) {
super('FAATimeoutError', message, { ...meta, retryable: true });
}
}
module.exports = {
FAAError,
FAAClientError,
FAAUnauthorizedError,
FAAServerError,
FAATimeoutError,
};

View File

@ -0,0 +1,297 @@
/**
* Unit tests for src/middleware/errorHandler.js
*
* 測試重點
* 1. ApiError 物件被展開為 status / code / message / details
* 2. 未預期錯誤統一變成 500 internal_error****洩漏 stack / message
* 3. response body 包含 request_id req.requestId
* 4. headersSent 時不重複寫
* 5. log 呼叫包含 request_id 與正確 level
*/
'use strict';
const { ApiError, errorHandler } = require('../errorHandler');
/**
* 建一組 req / res / next模擬 Express error handler 介面
*/
function makeReqResNext(reqOverrides = {}) {
const req = {
method: 'GET',
originalUrl: '/api/v1/test',
requestId: 'req-test-001',
...reqOverrides,
};
const res = {
headersSent: false,
statusCode: 200,
body: null,
status: jest.fn(function statusImpl(code) {
res.statusCode = code;
return res;
}),
json: jest.fn(function jsonImpl(body) {
res.body = body;
res.headersSent = true;
return res;
}),
};
const next = jest.fn();
return { req, res, next };
}
// 抑制 errorHandler 內部的 structured log避免測試輸出嘈雜
let _origWarn;
let _origError;
beforeAll(() => {
_origWarn = console.warn;
_origError = console.error;
// 用 jest.fn 包起來,後面可以斷言被呼叫過
});
afterAll(() => {
console.warn = _origWarn;
console.error = _origError;
});
beforeEach(() => {
// 每個 test 前 mock 掉 console使其可被 spy
console.warn = jest.fn();
console.error = jest.fn();
});
// ---------------------------------------------------------------------------
// ApiError class
// ---------------------------------------------------------------------------
describe('ApiError', () => {
it('extends Error and carries status/code/message', () => {
const err = new ApiError(409, 'user_has_active_job', '已有進行中的 job');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ApiError);
expect(err.name).toBe('ApiError');
expect(err.status).toBe(409);
expect(err.code).toBe('user_has_active_job');
expect(err.message).toBe('已有進行中的 job');
});
it('omits details when not provided', () => {
const err = new ApiError(404, 'job_not_found', 'not found');
expect(err.details).toBeUndefined();
// 確保 details key 沒被加入到物件
expect(Object.prototype.hasOwnProperty.call(err, 'details')).toBe(false);
});
it('preserves details when provided', () => {
const err = new ApiError(403, 'insufficient_scope', '權限不足', {
required_scope: 'converter:job.write',
provided_scopes: ['converter:job.read'],
});
expect(err.details).toEqual({
required_scope: 'converter:job.write',
provided_scopes: ['converter:job.read'],
});
});
it('preserves stack trace', () => {
const err = new ApiError(500, 'internal_error', 'oops');
expect(typeof err.stack).toBe('string');
expect(err.stack).toContain('ApiError');
});
});
// ---------------------------------------------------------------------------
// errorHandler — ApiError handling
// ---------------------------------------------------------------------------
describe('errorHandler — ApiError 預期錯誤', () => {
it('uses status/code/message from ApiError', () => {
const err = new ApiError(501, 'not_implemented', '尚未實作');
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(501);
expect(res.body).toEqual({
error: {
code: 'not_implemented',
message: '尚未實作',
request_id: 'req-test-001',
},
});
expect(next).not.toHaveBeenCalled();
});
it('includes details when ApiError has them', () => {
const err = new ApiError(403, 'insufficient_scope', '權限不足', {
required_scope: 'converter:job.write',
});
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(res.body.error.details).toEqual({
required_scope: 'converter:job.write',
});
});
it('includes request_id from req.requestId', () => {
const err = new ApiError(409, 'conflict', 'conflict');
const { req, res, next } = makeReqResNext({ requestId: 'custom-trace-42' });
errorHandler(err, req, res, next);
expect(res.body.error.request_id).toBe('custom-trace-42');
});
it('falls back request_id to null when req.requestId missing', () => {
const err = new ApiError(404, 'not_found', 'gone');
const { req, res, next } = makeReqResNext({ requestId: undefined });
errorHandler(err, req, res, next);
expect(res.body.error.request_id).toBeNull();
});
it('logs ApiError 4xx as WARN level', () => {
const err = new ApiError(404, 'job_not_found', 'not found');
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.error).not.toHaveBeenCalled();
const logged = JSON.parse(console.warn.mock.calls[0][0]);
expect(logged.level).toBe('WARN');
expect(logged.error_code).toBe('job_not_found');
expect(logged.status).toBe(404);
expect(logged.request_id).toBe('req-test-001');
});
it('logs ApiError 5xx as ERROR level', () => {
const err = new ApiError(503, 'service_unavailable', 'down');
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.warn).not.toHaveBeenCalled();
const logged = JSON.parse(console.error.mock.calls[0][0]);
expect(logged.level).toBe('ERROR');
expect(logged.status).toBe(503);
});
});
// ---------------------------------------------------------------------------
// errorHandler — 未預期錯誤
// ---------------------------------------------------------------------------
describe('errorHandler — 未預期錯誤(非 ApiError', () => {
it('converts plain Error to 500 internal_error', () => {
const err = new Error('database connection lost');
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.body).toEqual({
error: {
code: 'internal_error',
message: '伺服器內部錯誤',
request_id: 'req-test-001',
},
});
});
it('does NOT leak stack trace in response body', () => {
const err = new Error('secret internal detail');
err.stack = 'Error: secret internal detail\n at /path/to/internal.js:42';
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
const json = JSON.stringify(res.body);
expect(json).not.toContain('secret internal detail');
expect(json).not.toContain('/path/to/internal.js');
expect(json).not.toContain('stack');
});
it('does NOT leak original error.message in response body', () => {
const err = new Error('SELECT * FROM users WHERE password=...');
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(res.body.error.message).toBe('伺服器內部錯誤');
expect(JSON.stringify(res.body)).not.toContain('SELECT');
});
it('does NOT include details on unknown errors', () => {
const err = new Error('oops');
// 即使有人手動往 Error 上塞 details也不該被輸出
err.details = { sensitive: 'data' };
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(res.body.error.details).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('sensitive');
});
it('logs unknown errors as ERROR with stack to console', () => {
const err = new SyntaxError('unexpected token in JSON');
const { req, res, next } = makeReqResNext();
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalledTimes(1);
const logged = JSON.parse(console.error.mock.calls[0][0]);
expect(logged.level).toBe('ERROR');
expect(logged.error_code).toBe('internal_error');
expect(logged.status).toBe(500);
expect(logged.message).toBe('unexpected token in JSON');
// stack **應該**進 log給 ops但**不**進 response body
expect(logged.stack).toContain('SyntaxError');
});
it('handles non-Error thrown values gracefully', () => {
const err = 'just a string thrown';
const { req, res, next } = makeReqResNext();
// 不應 throw
expect(() => errorHandler(err, req, res, next)).not.toThrow();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.body.error.code).toBe('internal_error');
});
});
// ---------------------------------------------------------------------------
// errorHandler — headersSent 邊界
// ---------------------------------------------------------------------------
describe('errorHandler — headersSent 邊界', () => {
it('does not write response when headersSent=true (delegates to default)', () => {
const err = new ApiError(500, 'internal_error', 'too late');
const { req, res, next } = makeReqResNext();
res.headersSent = true;
errorHandler(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
// 必須交給下一個 handlerExpress 的預設 finalhandler 會中斷連線)
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(err);
});
it('still logs even when headersSent=true', () => {
const err = new Error('mid-stream error');
const { req, res, next } = makeReqResNext();
res.headersSent = true;
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,172 @@
/**
* perClientRateLimit middleware 單元測試T5
*
* 重點
* 1. quota (<= max) 不擋超過時走 ApiError 429 rate_limit_exceeded
* 2. keyGenerator req.auth.clientId 區分 quota兩個 client 互不干擾
* 3. req.auth fallback IP不同 IP 互不干擾
* 4. response 帶有 RateLimit-* header
*/
'use strict';
const express = require('express');
const http = require('http');
const { createPerClientRateLimiter } = require('../perClientRateLimit');
const { ApiError, errorHandler } = require('../errorHandler');
const { requestIdMiddleware } = require('../requestId');
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
/**
* 啟動一個小 app
* - requestId middleware
* - 一個假的 requireAuth query.clientId 寫到 req.auth.clientId
* - perClientRateLimiter
* - 一個 echo handler
* - errorHandler 在最後
*
* @param {object} opts
* @returns {Promise<{baseUrl: string, close: () => Promise<void>}>}
*/
async function startApp(opts) {
const app = express();
app.use(requestIdMiddleware);
app.use((req, _res, next) => {
if (req.query.clientId) {
req.auth = { clientId: String(req.query.clientId) };
}
next();
});
const limiter = createPerClientRateLimiter(opts);
app.get('/test', limiter, (_req, res) => {
res.json({ ok: true });
});
app.use(errorHandler);
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((r) => server.close(r)),
});
});
});
}
describe('perClientRateLimit — quota enforcement', () => {
it('allows requests within max', async () => {
const ctx = await startApp({ windowMs: 60000, max: 3 });
try {
for (let i = 0; i < 3; i++) {
const res = await fetch(`${ctx.baseUrl}/test?clientId=c-1`);
expect(res.status).toBe(200);
}
} finally {
await ctx.close();
}
});
it('blocks (429) after exceeding max with rate_limit_exceeded code', async () => {
const ctx = await startApp({ windowMs: 60000, max: 2 });
try {
// 前 2 次 ok
const r1 = await fetch(`${ctx.baseUrl}/test?clientId=c-1`);
expect(r1.status).toBe(200);
const r2 = await fetch(`${ctx.baseUrl}/test?clientId=c-1`);
expect(r2.status).toBe(200);
// 第 3 次擋下
const r3 = await fetch(`${ctx.baseUrl}/test?clientId=c-1`);
expect(r3.status).toBe(429);
const body = await r3.json();
expect(body.error.code).toBe('rate_limit_exceeded');
expect(typeof body.error.message).toBe('string');
// 應含 retry_after_seconds 細節
expect(body.error.details).toHaveProperty('retry_after_seconds');
// request_id 帶到 v1 格式
expect(typeof body.error.request_id).toBe('string');
} finally {
await ctx.close();
}
});
it('isolates quota per client_id', async () => {
const ctx = await startApp({ windowMs: 60000, max: 1 });
try {
// c-1 用完
const a1 = await fetch(`${ctx.baseUrl}/test?clientId=c-1`);
expect(a1.status).toBe(200);
const a2 = await fetch(`${ctx.baseUrl}/test?clientId=c-1`);
expect(a2.status).toBe(429);
// c-2 還有 quota
const b1 = await fetch(`${ctx.baseUrl}/test?clientId=c-2`);
expect(b1.status).toBe(200);
} finally {
await ctx.close();
}
});
});
describe('perClientRateLimit — fallback', () => {
it('falls back to IP when req.auth.clientId missing', async () => {
const ctx = await startApp({ windowMs: 60000, max: 1 });
try {
// 無 clientId → IP-keyed
const r1 = await fetch(`${ctx.baseUrl}/test`);
expect(r1.status).toBe(200);
const r2 = await fetch(`${ctx.baseUrl}/test`);
expect(r2.status).toBe(429);
} finally {
await ctx.close();
}
});
});
describe('perClientRateLimit — headers', () => {
it('sets RateLimit-* response headers', async () => {
const ctx = await startApp({ windowMs: 60000, max: 5 });
try {
const res = await fetch(`${ctx.baseUrl}/test?clientId=c-3`);
expect(res.status).toBe(200);
// standardHeaders=true 會有這些 headerRFC draft
// 部分版本是 RateLimit-*;舊版是 X-RateLimit-*
const limit = res.headers.get('ratelimit-limit') || res.headers.get('x-ratelimit-limit');
const remaining = res.headers.get('ratelimit-remaining') || res.headers.get('x-ratelimit-remaining');
expect(limit).toBeTruthy();
expect(remaining).toBeTruthy();
} finally {
await ctx.close();
}
});
});
describe('perClientRateLimit — defaults', () => {
it('uses sane defaults when no opts', async () => {
const ctx = await startApp();
try {
const res = await fetch(`${ctx.baseUrl}/test?clientId=c-default`);
expect(res.status).toBe(200);
} finally {
await ctx.close();
}
});
it('rejects invalid windowMs / max in opts (uses defaults)', async () => {
const ctx = await startApp({ windowMs: -1, max: 0 });
try {
const res = await fetch(`${ctx.baseUrl}/test?clientId=c-bad-opts`);
expect(res.status).toBe(200); // 預設 max=300 會接受
} finally {
await ctx.close();
}
});
});

View File

@ -0,0 +1,236 @@
/**
* Unit tests for src/middleware/requestId.js
*
* 測試重點
* 1. 沿用合法的外部 X-Request-IdUUID / 字母數字 / - _
* 2. 拒絕fallback to generated非法輸入含空白 / 控制字元 / 超長 / CRLF
* 3. 設置 req.requestId
* 4. 設置 res header X-Request-Id值與 req.requestId 一致
* 5. 沒帶 header 自行 randomUUID 產生且為 UUIDv4 格式
* 6. isValidRequestId helper 邊界值
*/
'use strict';
const { requestIdMiddleware, _internals } = require('../requestId');
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* 建一組精簡的 req / res / next模擬 Express 行為
* - req.get(name) case insensitive header lookup
* - res.setHeader / res.getHeader
*/
function makeReqResNext(headers = {}) {
// 將 header key normalize 成 lowercase對應 Express req.get 行為)
const lowerHeaders = {};
for (const [k, v] of Object.entries(headers)) {
lowerHeaders[k.toLowerCase()] = v;
}
const req = {
headers: lowerHeaders,
get(name) {
return lowerHeaders[name.toLowerCase()];
},
};
const responseHeaders = {};
const res = {
setHeader: jest.fn((k, v) => {
responseHeaders[k] = v;
}),
getHeader: (k) => responseHeaders[k],
_headers: responseHeaders,
};
const next = jest.fn();
return { req, res, next };
}
describe('requestIdMiddleware — 沿用外部 ID', () => {
it('uses external UUID when header is present and valid', () => {
const externalId = '550e8400-e29b-41d4-a716-446655440000';
const { req, res, next } = makeReqResNext({ 'X-Request-Id': externalId });
requestIdMiddleware(req, res, next);
expect(req.requestId).toBe(externalId);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', externalId);
expect(next).toHaveBeenCalledTimes(1);
});
it('uses external ID for case-insensitive header (x-request-id)', () => {
const externalId = 'trace-abc-123';
const { req, res, next } = makeReqResNext({ 'x-request-id': externalId });
requestIdMiddleware(req, res, next);
expect(req.requestId).toBe(externalId);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', externalId);
});
it('accepts custom alphanumeric trace IDs (e.g. OpenTelemetry 32-hex)', () => {
const externalId = 'a'.repeat(32);
const { req, res, next } = makeReqResNext({ 'X-Request-Id': externalId });
requestIdMiddleware(req, res, next);
expect(req.requestId).toBe(externalId);
});
it('accepts ID with - and _', () => {
const externalId = 'my_trace-id_42';
const { req, res, next } = makeReqResNext({ 'X-Request-Id': externalId });
requestIdMiddleware(req, res, next);
expect(req.requestId).toBe(externalId);
});
});
describe('requestIdMiddleware — fallback 自行產生', () => {
it('generates UUID when no X-Request-Id header is sent', () => {
const { req, res, next } = makeReqResNext({});
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.requestId);
expect(next).toHaveBeenCalledTimes(1);
});
it('generates UUID when X-Request-Id is empty string', () => {
const { req, res, next } = makeReqResNext({ 'X-Request-Id': '' });
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
});
it('generates UUID when X-Request-Id contains spaces (illegal)', () => {
const { req, res, next } = makeReqResNext({ 'X-Request-Id': 'has space' });
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
expect(req.requestId).not.toBe('has space');
});
it('generates UUID when X-Request-Id contains CRLF (log injection attempt)', () => {
const { req, res, next } = makeReqResNext({
'X-Request-Id': 'evil\r\nX-Forwarded-For: 1.2.3.4',
});
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
// 確保 response header 寫入的也是安全值
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.requestId);
});
it('generates UUID when X-Request-Id contains control chars', () => {
const { req, res, next } = makeReqResNext({ 'X-Request-Id': 'abc\x00def' });
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
});
it('generates UUID when X-Request-Id is too long (> 100 chars)', () => {
const tooLong = 'a'.repeat(101);
const { req, res, next } = makeReqResNext({ 'X-Request-Id': tooLong });
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
expect(req.requestId).not.toBe(tooLong);
});
it('generates UUID when X-Request-Id contains illegal chars (e.g. /)', () => {
const { req, res, next } = makeReqResNext({ 'X-Request-Id': 'abc/def' });
requestIdMiddleware(req, res, next);
expect(req.requestId).toMatch(UUID_V4_REGEX);
});
it('generates a unique ID per call', () => {
const seen = new Set();
for (let i = 0; i < 50; i++) {
const { req, res, next } = makeReqResNext({});
requestIdMiddleware(req, res, next);
seen.add(req.requestId);
}
expect(seen.size).toBe(50); // 50 unique UUIDs
});
});
describe('requestIdMiddleware — 行為一致性', () => {
it('always calls next() exactly once', () => {
const { req, res, next } = makeReqResNext({});
requestIdMiddleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(); // 不傳 error
});
it('always sets X-Request-Id header on response (even when generated)', () => {
const { req, res, next } = makeReqResNext({});
requestIdMiddleware(req, res, next);
expect(res.setHeader).toHaveBeenCalledTimes(1);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.requestId);
});
it('echoes the same value when external ID was used', () => {
const externalId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const { req, res, next } = makeReqResNext({ 'X-Request-Id': externalId });
requestIdMiddleware(req, res, next);
expect(req.requestId).toBe(externalId);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', externalId);
});
it('does not throw when res.setHeader is not a function (graceful)', () => {
const req = { headers: {}, get: () => undefined };
const res = {}; // 缺 setHeader
const next = jest.fn();
expect(() => requestIdMiddleware(req, res, next)).not.toThrow();
expect(req.requestId).toMatch(UUID_V4_REGEX);
expect(next).toHaveBeenCalledTimes(1);
});
it('does not throw when req.get is not a function (graceful)', () => {
const req = { headers: {} };
const responseHeaders = {};
const res = { setHeader: (k, v) => { responseHeaders[k] = v; } };
const next = jest.fn();
expect(() => requestIdMiddleware(req, res, next)).not.toThrow();
expect(req.requestId).toMatch(UUID_V4_REGEX);
});
});
describe('_internals.isValidRequestId', () => {
const { isValidRequestId } = _internals;
it.each([
['valid UUID', '550e8400-e29b-41d4-a716-446655440000', true],
['simple alphanumeric', 'abc123', true],
['with hyphen', 'a-b-c', true],
['with underscore', 'a_b_c', true],
['mixed', 'Trace_42-XYZ', true],
['100 chars (boundary)', 'a'.repeat(100), true],
['101 chars (over)', 'a'.repeat(101), false],
['empty', '', false],
['contains space', 'a b', false],
['contains slash', 'a/b', false],
['contains CR', 'a\rb', false],
['contains LF', 'a\nb', false],
['contains null byte', 'a\x00b', false],
['contains tab', 'a\tb', false],
['contains semicolon', 'a;b', false],
['contains dot', 'a.b', false], // 我們的 regex 不允許 dot保守做法
['number (non-string)', 12345, false],
['null', null, false],
['undefined', undefined, false],
['object', {}, false],
['array', ['a'], false],
])('%s → %s', (_label, input, expected) => {
expect(isValidRequestId(input)).toBe(expected);
});
});

View File

@ -0,0 +1,91 @@
/**
* upload.js multer factory 單元測試T10 D5env / opts 串接
*
* 重點
* 1. createUploader() 預設值500MB / 102 files
* 2. opts.maxFileSize 可覆寫 fileSize
* 3. opts.maxRefImages 可推算 maxFilesN+2
* 4. opts.maxFiles 可顯式覆寫
* 5. 非法值0 / fallback 到預設
*/
'use strict';
const {
createUploader,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REF_IMAGES,
DEFAULT_MAX_FILES,
} = require('../upload');
describe('createUploader — defaults', () => {
it('uses 500MB fileSize and 102 files by default', () => {
const uploader = createUploader();
// multer.Multer 物件無公開 API 看 limits走 internal property
// _multerInstance.limits.* — 這個依賴 multer 內部結構,但很穩定(已多年)
// 為了不依賴內部細節,改驗常數
expect(DEFAULT_MAX_FILE_SIZE).toBe(500 * 1024 * 1024);
expect(DEFAULT_MAX_REF_IMAGES).toBe(100);
expect(DEFAULT_MAX_FILES).toBe(102);
// 同時確保 createUploader() 不 throw
expect(uploader).toBeDefined();
});
});
describe('createUploader — opts.maxFileSize override', () => {
it('respects custom maxFileSize', () => {
const uploader = createUploader({ maxFileSize: 100 * 1024 * 1024 });
// multer 的 storage.limits 不公開;用反射方式取(跨 multer 版本相對穩)
// 若未來 multer 內部結構改了,此測試會 failure是預期內的
// multer 物件本身是 function可呼叫的 instancelimits 在內部
// 用 _multerInstance不存在直接驗 createUploader 不 throw + opts 被吃進去
expect(uploader).toBeDefined();
// 透過 opts 行為驗證較困難(需真打 multer本檔做表層 sanity check
// 真正的「env → multer」串接由 server.js 端 + integration test 驗
});
});
describe('createUploader — opts.maxRefImages affects maxFiles', () => {
it('default maxFiles = maxRefImages + 2', () => {
// 驗證 helper 計算邏輯(再 expose 一次以利測試)
// 因 createUploader 內部封裝,這裡只驗 const 一致
expect(DEFAULT_MAX_FILES).toBe(DEFAULT_MAX_REF_IMAGES + 2);
});
it('does not throw when maxRefImages explicitly set', () => {
expect(() => createUploader({ maxRefImages: 50 })).not.toThrow();
expect(() => createUploader({ maxRefImages: 200 })).not.toThrow();
});
it('does not throw when maxFiles explicitly set', () => {
expect(() => createUploader({ maxFiles: 5 })).not.toThrow();
});
});
describe('createUploader — invalid opts fallback', () => {
it('falls back to default for non-positive maxFileSize', () => {
// 0 / 負 / NaN 都應 fallback 到 DEFAULT_MAX_FILE_SIZE
expect(() => createUploader({ maxFileSize: 0 })).not.toThrow();
expect(() => createUploader({ maxFileSize: -1 })).not.toThrow();
expect(() => createUploader({ maxFileSize: 'huge' })).not.toThrow();
});
it('falls back to default for non-positive maxRefImages', () => {
expect(() => createUploader({ maxRefImages: 0 })).not.toThrow();
expect(() => createUploader({ maxRefImages: -10 })).not.toThrow();
});
it('falls back to default for non-positive maxFiles', () => {
expect(() => createUploader({ maxFiles: 0 })).not.toThrow();
expect(() => createUploader({ maxFiles: -1 })).not.toThrow();
});
});
describe('createUploader — multer integration smoke', () => {
it('returned uploader has fields() method (multer.Multer interface)', () => {
const uploader = createUploader({ maxFileSize: 1024 });
expect(typeof uploader.fields).toBe('function');
expect(typeof uploader.single).toBe('function');
expect(typeof uploader.array).toBe('function');
});
});

View File

@ -0,0 +1,349 @@
/**
* uploadConcurrency middleware 單元測試T10 D5
*
* 重點
* 1. 不超 max next() 通過counter 增減正確
* 2. 超過 max 時下個 request 503 + Retry-After + service_busy code
* 3. response close releasecounter 回到正確值
* 4. 同一個 res 'close' 多次觸發只 release 一次idempotent
* 5. fallback 預設值不傳 opts
* 6. Log hook 被呼叫acquire / rejected / released
*
* 測試策略
* - 行為對 HTTP supertest 風格的 express+fetch但避免 client abort 這種
* 不可控情境不同平台 fetch 行為差異大abort/release 改用直接呼叫
* middleware function fake req/res 觀察 counter 變化更可控
*/
'use strict';
const express = require('express');
const { EventEmitter } = require('events');
const {
createUploadConcurrencyLimiter,
DEFAULT_MAX_CONCURRENT,
DEFAULT_RETRY_AFTER_SECONDS,
} = require('../uploadConcurrency');
const { ApiError, errorHandler } = require('../errorHandler');
const { requestIdMiddleware } = require('../requestId');
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
/**
* 建構一個 fake request / response 模擬 Express 行為足以測 limiter middleware
* 不依賴真實 HTTP server避免 abort 測試在不同平台不穩
*/
function makeFakeReqRes(opts = {}) {
const req = {
requestId: opts.requestId || 'req-fake',
auth: opts.clientId ? { clientId: opts.clientId } : undefined,
ip: opts.ip || '127.0.0.1',
};
// res 必須是 EventEmittermiddleware 內 res.once('close', ...)
const res = new EventEmitter();
res.statusCode = 200;
res.headers = {};
res.headersSent = false;
res.setHeader = (k, v) => {
res.headers[k.toLowerCase()] = v;
};
res.getHeader = (k) => res.headers[k.toLowerCase()];
res.status = (code) => {
res.statusCode = code;
return res;
};
res.json = (body) => {
res.body = body;
res.headersSent = true;
return res;
};
res.end = () => {
res.headersSent = true;
return res;
};
res.simulateClose = () => res.emit('close');
return { req, res };
}
/**
* 啟動一個小 appHTTP 行為測試用
* - requestId
* - concurrency limiter
* - 一個 fast handler立即回 200
* - errorHandler 在最後
*/
async function startApp(opts) {
const app = express();
app.use(requestIdMiddleware);
const lim = createUploadConcurrencyLimiter(opts.limiterOpts || {});
app.locals.limiter = lim;
app.get('/fast', lim.middleware, (_req, res) => {
res.json({ ok: true });
});
app.use(errorHandler);
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
baseUrl: `http://127.0.0.1:${port}`,
limiter: lim,
close: () => new Promise((r) => server.close(r)),
});
});
});
}
describe('uploadConcurrency — basic flow (HTTP)', () => {
it('allows requests within max', async () => {
const ctx = await startApp({ limiterOpts: { maxConcurrent: 3 } });
try {
for (let i = 0; i < 3; i += 1) {
const res = await fetch(`${ctx.baseUrl}/fast`);
expect(res.status).toBe(200);
await res.text();
}
// 等 'close' 事件全跑完
await new Promise((r) => setTimeout(r, 30));
expect(ctx.limiter.getInFlight()).toBe(0);
} finally {
await ctx.close();
}
});
it('exposes max and inFlight via getters', async () => {
const ctx = await startApp({ limiterOpts: { maxConcurrent: 4 } });
try {
expect(ctx.limiter.getMax()).toBe(4);
expect(ctx.limiter.getInFlight()).toBe(0);
} finally {
await ctx.close();
}
});
});
describe('uploadConcurrency — limit enforcement (synthetic)', () => {
it('rejects with 503 service_busy when in-flight reaches max', async () => {
const lim = createUploadConcurrencyLimiter({
maxConcurrent: 2,
retryAfterSeconds: 7,
});
// 先 acquire 2 個(不 release— 用 fake req/res
const { req: r1, res: s1 } = makeFakeReqRes({ requestId: 'req-1' });
const { req: r2, res: s2 } = makeFakeReqRes({ requestId: 'req-2' });
let next1Called = false;
let next2Called = false;
lim.middleware(r1, s1, () => {
next1Called = true;
});
lim.middleware(r2, s2, () => {
next2Called = true;
});
expect(next1Called).toBe(true);
expect(next2Called).toBe(true);
expect(lim.getInFlight()).toBe(2);
// 第三個應觸發 503 ApiError
const { req: r3, res: s3 } = makeFakeReqRes({ requestId: 'req-3' });
let nextErr = null;
lim.middleware(r3, s3, (err) => {
nextErr = err;
});
expect(nextErr).toBeInstanceOf(ApiError);
expect(nextErr.status).toBe(503);
expect(nextErr.code).toBe('service_busy');
expect(nextErr.details).toEqual(
expect.objectContaining({
retry_after_seconds: 7,
max_concurrent: 2,
})
);
// Retry-After header 必須有
expect(s3.getHeader('Retry-After')).toBe('7');
// 被 reject 的請求不增加 in-flight
expect(lim.getInFlight()).toBe(2);
// 釋放第一個 → in-flight 回到 1可以再接受新請求
s1.simulateClose();
expect(lim.getInFlight()).toBe(1);
const { req: r4, res: s4 } = makeFakeReqRes({ requestId: 'req-4' });
let next4Called = false;
lim.middleware(r4, s4, () => {
next4Called = true;
});
expect(next4Called).toBe(true);
expect(lim.getInFlight()).toBe(2);
// 收尾:釋放剩餘
s2.simulateClose();
s4.simulateClose();
expect(lim.getInFlight()).toBe(0);
});
it('release is idempotent (multiple close events)', async () => {
const lim = createUploadConcurrencyLimiter({ maxConcurrent: 2 });
const { req, res } = makeFakeReqRes();
lim.middleware(req, res, () => {});
expect(lim.getInFlight()).toBe(1);
// 觸發兩次 close理論上 'close' 是 once但 simulateClose 我們手動觸發)
res.simulateClose();
res.simulateClose();
// 即使觸發兩次counter 也只會 -1once + idempotent flag 雙重保險)
expect(lim.getInFlight()).toBe(0);
});
it('release counter never goes negative even if release called more than acquire', async () => {
const lim = createUploadConcurrencyLimiter({ maxConcurrent: 5 });
const { req, res } = makeFakeReqRes();
lim.middleware(req, res, () => {});
res.simulateClose();
res.simulateClose();
res.simulateClose();
expect(lim.getInFlight()).toBe(0);
});
});
describe('uploadConcurrency — release on real HTTP close', () => {
it('releases counter when response finishes normally', async () => {
const ctx = await startApp({ limiterOpts: { maxConcurrent: 2 } });
try {
const res = await fetch(`${ctx.baseUrl}/fast`);
expect(res.status).toBe(200);
await res.text();
// 'close' 是 next-tick給 event loop 一點時間
await new Promise((r) => setTimeout(r, 50));
expect(ctx.limiter.getInFlight()).toBe(0);
} finally {
await ctx.close();
}
});
});
describe('uploadConcurrency — defaults', () => {
it('uses sane defaults when no opts', async () => {
const lim = createUploadConcurrencyLimiter();
expect(lim.getMax()).toBe(DEFAULT_MAX_CONCURRENT);
expect(DEFAULT_RETRY_AFTER_SECONDS).toBe(30);
});
it('falls back to defaults for invalid maxConcurrent / retryAfterSeconds', async () => {
const lim = createUploadConcurrencyLimiter({
maxConcurrent: 0,
retryAfterSeconds: -5,
});
expect(lim.getMax()).toBe(DEFAULT_MAX_CONCURRENT);
// 用 fake reject 觀察 retry-after 是否走預設
// 把 max acquire 滿
const filled = [];
for (let i = 0; i < DEFAULT_MAX_CONCURRENT; i += 1) {
const { req, res } = makeFakeReqRes({ requestId: `r-${i}` });
lim.middleware(req, res, () => {});
filled.push({ req, res });
}
const { req: rN, res: sN } = makeFakeReqRes({ requestId: 'r-N' });
let nextErr = null;
lim.middleware(rN, sN, (err) => {
nextErr = err;
});
expect(nextErr).toBeInstanceOf(ApiError);
expect(sN.getHeader('Retry-After')).toBe(String(DEFAULT_RETRY_AFTER_SECONDS));
// cleanup
filled.forEach(({ res }) => res.simulateClose());
});
});
describe('uploadConcurrency — log hook', () => {
it('invokes onLog with acquire / rejected / released events', async () => {
const logs = [];
const onLog = (fields) => {
logs.push(fields);
};
const lim = createUploadConcurrencyLimiter({
maxConcurrent: 1,
retryAfterSeconds: 10,
onLog,
});
// 1. acquire
const { req: r1, res: s1 } = makeFakeReqRes({ requestId: 'req-1' });
lim.middleware(r1, s1, () => {});
expect(logs.some((l) => l.action === 'upload.concurrency.acquired')).toBe(true);
// 2. rejected
const { req: r2, res: s2 } = makeFakeReqRes({ requestId: 'req-2' });
let err = null;
lim.middleware(r2, s2, (e) => {
err = e;
});
expect(err).toBeInstanceOf(ApiError);
const rejectedLog = logs.find(
(l) => l.action === 'upload.concurrency.rejected'
);
expect(rejectedLog).toBeTruthy();
expect(rejectedLog.in_flight).toBe(1);
expect(rejectedLog.max_concurrent).toBe(1);
expect(rejectedLog.retry_after_seconds).toBe(10);
// 3. release
s1.simulateClose();
expect(logs.some((l) => l.action === 'upload.concurrency.released')).toBe(true);
});
it('falls back to console.log when onLog not provided', async () => {
// 不指定 onLogconsole.log 已被 spy
const lim = createUploadConcurrencyLimiter({ maxConcurrent: 1 });
const { req, res } = makeFakeReqRes();
lim.middleware(req, res, () => {});
// 至少有一次 console.log 被呼叫acquire log
// jest.spyOn console.log 已開
// 用較寬鬆的斷言counter +1 即可(細節 log 內容不在此驗)
expect(lim.getInFlight()).toBe(1);
res.simulateClose();
expect(lim.getInFlight()).toBe(0);
});
});
describe('uploadConcurrency — auth context in log', () => {
it('records client_id in rejected log when req.auth.clientId present', async () => {
const logs = [];
const lim = createUploadConcurrencyLimiter({
maxConcurrent: 1,
onLog: (f) => logs.push(f),
});
// 先 acquire 滿
const { req: r1, res: s1 } = makeFakeReqRes({
requestId: 'r-1',
clientId: 'client-A',
});
lim.middleware(r1, s1, () => {});
// 第二個被 reject
const { req: r2, res: s2 } = makeFakeReqRes({
requestId: 'r-2',
clientId: 'client-B',
});
lim.middleware(r2, s2, () => {});
const rejected = logs.find(
(l) => l.action === 'upload.concurrency.rejected'
);
expect(rejected).toBeTruthy();
expect(rejected.client_id).toBe('client-B');
s1.simulateClose();
});
});

View File

@ -0,0 +1,152 @@
/**
* /api/v1 middlewareT3
*
* 職責
* 1. 提供 `ApiError` classhandler `next(new ApiError(status, code, message, details?))`
* 2. 接住下游所有 error輸出統一的 v1 錯誤格式TDD §1.2
* {
* "error": {
* "code": "string",
* "message": "human readable",
* "details": { ... } // 可選
* "request_id": "uuid"
* }
* }
* 3. **不洩漏 stack trace / 內部訊息** clientlog ops
*
* 為什麼要獨立一支 errorHandler 而非用 app.js 既有的
* - 既有 handler 回的是 `{ error: 'Internal server error' }`純字串
* - 既有 404 回的是 `{ error: 'Endpoint not found' }`
* - 兩者格式都不符合 v1 規格 code / request_id
* - 為了不破壞 legacy 行為v1 errorHandler **掛在 v1 router 內部**
* legacy 路由依然走既有 handler
*
* 使用範例 v1 router
* const { errorHandler, ApiError } = require('../../middleware/errorHandler');
* router.post('/jobs', (req, res, next) => {
* return next(new ApiError(501, 'not_implemented', 'Phase 2 only'));
* });
* router.use(errorHandler); // **必須**最後才掛4-arg 簽名)
*/
'use strict';
/**
* v1 API 標準錯誤類別
*
* 用法
* throw new ApiError(409, 'user_has_active_job', '使用者已有進行中的 job', {
* active_job_id: '...',
* });
*
* 為什麼用 class 而非 plain object
* - 透過 `instanceof` errorHandler 中可靠地識別預期錯誤 vs 未預期錯誤
* - 預期錯誤 用其 status/code/message
* - 未預期錯誤 統一 500 internal_error****洩漏內部訊息
*
* 為什麼繼承 Error
* - 保留 stack trace server log不回給 client
* - Express next(err) Error 物件做特殊處理
*/
class ApiError extends Error {
/**
* @param {number} status - HTTP status code4xx / 5xx
* @param {string} code - 錯誤代碼必須對齊 TDD §14 表格
* @param {string} message - client 的訊息zh-TW避免敏感資訊
* @param {object} [details] - 補充欄位 required_scope, active_job_id
*/
constructor(status, code, message, details) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
// details 為 undefined 時不掛屬性(後續 JSON.stringify 會 omit
if (details !== undefined) {
this.details = details;
}
}
}
/**
* 結構化 log****洩漏 stack client但會記錄到 stderr ops
*
* @param {Error} err
* @param {import('express').Request} req
*/
function logError(err, req) {
const isApi = err instanceof ApiError;
const level = !isApi || (typeof err.status === 'number' && err.status >= 500) ? 'ERROR' : 'WARN';
const fields = {
level,
service: 'task-scheduler',
action: 'api.v1.error',
request_id: req && req.requestId ? req.requestId : null,
method: req && req.method ? req.method : null,
path: req && req.originalUrl ? req.originalUrl : null,
error_code: isApi ? err.code : 'internal_error',
status: isApi ? err.status : 500,
message: err && err.message ? err.message : 'unknown',
timestamp: new Date().toISOString(),
};
// 只有真正未預期錯誤才印 stack避免噪音
if (!isApi && err && err.stack) {
fields.stack = err.stack;
}
const line = JSON.stringify(fields);
if (level === 'ERROR') {
// eslint-disable-next-line no-console
console.error(line);
} else {
// eslint-disable-next-line no-console
console.warn(line);
}
}
/**
* 4-arg Express error handler**必須**4 個參數才會被 Express 認為是 error
* handler這是 Express 4 的官方 contract
*
* @type {import('express').ErrorRequestHandler}
*/
// eslint-disable-next-line no-unused-vars
function errorHandler(err, req, res, next) {
// log 永遠先做(即使 headersSent 也要留紀錄)
logError(err, req);
// 若 response header 已發出(罕見但可能:例如 streaming 中途出錯),
// Express 4 規範:不要嘗試再寫,直接交給預設 handler 中斷連線。
if (res.headersSent) {
return next(err);
}
// 預期錯誤 → 用其 status/code/message
// 未預期錯誤 → 500 internal_errormessage 用通用文案
const isApi = err instanceof ApiError;
const status = isApi ? err.status : 500;
const code = isApi ? err.code : 'internal_error';
const message = isApi ? err.message : '伺服器內部錯誤';
const body = {
error: {
code,
message,
request_id: req && req.requestId ? req.requestId : null,
},
};
// details 只有在 ApiError 上有設才帶;不洩漏未預期錯誤的內部資料
if (isApi && err.details !== undefined) {
body.error.details = err.details;
}
res.status(status).json(body);
}
module.exports = {
ApiError,
errorHandler,
// 內部 helper 暴露供測試
_internals: { logError },
};

View File

@ -0,0 +1,95 @@
/**
* per-client_id rate limiter for /api/v1/*T5
*
* 為什麼新建一支 limiter而非沿用 server.js L117 IP-based limiter
* - 既有 IP-based 外層護欄200 req / 15 min 防止單一 IP 暴量
* - API client_credentials grant多個 user 共用同一個 visionA-backend
* IPIP-based 會把所有 user request 計成同一個 quota誤殺正常流量
* - per-client_id 則對齊 TDD §1.1300 req / 5 min per client_id是商務層的
* 合約上限vendor SLA
*
* 為什麼必須掛在 requireAuth 之後
* - 要拿 `req.auth.clientId` keyGenerator key
* - 沒驗證的 request 會在 requireAuth 階段就被 401 擋掉不會走到 limiter
* - 結果未驗證流量先被 IP-based limiter外層+ requireAuth
* 驗證過的流量再被 per-client_id limiter內層
*
* 為什麼必須掛在 multer 之前
* - multer 會把 multipart body 全部讀進 memoryStorage最大 500MB
* - limiter multer 之後超過 quota client 仍會把 500MB 灌進 server 才拒
* - 結論requireAuth perClientRateLimit multer handler 是唯一正確順序
*
* 安全
* - express-rate-limit 預設用 memory storeper Node process計數
* - process / instance quota 會被乘以 instance 放鬆
* - Phase 1 部署是單 instance可接受Phase 2 instance 時應改 Redis store
* - keyGenerator 失敗時 fallback IP避免 429 變成 NaN-keyed bucket
*
* 對應錯誤格式
* handler 在超過 quota 時應回 v1 標準格式 `{ error: { code: 'rate_limit_exceeded', ... } }`
* 並設 `Retry-After` header同時保留 `X-RateLimit-*` 標頭
*/
'use strict';
const rateLimit = require('express-rate-limit');
const { ApiError } = require('./errorHandler');
/**
* 預設參數對齊 TDD §1.1per client_id 300 req / 5 min
*/
const DEFAULT_WINDOW_MS = 5 * 60 * 1000; // 5 分鐘
const DEFAULT_MAX = 300;
/**
* 建立一個 per-client_id express-rate-limit middleware
*
* @param {object} [opts]
* @param {number} [opts.windowMs=300000]
* @param {number} [opts.max=300]
* @returns {import('express').RequestHandler}
*/
function createPerClientRateLimiter(opts = {}) {
const windowMs = Number.isInteger(opts.windowMs) && opts.windowMs > 0
? opts.windowMs
: DEFAULT_WINDOW_MS;
const max = Number.isInteger(opts.max) && opts.max > 0 ? opts.max : DEFAULT_MAX;
return rateLimit({
windowMs,
max,
// 開啟標準 RateLimit-* headerRFC draft同時保留 X-RateLimit-* legacy
standardHeaders: true,
legacyHeaders: true,
keyGenerator(req) {
// requireAuth 已在前面跑過 → req.auth.clientId 必有;保險起見 fallback
// 到 IP避免 undefined key 把所有 anon 計成同一個 bucket。
const clientId =
req && req.auth && typeof req.auth.clientId === 'string'
? req.auth.clientId
: null;
if (clientId) return `cid:${clientId}`;
// fallback 不應該發生middleware 順序保證),這裡用 IP 防 NaN-keyed bucket
return `ip:${req.ip || 'unknown'}`;
},
handler(req, res, next /* , options */) {
// 統一走 errorHandler回 v1 標準格式
// express-rate-limit 已經設好 Retry-After / RateLimit-* headers不要 res.json 自己回
// 透過 next(ApiError) 走 errorHandler 才能含 request_id
const retryAfterSec = res.getHeader('Retry-After');
return next(
new ApiError(429, 'rate_limit_exceeded', '請求頻率過高,請稍後再試', {
retry_after_seconds:
typeof retryAfterSec === 'string' ? Number(retryAfterSec) : retryAfterSec,
})
);
},
});
}
module.exports = {
createPerClientRateLimiter,
DEFAULT_WINDOW_MS,
DEFAULT_MAX,
};

View File

@ -0,0 +1,86 @@
/**
* X-Request-Id middlewareT3
*
* 職責
* 1. 接收 client 帶來的 `X-Request-Id` header若合法則沿用
* 2. 否則用 `crypto.randomUUID()` 產生一個新的
* 3. 掛到 `req.requestId` 供下游 middleware / handler / logger 使用
* 4. 透過 `res.setHeader('X-Request-Id', ...)` 回寫到 response便於 client 對應
*
* 設計取捨
* - **不阻擋非法 ID** client 送的 X-Request-Id 不合法我們直接 ignore
* 自行產生一個**** 4xx這樣可以保證 request flow 不被無關的 header
* 問題打斷log 觀察用的 header 不該成為 single point of failure
* - **合法定義**1 長度 100 字元且僅含 ASCII alphanumerics / `-` / `_`
* - 排除控制字元CRLF避免 log injection
* - 排除空白避免 header parsing 歧義
* - 100 字元上限足以容納 UUID36 字元/ 多段 trace ID / 大部分自訂格式
* - **Node 18+**用內建 `crypto.randomUUID()`不再加 `uuid` 套件依賴
* `uuid` 已是專案 dep但讓 middleware 自含無外部相依較理想
*
* 安全
* - X-Request-Id 會出現在 log / response header**必須**過濾控制字元
* - 不要把 raw header 拿去當 Redis key file path無相關使用僅作觀察用
*
* 使用範例
* const { requestIdMiddleware } = require('./middleware/requestId');
* app.use(requestIdMiddleware); // 全域掛在所有 route 之前
*/
'use strict';
const { randomUUID } = require('crypto');
/**
* 合法 X-Request-Id 的字元 / 長度限制
* - 長度 1-100
* - 只允許 ASCII letters / digits / `-` / `_`
*
* 為什麼不嚴格要求 UUID 格式
* client 端能用自己的 trace ID 體系例如 OpenTelemetry trace_id 32 hex
* 字元只要不是明顯惡意就接受
*/
const REQUEST_ID_REGEX = /^[A-Za-z0-9_-]{1,100}$/;
/**
* 判斷外部送來的 X-Request-Id 是否可被沿用
*
* @param {unknown} candidate
* @returns {boolean}
*/
function isValidRequestId(candidate) {
if (typeof candidate !== 'string') return false;
if (candidate.length === 0) return false;
return REQUEST_ID_REGEX.test(candidate);
}
/**
* Express middleware產生或沿用 X-Request-Id
*
* 副作用
* - `req.requestId`
* - `res` header `X-Request-Id`
*
* @type {import('express').RequestHandler}
*/
function requestIdMiddleware(req, res, next) {
// Express 的 req.get() 會做 case-insensitive 查找
const incoming = typeof req.get === 'function' ? req.get('X-Request-Id') : undefined;
const requestId = isValidRequestId(incoming) ? incoming : randomUUID();
req.requestId = requestId;
// 回寫到 response便於 client / 監控系統做端對端追蹤
// 即使 incoming 合法被沿用,也要回寫(避免 client 不知道 server 用的是哪一個)
if (typeof res.setHeader === 'function') {
res.setHeader('X-Request-Id', requestId);
}
next();
}
module.exports = {
requestIdMiddleware,
// 內部 helper 暴露供測試使用
_internals: { isValidRequestId, REQUEST_ID_REGEX },
};

View File

@ -0,0 +1,97 @@
/**
* Multer 上傳中介層配置T4 重構自 server.js L123-126T5 Sec C2 強化T10 D5
*
* 行為對齊重構不改行為
* - 使用 memoryStorage與既有 Web UI multipart 一致
* - per-file 大小上限與總 file 數從呼叫端傳入T10 起由 config 串入
* 預設值對齊 TDD §1.4.2 legacy 設定500MB / 102 files
* - legacy /jobs route 來說欄位設定為 model(1) + ref_images(100)
*
* 設計取捨
* - 提供一個共用的 `createUploader()` factory回傳 multer instance不在
* module load 時建立避免測試 import 副作用
* - **** upload.js 內直接 require config 保持純 factory所有上限值由呼叫端
* 注入server.js 啟動時會從 `config.multipart.*` 讀取並傳入測試可注入任意值
*
* Sec C2 後續強化T5 落實T10 補環境變數整合
* - 雖然 fileSize per-file上限不是 sum 100 ref_images × 500MB
* = 50GB 仍可能造成 OOM**單張 ref_image 10MB 上限**已在 validator
* `routes/v1/validators/createJob.js` 落實 任一張 > 10MB 413 file_too_large
* - validator multer 解析完成後執行multer 把整批 files 全部 load 進記憶體
* 才呼叫 next為了限制 multer 端的瞬間記憶體用量這裡額外設 `files` limit
* model(1) + ref_images(MAX) + 安全 buffer = MAX+2Phase 2 評估改用
* streaming / disk storage 做根本解決
*/
'use strict';
const multer = require('multer');
/**
* 預設 file size 上限500MB對齊 server.js L125 TDD §1.4.2
*
* 注意這是 multer **per-file** 上限針對 ref_images per-file 10MB
* 限制由 validator 處理Sec C2T10可被 `MULTIPART_MODEL_MAX_BYTES` 覆寫
*/
const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024;
/**
* 預設 ref_images 張數上限multer 會把總 files 限制設為此值 + 1model+ 1 buffer
* T10可被 `MULTIPART_REF_IMAGES_MAX_COUNT` 覆寫
*/
const DEFAULT_MAX_REF_IMAGES = 100;
/**
* Multer 接受的最大 file model 1 + ref_images N + 1 安全 buffer
*
* 為什麼不用 maxCount per-field
* maxCount 只控制單一 field 的數量ref_images=Ntotal file 限制保險
* multer N+2 file throw LIMIT_FILE_COUNT而非繼續 parse
*/
const DEFAULT_MAX_FILES = DEFAULT_MAX_REF_IMAGES + 2;
/**
* 建立一個 multer uploadermemoryStorage
*
* @param {object} [opts]
* @param {number} [opts.maxFileSize=500MB] - per-file 大小上限bytes對應
* `config.multipart.modelMaxBytes`
* @param {number} [opts.maxFiles] - file 數上限若不傳 `maxRefImages` 計算
* @param {number} [opts.maxRefImages=100] - ref_images 張數上限影響 maxFiles 推算
* @returns {import('multer').Multer}
*/
function createUploader(opts) {
const o = opts || {};
const maxFileSize =
Number.isInteger(o.maxFileSize) && o.maxFileSize > 0
? o.maxFileSize
: DEFAULT_MAX_FILE_SIZE;
// maxRefImages 是單一 field 上限;用來推算總 file 上限fallback 鏈)
const maxRefImages =
Number.isInteger(o.maxRefImages) && o.maxRefImages > 0
? o.maxRefImages
: DEFAULT_MAX_REF_IMAGES;
// maxFiles呼叫端可顯式覆寫否則 model(1) + ref_images(N) + 1 buffer
const maxFiles =
Number.isInteger(o.maxFiles) && o.maxFiles > 0
? o.maxFiles
: maxRefImages + 2;
const limits = {
fileSize: maxFileSize,
files: maxFiles,
};
return multer({
storage: multer.memoryStorage(),
limits,
});
}
module.exports = {
createUploader,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REF_IMAGES,
DEFAULT_MAX_FILES,
};

View File

@ -0,0 +1,192 @@
/**
* Upload concurrency limiterT10 D5 第二部分
*
* 為什麼需要這層
* multer memoryStorage 把整個 multipart body load buffer每個並發 upload
* 都吃掉 model size 大小的 heap例如5 個並發 × 500MB 2.5GB heap加上
* Node 的其他 overhead容易撞上容器 4GB 上限導致 OOM kill
*
* per-process counter 限制同時間正在進行 multipart parse + handler 的請求
* 數量超過時直接回 503 + `Retry-After` header client 主動 backoff
*
* 為什麼選 503 而不是 queue
* - queue hold connection 不確定多久毫秒到分鐘 client 來說 timeout
* 行為不可預期HTTP hold 太久也會吃掉檔案描述器
* - 503 + Retry-After client 主動 retry符合 12-Factor 無狀態原則
* - visionA-backend 這種使用方來說503 + 30s retry 是清楚的退避訊號
*
* 設計原則
* - **必須掛在 multer 之前**要在 multipart parse 開始前就決定收不收這個請求
* 若先 multer 才檢查 concurrency500MB 已經灌進記憶體limit 失去意義
* - **必須掛在 requireAuth + rate limit 之後**避免 unauthorized / quota 流量
* 擠占有限的 slot先讓那兩層擋掉非法流量
* - **acquire middleware 進入時release response close/finish **
* `res.on('close')` 涵蓋所有結束情境成功 / error / abort保證 counter
* 不洩漏同時用 idempotent flag 確保只 release 一次
* - **fail-safe** counter bug 進入錯誤狀態最多就是拒絕新請求503
* 不會 silently 接受新請求然後 OOM
*
* 對應錯誤格式v1 標準
* 503 + `{ error: { code: 'service_busy', message: '...', details: { retry_after_seconds, max_concurrent }, request_id } }`
* 並在 response header `Retry-After: <seconds>`RFC 7231 §7.1.3
*
* 限制
* - limiter **per-process**同一 Node process counter instance
* 部署時每個 instance 各有自己的 counter=總並發= instance maxConcurrent
* × instance Phase 1 部署是單 instance可接受
* - 對抗惡意 client 灌爆 slots 的長連線依賴 `Retry-After` + per-client rate
* limit已掛在前面共同防禦
*/
'use strict';
const { ApiError } = require('./errorHandler');
/**
* 預設並發上限會被 `MAX_CONCURRENT_UPLOADS` env 覆寫server.js 啟動時透過
* `config.uploadConcurrency.maxConcurrent` 注入
*
* 5 是個保守值5 並發 × 500MB 2.5GB heap覆蓋 4GB 容器無 OOM 風險
*/
const DEFAULT_MAX_CONCURRENT = 5;
/**
* 預設 Retry-After 秒數30s 是經驗值足夠等多數 upload 完成500MB / 5MB/s 100s
* 邊緣情境會錯過第一次 retry但後續 retry 會慢慢成功且不會太短讓 client 一直撞牆
*/
const DEFAULT_RETRY_AFTER_SECONDS = 30;
/**
* 建立一個 concurrency limiter middlewarecounter-based semaphore
*
* @param {object} [opts]
* @param {number} [opts.maxConcurrent=5] - 同時進行中的 upload 上限
* @param {number} [opts.retryAfterSeconds=30] - 503 response Retry-After
* @param {(fields: object) => void} [opts.onLog] - 結構化 log hook方便觀測
* 若不傳則 fallback `console.log(JSON.stringify(...))`測試可注入 spy 驗證
* @returns {{
* middleware: import('express').RequestHandler,
* getInFlight: () => number,
* getMax: () => number,
* }}
*/
function createUploadConcurrencyLimiter(opts) {
const o = opts || {};
const maxConcurrent =
Number.isInteger(o.maxConcurrent) && o.maxConcurrent > 0
? o.maxConcurrent
: DEFAULT_MAX_CONCURRENT;
const retryAfterSeconds =
Number.isInteger(o.retryAfterSeconds) && o.retryAfterSeconds > 0
? o.retryAfterSeconds
: DEFAULT_RETRY_AFTER_SECONDS;
// log hook預設用 stdout 印結構化 JSON與專案其他模組一致
const onLog =
typeof o.onLog === 'function'
? o.onLog
: (fields) => {
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
service: 'task-scheduler',
timestamp: new Date().toISOString(),
...fields,
})
);
};
/**
* 進行中的 upload 數量所有 acquire / release 操作都在 Node event loop
* single-threaded model 下執行無需鎖 acquire release 之間沒有 await
* 切點counter 操作為原子
*
* 這個 counter closure-scoped每個 limiter instance 各有自己的測試友善
*/
let inFlight = 0;
/**
* Express middleware順序acquire next() 監聽 res 結束 release
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
function middleware(req, res, next) {
// === 1. 嘗試 acquire ===
if (inFlight >= maxConcurrent) {
// 拒絕:設 Retry-After headerRFC 7231+ 走 v1 ApiError
res.setHeader('Retry-After', String(retryAfterSeconds));
onLog({
level: 'WARN',
action: 'upload.concurrency.rejected',
request_id: req.requestId,
in_flight: inFlight,
max_concurrent: maxConcurrent,
retry_after_seconds: retryAfterSeconds,
client_id:
req && req.auth && req.auth.clientId ? req.auth.clientId : null,
});
return next(
new ApiError(503, 'service_busy', '伺服器忙碌中,請稍後重試', {
retry_after_seconds: retryAfterSeconds,
max_concurrent: maxConcurrent,
})
);
}
inFlight += 1;
// === 2. 註冊 release必須在 onLog 前完成,避免 onLog throw 造成 counter leak===
// 用 idempotent flag 確保不重複 release'close' 與 'finish' 可能都會觸發)
let released = false;
const release = () => {
if (released) return;
released = true;
inFlight = Math.max(0, inFlight - 1); // 防呆counter 不應為負
onLog({
level: 'DEBUG',
action: 'upload.concurrency.released',
request_id: req.requestId,
in_flight: inFlight,
});
};
// 'close' 涵蓋所有結束情境(包含 client abort、error、normal finish
// 為什麼不用 'finish'
// - 'finish' 只在 response 成功送完才觸發
// - client abortFIN/RST 中途斷線)會跳過 'finish'counter 永遠不釋放
// - 'close' 是底層 socket 關閉,所有情境都會觸發
res.once('close', release);
onLog({
level: 'DEBUG',
action: 'upload.concurrency.acquired',
request_id: req.requestId,
in_flight: inFlight,
max_concurrent: maxConcurrent,
});
return next();
}
return {
middleware,
/**
* 觀測用當前進行中的 upload 測試 / health check / metrics 都可呼叫
* @returns {number}
*/
getInFlight: () => inFlight,
/**
* 上限值const建構時就決定
* @returns {number}
*/
getMax: () => maxConcurrent,
};
}
module.exports = {
createUploadConcurrencyLimiter,
DEFAULT_MAX_CONCURRENT,
DEFAULT_RETRY_AFTER_SECONDS,
};

View File

@ -0,0 +1,76 @@
/**
* Redis client 集中初始化與 helperT4 重構自 server.js L96-100L225-232
*
* 職責
* 1. 提供主 client`redis` blocking 用的 subscriber client`redisSub`
* 2. 集中錯誤 listener避免上層 module 重複加 handler
* 3. 提供 `ensureConsumerGroup` 共用 helper
*
* 注意事項
* - 既有 server.js 直接在 module 載入時就建立 ioredis 連線本檔保留同樣行為
* server.js 啟動行為不變行為 0 改變原則
* - 為了測試友善提供 `createClients(redisUrl)` 工廠函式使單元測試能用 mock URL
* ioredis-mockmodule-level 的預設 client 仍從 process.env 讀取
*/
'use strict';
const Redis = require('ioredis');
/**
* 預設的 Redis URL與既有 server.js L30 行為一致
*/
function getDefaultRedisUrl() {
return process.env.REDIS_URL || 'redis://localhost:6379';
}
/**
* 為一對 clientcommands + subscriber掛上錯誤 log
* 與既有 server.js L99-100 行為一致 console.error 印出錯誤 throw
*/
function attachErrorLogger(client, label) {
client.on('error', (err) => {
// 與 server.js 既有訊息對齊
// eslint-disable-next-line no-console
console.error(`${label}:`, err);
});
}
/**
* 建立一對 Redis client一個給一般指令一個給 blocking xreadgroup
*
* @param {string} [redisUrl] - 連線字串省略時取自 process.env.REDIS_URL
* @returns {{ redis: Redis, redisSub: Redis }}
*/
function createClients(redisUrl) {
const url = redisUrl || getDefaultRedisUrl();
const redis = new Redis(url);
const redisSub = new Redis(url);
attachErrorLogger(redis, 'Redis error');
attachErrorLogger(redisSub, 'Redis subscriber error');
return { redis, redisSub };
}
/**
* 為指定 stream 確保 consumer group 存在BUSYGROUP 視為正常
*
* 這個 helper server.js L225-232 `ensureConsumerGroup` 邏輯完全一致
*
* @param {Redis} redis
* @param {string} queue - stream key
* @param {string} group - consumer group 名稱
*/
async function ensureConsumerGroup(redis, queue, group) {
try {
await redis.xgroup('CREATE', queue, group, '0', 'MKSTREAM');
} catch (err) {
if (!err.message.includes('BUSYGROUP')) throw err;
}
}
module.exports = {
createClients,
ensureConsumerGroup,
// 暴露給測試
_internals: { getDefaultRedisUrl, attachErrorLogger },
};

View File

@ -0,0 +1,280 @@
/**
* luaScripts.js 單元測試T5
*
* 重點
* 1. claimActiveJob redis client 發出正確的 evalsha 呼叫KEYS + ARGV 順序
* 2. NOSCRIPT fallback eval 重發
* 3. 解析 Lua 回的 ['OK'] / ['CONFLICT', id]
* 4. 異常 / 非法回應 throw
* 5. 參數驗證 userId / jobId / 非整數 ttl
*/
'use strict';
const path = require('path');
const fs = require('fs');
const {
claimActiveJob,
releaseActiveJob,
_internals,
} = require('../luaScripts');
function makeFakeRedis() {
return {
evalsha: jest.fn(),
eval: jest.fn(),
};
}
beforeEach(() => {
// 每個測試重置 cache確保 fileSystem mock / 真實檔都會被重新載
_internals.resetCache();
});
describe('claimActiveJob — argument validation', () => {
it.each([
[{ userId: '', jobId: 'j', jobJson: '{}', ttlSeconds: 1 }, /userId/],
[{ userId: 'u', jobId: '', jobJson: '{}', ttlSeconds: 1 }, /jobId/],
[{ userId: 'u', jobId: 'j', jobJson: 123, ttlSeconds: 1 }, /jobJson/],
[{ userId: 'u', jobId: 'j', jobJson: '{}', ttlSeconds: 0 }, /ttlSeconds/],
[{ userId: 'u', jobId: 'j', jobJson: '{}', ttlSeconds: -1 }, /ttlSeconds/],
[{ userId: 'u', jobId: 'j', jobJson: '{}', ttlSeconds: 1.5 }, /ttlSeconds/],
])('throws with descriptive message for invalid args', async (args, regex) => {
const redis = makeFakeRedis();
await expect(claimActiveJob(redis, args)).rejects.toThrow(regex);
});
});
describe('claimActiveJob — happy paths', () => {
it('returns ok=true on Lua "OK" response', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['OK']);
const res = await claimActiveJob(redis, {
userId: 'u-1',
jobId: 'j-1',
jobJson: '{"a":1}',
ttlSeconds: 100,
});
expect(res).toEqual({ ok: true });
});
it('passes correct keys + args to evalsha', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['OK']);
await claimActiveJob(redis, {
userId: 'alice',
jobId: 'job-xyz',
jobJson: '{"hello":"world"}',
ttlSeconds: 604800,
});
expect(redis.evalsha).toHaveBeenCalledTimes(1);
const callArgs = redis.evalsha.mock.calls[0];
// 第一個參數 = sha, 第二個 = numKeys = 3, 接下來是 keys, 再來是 args
const [sha, numKeys, k1, k2, k3, a1, a2, a3] = callArgs;
expect(typeof sha).toBe('string');
expect(sha.length).toBe(40); // SHA-1 hex
expect(numKeys).toBe(3);
expect(k1).toBe('user:alice:active_job');
expect(k2).toBe('job:job-xyz');
expect(k3).toBe('user:alice:jobs');
expect(a1).toBe('job-xyz');
expect(a2).toBe('{"hello":"world"}');
expect(a3).toBe('604800');
});
it('returns conflict + activeJobId on Lua "CONFLICT" response', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['CONFLICT', 'old-job-id']);
const res = await claimActiveJob(redis, {
userId: 'u',
jobId: 'j',
jobJson: '{}',
ttlSeconds: 100,
});
expect(res).toEqual({
ok: false,
conflict: true,
activeJobId: 'old-job-id',
});
});
it('throws on unexpected Lua response', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['UNKNOWN']);
await expect(
claimActiveJob(redis, {
userId: 'u',
jobId: 'j',
jobJson: '{}',
ttlSeconds: 100,
})
).rejects.toThrow(/Unexpected Lua response/);
});
});
describe('claimActiveJob — NOSCRIPT fallback', () => {
it('falls back to eval when evalsha NOSCRIPT', async () => {
const redis = makeFakeRedis();
const noScriptErr = new Error('NOSCRIPT No matching script.');
redis.evalsha.mockRejectedValueOnce(noScriptErr);
redis.eval.mockResolvedValueOnce(['OK']);
const res = await claimActiveJob(redis, {
userId: 'u',
jobId: 'j',
jobJson: '{}',
ttlSeconds: 100,
});
expect(res).toEqual({ ok: true });
expect(redis.evalsha).toHaveBeenCalledTimes(1);
expect(redis.eval).toHaveBeenCalledTimes(1);
// eval 應該帶完整 script body而非 sha
const evalArgs = redis.eval.mock.calls[0];
const [body] = evalArgs;
expect(typeof body).toBe('string');
expect(body).toContain('redis.call');
});
it('does NOT fallback for non-NOSCRIPT errors', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockRejectedValueOnce(new Error('READONLY'));
await expect(
claimActiveJob(redis, {
userId: 'u',
jobId: 'j',
jobJson: '{}',
ttlSeconds: 100,
})
).rejects.toThrow(/READONLY/);
expect(redis.eval).not.toHaveBeenCalled();
});
});
describe('Lua script file integrity (sanity check)', () => {
it('claim_active_job.lua is loadable and contains expected commands', () => {
const luaPath = path.join(
__dirname,
'..',
'luaScripts',
'claim_active_job.lua'
);
const body = fs.readFileSync(luaPath, 'utf8');
// 必要操作齊全
expect(body).toContain("EXISTS");
expect(body).toContain("'CONFLICT'");
expect(body).toContain("'OK'");
expect(body).toContain("SET");
expect(body).toContain("EXPIRE");
expect(body).toContain("SADD");
});
// Sec m5claim_active_job.lua 在 ttl 不合法時 error_reply
it('claim_active_job.lua has invalid_ttl guard (Sec m5)', () => {
const luaPath = path.join(
__dirname,
'..',
'luaScripts',
'claim_active_job.lua'
);
const body = fs.readFileSync(luaPath, 'utf8');
expect(body).toContain('invalid_ttl');
expect(body).toContain('error_reply');
});
// Sec M2release_active_job.lua 完整實作
it('release_active_job.lua is loadable and contains expected commands (Sec M2)', () => {
const luaPath = path.join(
__dirname,
'..',
'luaScripts',
'release_active_job.lua'
);
const body = fs.readFileSync(luaPath, 'utf8');
expect(body).toContain('GET');
expect(body).toContain("'NOOP'");
expect(body).toContain("'OK'");
expect(body).toContain('DEL');
expect(body).toContain('SREM');
});
});
// ---------------------------------------------------------------------------
// Sec M2 + Reviewer Major-2: releaseActiveJob
// ---------------------------------------------------------------------------
describe('releaseActiveJob — argument validation', () => {
it.each([
[{ userId: '', jobId: 'j' }, /userId/],
[{ userId: 'u', jobId: '' }, /jobId/],
[{ userId: 123, jobId: 'j' }, /userId/],
[{ userId: 'u', jobId: null }, /jobId/],
])('throws with descriptive message for invalid args', async (args, regex) => {
const redis = makeFakeRedis();
await expect(releaseActiveJob(redis, args)).rejects.toThrow(regex);
});
});
describe('releaseActiveJob — happy paths', () => {
it('returns released=true on Lua "OK" response', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['OK']);
const res = await releaseActiveJob(redis, {
userId: 'u-1',
jobId: 'j-1',
});
expect(res).toEqual({ ok: true, released: true });
});
it('returns released=false on Lua "NOOP" response (active_job mismatch)', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['NOOP']);
const res = await releaseActiveJob(redis, {
userId: 'u-1',
jobId: 'orphan-id',
});
expect(res).toEqual({ ok: true, released: false });
});
it('passes correct keys + args to evalsha', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['OK']);
await releaseActiveJob(redis, {
userId: 'alice',
jobId: 'job-xyz',
});
expect(redis.evalsha).toHaveBeenCalledTimes(1);
const callArgs = redis.evalsha.mock.calls[0];
const [sha, numKeys, k1, k2, k3, a1] = callArgs;
expect(typeof sha).toBe('string');
expect(sha.length).toBe(40);
expect(numKeys).toBe(3);
expect(k1).toBe('user:alice:active_job');
expect(k2).toBe('job:job-xyz');
expect(k3).toBe('user:alice:jobs');
expect(a1).toBe('job-xyz');
});
it('throws on unexpected Lua response', async () => {
const redis = makeFakeRedis();
redis.evalsha.mockResolvedValueOnce(['WAT']);
await expect(
releaseActiveJob(redis, { userId: 'u', jobId: 'j' })
).rejects.toThrow(/Unexpected Lua response/);
});
});
describe('releaseActiveJob — NOSCRIPT fallback', () => {
it('falls back to eval when evalsha NOSCRIPT', async () => {
const redis = makeFakeRedis();
const noScriptErr = new Error('NOSCRIPT No matching script.');
redis.evalsha.mockRejectedValueOnce(noScriptErr);
redis.eval.mockResolvedValueOnce(['OK']);
const res = await releaseActiveJob(redis, { userId: 'u', jobId: 'j' });
expect(res).toEqual({ ok: true, released: true });
expect(redis.eval).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,188 @@
/**
* Lua script loader / runner for ioredisT5
*
* 職責
* 1. disk `claim_active_job.lua`純文字方便 Reviewer / Auditor
* 2. 提供 `claimActiveJob({ userId, jobId, jobJson, ttlSeconds })` 介面
* 3. Redis 重啟導致 NOSCRIPT自動 fallback 重新 SCRIPT LOAD 後再 EVAL
*
* 為什麼把 Lua 放獨立檔再用 readFileSync 載入
* - script 內嵌成 JS 字串會讓 reviewer 看不清楚每行做什麼
* - 純文字 .lua 檔可獨立用 redis-cli SCRIPT LOAD 測試 / 檢查
* - 啟動時讀一次cache效能可接受< 1KB
*
* 為什麼採 SCRIPT LOAD + EVALSHA
* - 每次 EVAL script body 會占用網路頻寬EVALSHA 只送 sha 大幅省頻寬
* - Redis 重啟OOMreboot會清掉 script cache 我們需要 catch NOSCRIPT 後重 LOAD
*
* 設計取捨 不用 ioredis defineCommand
* - defineCommand 雖好用但會把 redis client 物件改造影響測試 mock 的純度
* - 用顯式 `evalsha` + NOSCRIPT fallback 行為跟下游 expectations 吻合
*
* 安全
* - jobJson 由呼叫端組裝已序列化過Lua 端只當 String 寫入任何 user 輸入
* 已在 handler 端做過 sanitizefilename / object_key
* - 三個 KEYS 名稱都由 server 端組裝user 不能控制 Redis key
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/**
* 讀取 lua script 檔案內容cached
*
* 為什麼包成 function 而非 module-level 常數
* 讓測試能 reset cache必要時透過 `_internals.resetCache()`
*
* @param {string} fileName - 對應 luaScripts/ 下的檔名不含路徑
*/
const _scriptCache = new Map();
function loadScript(fileName) {
if (_scriptCache.has(fileName)) {
return _scriptCache.get(fileName);
}
const fullPath = path.join(__dirname, 'luaScripts', fileName);
const body = fs.readFileSync(fullPath, 'utf8');
const sha1 = crypto.createHash('sha1').update(body).digest('hex');
const entry = { body, sha1 };
_scriptCache.set(fileName, entry);
return entry;
}
/**
* 執行 Lua script NOSCRIPT 自動 reload 與重試一次
*
* @param {import('ioredis').Redis} redis
* @param {{ body: string, sha1: string }} script
* @param {string[]} keys
* @param {string[]} args
* @returns {Promise<unknown>}
*/
async function evalScript(redis, script, keys, args) {
try {
return await redis.evalsha(script.sha1, keys.length, ...keys, ...args);
} catch (err) {
// Redis 沒有 cache 此 script → reload 後重試一次
// 不同 driver 的 NOSCRIPT 訊息略有差異,採寬鬆比對
const msg = err && err.message ? err.message : '';
if (msg.includes('NOSCRIPT')) {
// 用 EVAL 走完整 body 一次,順帶會在 server 端 cache
return await redis.eval(script.body, keys.length, ...keys, ...args);
}
throw err;
}
}
/**
* Claim active job + 完整寫入 job recordM5 方案 A
*
* @param {import('ioredis').Redis} redis
* @param {object} args
* @param {string} args.userId sanitize 過的 user_id
* @param {string} args.jobId 新生成的 job_iduuidv4
* @param {string} args.jobJson 完整 job record JSON.stringify 後的字串
* @param {number} args.ttlSeconds 三把 key TTL預設 7 = 604800
* @returns {Promise<
* | { ok: true }
* | { ok: false, conflict: true, activeJobId: string }
* >}
*/
async function claimActiveJob(redis, { userId, jobId, jobJson, ttlSeconds }) {
if (!userId || typeof userId !== 'string') {
throw new Error('[claimActiveJob] userId is required');
}
if (!jobId || typeof jobId !== 'string') {
throw new Error('[claimActiveJob] jobId is required');
}
if (typeof jobJson !== 'string') {
throw new Error('[claimActiveJob] jobJson must be a string');
}
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error('[claimActiveJob] ttlSeconds must be a positive integer');
}
const script = loadScript('claim_active_job.lua');
const keys = [
`user:${userId}:active_job`,
`job:${jobId}`,
`user:${userId}:jobs`,
];
const args = [jobId, jobJson, String(ttlSeconds)];
const result = await evalScript(redis, script, keys, args);
// ioredis 把 Lua 回的 array 轉成 JS array of strings
if (Array.isArray(result) && result[0] === 'OK') {
return { ok: true };
}
if (Array.isArray(result) && result[0] === 'CONFLICT') {
return {
ok: false,
conflict: true,
activeJobId: typeof result[1] === 'string' ? result[1] : null,
};
}
// 不應該走到,但保險起見回 internal error 給呼叫端
throw new Error(
`[claimActiveJob] Unexpected Lua response: ${JSON.stringify(result)}`
);
}
/**
* Release active jobSec M2 + Reviewer Major-2 修復
*
* 用於 enqueue 失敗時補償釋放 user:{userId}:active_job
* 配合 release_active_job.lua atomic guard 確保只在 active_job 仍指向自己
* jobId 時才 DEL
*
* @param {import('ioredis').Redis} redis
* @param {object} args
* @param {string} args.userId sanitize 過的 user_id
* @param {string} args.jobId 要釋放的 job_id
* @returns {Promise<
* | { ok: true, released: true } 成功釋放
* | { ok: true, released: false } NOOPactive_job 已不是這個 jobId
* >}
*/
async function releaseActiveJob(redis, { userId, jobId }) {
if (!userId || typeof userId !== 'string') {
throw new Error('[releaseActiveJob] userId is required');
}
if (!jobId || typeof jobId !== 'string') {
throw new Error('[releaseActiveJob] jobId is required');
}
const script = loadScript('release_active_job.lua');
const keys = [
`user:${userId}:active_job`,
`job:${jobId}`,
`user:${userId}:jobs`,
];
const args = [jobId];
const result = await evalScript(redis, script, keys, args);
if (Array.isArray(result) && result[0] === 'OK') {
return { ok: true, released: true };
}
if (Array.isArray(result) && result[0] === 'NOOP') {
return { ok: true, released: false };
}
throw new Error(
`[releaseActiveJob] Unexpected Lua response: ${JSON.stringify(result)}`
);
}
module.exports = {
claimActiveJob,
releaseActiveJob,
// 內部 helper 暴露給單元測試
_internals: {
loadScript,
evalScript,
resetCache: () => _scriptCache.clear(),
},
};

View File

@ -0,0 +1,57 @@
-- claim_active_job.lua
--
-- 對齊 TDD §2.7.2,搭配 M5 方案 A 改動:
-- 「先寫 MinIO成功後才用 Lua script 一次寫入完整 job record」。
--
-- 這支 script 的責任是「在沒有衝突的前提下,原子地把 active_job、完整 job record、
-- user:jobs 索引 + TTL」全部寫進 Redis衝突時不寫任何鍵把當前 active_job_id
-- 回給呼叫端,呼叫端再決定如何回應使用者(並負責清掉已寫到 MinIO 的 input 檔)。
--
-- 為什麼要用 Lua 而不是 MULTI/EXEC
-- * Redis Cluster / 重 ACL 環境下 MULTI/EXEC 行為跟 EVAL 都類似,但 Lua 可在
-- 伺服器端做條件判斷後決定要不要寫,避免 client 來回兩趟 round-trip。
-- * 透過單一 EVALRedis 保證「先檢查、再寫入」之間沒有任何其他指令交插,
-- 即便 100 個 client 同時打 POST /api/v1/jobs也只會有一個成功。
--
-- KEYS:
-- KEYS[1] = user:{user_id}:active_job — 該 user 當前 in-progress job_idString
-- KEYS[2] = job:{job_id} — 完整 job recordJSON String
-- KEYS[3] = user:{user_id}:jobs — 該 user 的所有 job_idSet
--
-- ARGV:
-- ARGV[1] = job_id — 本次要寫入的 job_id
-- ARGV[2] = job_record_json — 完整 job record已 JSON.stringify
-- ARGV[3] = ttl_seconds — 三把鑰匙統一的 TTL建議 7d = 604800
--
-- Returns:
-- {"OK"} — 成功 claim 並寫入完整 job record
-- {"CONFLICT", existing_job_id} — 該 user 已有 active job未寫入任何鍵
--
-- 注意事項:
-- * 一旦 Set 已存在則 EXPIRE 會更新 TTL首次建立時 SADD 後再 EXPIRE
-- 等同初始化 TTL與 TDD §2.7.2 的「每次寫入時 EXPIRE 7d」一致
-- * tonumber 失敗時 EXPIRE 會 throw本 script 把 ttl 視為呼叫端責任,
-- 若傳壞值 Redis 會回 ERR 給 client呼叫端應自行轉 500 internal_error
-- * 不在 Lua 內 log所有觀察性靠呼叫端的 structured log
if redis.call('EXISTS', KEYS[1]) == 1 then
return {'CONFLICT', redis.call('GET', KEYS[1])}
end
-- Sec m5明確驗證 ttl 合法性,避免 tonumber 失敗時 EXPIRE 拋 Redis ERR
-- 訊息含義不清(呼叫端不容易區分是參數錯還是 Redis infra 問題)。
local ttl = tonumber(ARGV[3])
if not ttl or ttl <= 0 then
return redis.error_reply('invalid_ttl')
end
redis.call('SET', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ttl)
redis.call('SET', KEYS[2], ARGV[2])
redis.call('EXPIRE', KEYS[2], ttl)
redis.call('SADD', KEYS[3], ARGV[1])
redis.call('EXPIRE', KEYS[3], ttl)
return {'OK'}

View File

@ -0,0 +1,43 @@
-- release_active_job.lua
--
-- 對齊 Sec M2 + Reviewer Major-2 修復:
-- enqueue (xadd queue:onnx) 失敗時,補償釋放 user:{userId}:active_job
-- 避免使用者被鎖死 7 天 TTL。
--
-- 為什麼用 Lua而非 client 端 GET → 比較 → DEL
-- 1. **Atomic guard**:只有當 active_job 仍然指向「我們剛剛 claim 的 jobId」時
-- 才釋放避免「completion + 新 claim 連續發生」造成誤刪別人 job 的鎖
-- 2. **單次 round-trip**:減少 release 失敗時再次與 Redis 互動的機率
-- 3. **與 claim_active_job.lua 對稱**claim 用 Lua atomic 寫入三把 keyrelease
-- 也用 Lua atomic 清理active_job DEL + job:{id} DEL + user:{}:jobs SREM
--
-- KEYS:
-- KEYS[1] = user:{user_id}:active_job — 該 user 當前 in-progress job_idString
-- KEYS[2] = job:{job_id} — 完整 job recordJSON String
-- KEYS[3] = user:{user_id}:jobs — 該 user 的所有 job_idSet
--
-- ARGV:
-- ARGV[1] = job_id — 要釋放的 job_id只有當 active_job
-- 的值等於這個 job_id 時才執行 DEL
--
-- Returns:
-- {"OK"} — 成功釋放active_job 已 DEL
-- job:{id} 已 DELSREM 已執行)
-- {"NOOP"} — active_job 不等於 ARGV[1] 或不存在;
-- 未做任何修改(保護原本 holder
--
-- 注意事項:
-- * 即便 release 失敗NOOP對使用者最差情境也只是維持「等 7d」的當前行為
-- 沒有任何劣化(呼叫端應 log WARN 而非 ERROR
-- * 不在 Lua 內 log所有觀察性靠呼叫端的 structured log
local current = redis.call('GET', KEYS[1])
if current ~= ARGV[1] then
return {'NOOP'}
end
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
redis.call('SREM', KEYS[3], ARGV[1])
return {'OK'}

View File

@ -0,0 +1,394 @@
/**
* Legacy 路由T4 重構自 server.js L301-607
*
* **嚴格保留行為**本檔的 7 個端點對外行為與 server.js 既有版本對齊
* 除了時間戳這類非確定性欄位任何順便改善的修改都不在 T4 範圍
*
* 端點清單
* GET /health 服務健康T8 升級 MC / FAA 可達性
* POST /jobs multipart 上傳 job
* GET /jobs/:jobId job
* GET /jobs 列全部 jobKEYS job:*legacy
* GET /jobs/:jobId/events SSE 推送 job 狀態
* GET /jobs/:jobId/download/:filename 下載結果檔
* GET /queues/stats Redis Stream / Group 統計
*
* 設計取捨
* - factory `createLegacyRouter(deps)` redis / jobService / sseService /
* minio / uploader / healthService 等全部依賴顯式注入避免再產生新的全域狀態
* - 所有 helper`getJob` / `enqueueStage` / `setJob`都改走 jobService
* 不再從本檔內定義
* - multer middleware deps.uploader 提供共用
*
* T8 變更/health
* - deps.healthService 存在 使用其 cached snapshot加上向後相容欄位
* `service: 'task-scheduler'`頂層 `redis`避免破壞既有監控
* - deps.healthService 缺漏 退回原本只 ping Redis 的舊行為單元測試友善
*/
'use strict';
const express = require('express');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { writeJobFilesToLocal, resolveLocalDownloadPath } = require('../storage/local');
const { STAGE_QUEUES, DONE_QUEUE } = require('../services/jobService');
/**
* 建立 legacy router
*
* @param {object} deps
* @param {import('ioredis').Redis} deps.redis
* @param {ReturnType<typeof import('../services/jobService').createJobService>} deps.jobService
* @param {{ sendSSE: Function, registerSseClient: Function }} deps.sseService
* @param {ReturnType<typeof import('../storage/minio').createMinioFacade>} deps.minio
* @param {import('multer').Multer} deps.uploader
* @param {ReturnType<typeof import('../services/healthService').createHealthService>} [deps.healthService]
* T8若提供/health 改用 cached snapshot若缺漏單元測試常見退回 Redis ping 模式
* @returns {import('express').Router}
*/
function createLegacyRouter(deps) {
if (!deps || !deps.redis) throw new Error('[legacy] deps.redis required');
if (!deps.jobService) throw new Error('[legacy] deps.jobService required');
if (!deps.sseService) throw new Error('[legacy] deps.sseService required');
if (!deps.minio) throw new Error('[legacy] deps.minio required');
if (!deps.uploader) throw new Error('[legacy] deps.uploader required');
const { redis, jobService, sseService, minio, uploader, healthService } = deps;
const router = express.Router();
// -------------------------------------------------------------------------
// GET /health
//
// T8 升級:使用 healthService 的 background-cached snapshot包含 redis /
// member_center / file_access_agent 可達性。永遠不阻塞snapshot 為 sync 讀取)。
//
// 向後相容:保留既有監控期待的欄位
// - 頂層 `service: 'task-scheduler'`(既有)+ snapshot 內也保留新的
// `service: 'kneron-converter-api'`?答:避免衝突,回應根欄位採既有
// `service: 'task-scheduler'`,新欄位 `dependencies.*` 並列TDD §1.4.1
// 的 service 名稱對齊 v1未來在 v1 出新 /api/v1/health 時可改名)。
// - 頂層 `redis: 'connected' | 'disconnected'`(既有)
// - 頂層 `timestamp`(既有)
// - 新增 `dependencies` 物件(含 redis / member_center / file_access_agent
// - 新增 `version`
//
// 503 行為snapshot.status === 'unhealthy'(即 Redis disconnected→ 503
// degradedMC / FAA 任一不可達但 Redis OK→ 200由監控決定告警等級。
// -------------------------------------------------------------------------
router.get('/health', async (req, res) => {
if (healthService && typeof healthService.getHealth === 'function') {
const snapshot = healthService.getHealth();
const httpStatus = snapshot.status === 'unhealthy' ? 503 : 200;
// 向後相容:頂層保留 service / timestamp / redis 欄位(既有監控可能依賴)
const legacyTopLevelRedis = snapshot.dependencies.redis; // 'connected' | 'disconnected'
res.status(httpStatus).json({
service: 'task-scheduler', // 既有監控用
status: snapshot.status, // 'healthy' | 'degraded' | 'unhealthy'
timestamp: snapshot.timestamp,
redis: legacyTopLevelRedis, // 既有欄位
version: snapshot.version,
dependencies: snapshot.dependencies,
});
return;
}
// Fallbackdeps 沒提供 healthService測試 / 啟動失敗時的降級)
// 行為對齊 server.js L303-319 的舊實作
try {
await redis.ping();
res.json({
service: 'task-scheduler',
status: 'healthy',
timestamp: new Date().toISOString(),
redis: 'connected',
});
} catch {
res.status(503).json({
service: 'task-scheduler',
status: 'unhealthy',
redis: 'disconnected',
});
}
});
// -------------------------------------------------------------------------
// POST /jobs (對齊 server.js L322-420)
// -------------------------------------------------------------------------
router.post(
'/jobs',
uploader.fields([
{ name: 'model', maxCount: 1 },
{ name: 'ref_images', maxCount: 100 },
]),
async (req, res) => {
try {
// 必填欄位model_id, version, platform
const { model_id, version, platform } = req.body;
if (!model_id || !version || !platform) {
return res
.status(400)
.json({ error: 'model_id, version, platform are required' });
}
if (!req.files || !req.files.model || req.files.model.length === 0) {
return res.status(400).json({ error: 'model file is required' });
}
const jobId = uuidv4();
const modelFile = req.files.model[0];
if (minio.client) {
// S3 mode上傳到 MinIO
const s3Prefix = `jobs/${jobId}`;
await minio.uploadToMinIO(
`${s3Prefix}/input/${modelFile.originalname}`,
modelFile.buffer,
modelFile.mimetype || 'application/octet-stream'
);
if (req.files.ref_images) {
for (const img of req.files.ref_images) {
await minio.uploadToMinIO(
`${s3Prefix}/input/ref_images/${img.originalname}`,
img.buffer,
img.mimetype || 'image/jpeg'
);
}
}
// eslint-disable-next-line no-console
console.log(`[Scheduler] Uploaded job ${jobId} files to MinIO`);
} else {
// Local mode寫到 shared volume
writeJobFilesToLocal(jobId, modelFile, req.files.ref_images);
}
// 可選旗標
const parameters = {
model_id: parseInt(model_id, 10),
version,
platform,
enable_evaluate: req.body.enable_evaluate === 'true',
enable_sim_fp: req.body.enable_sim_fp === 'true',
enable_sim_fixed: req.body.enable_sim_fixed === 'true',
enable_sim_hw: req.body.enable_sim_hw === 'true',
};
// Job record與 legacy 完全一致)
const job = {
job_id: jobId,
created_at: new Date().toISOString(),
status: 'ONNX',
stage: 'onnx',
progress: 0,
updated_at: new Date().toISOString(),
parameters,
output: { bie_path: null, nef_path: null },
error: null,
};
await jobService.setJob(jobId, job);
await jobService.enqueueStage('onnx', job);
res.status(201).json({
job_id: jobId,
status: 'ONNX',
message: 'Job created and queued',
});
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Scheduler] POST /jobs error:', err);
res.status(500).json({ error: err.message });
}
}
);
// -------------------------------------------------------------------------
// GET /jobs/:jobId (對齊 server.js L423-429)
// -------------------------------------------------------------------------
router.get('/jobs/:jobId', async (req, res) => {
const job = await jobService.getJob(req.params.jobId);
if (!job) {
return res.status(404).json({ error: 'JOB_NOT_FOUND' });
}
res.json(job);
});
// -------------------------------------------------------------------------
// GET /jobs (對齊 server.js L432-446)
// -------------------------------------------------------------------------
router.get('/jobs', async (req, res) => {
try {
const keys = await redis.keys('job:*');
const jobs = [];
for (const key of keys) {
const raw = await redis.get(key);
if (raw) jobs.push(JSON.parse(raw));
}
jobs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
res.json(jobs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------------------
// GET /jobs/:jobId/events — SSE (對齊 server.js L449-487)
// -------------------------------------------------------------------------
router.get('/jobs/:jobId/events', async (req, res) => {
const jobId = req.params.jobId;
const job = await jobService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'JOB_NOT_FOUND' });
}
// 由 sseService 處理 headers / heartbeat / cleanup行為與 legacy 對齊)
sseService.registerSseClient(jobId, job, res, req);
});
// -------------------------------------------------------------------------
// GET /jobs/:jobId/download/:filename (對齊 server.js L490-524)
// -------------------------------------------------------------------------
router.get('/jobs/:jobId/download/:filename', async (req, res) => {
const { jobId, filename } = req.params;
const job = await jobService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'JOB_NOT_FOUND' });
}
if (minio.client) {
// MinIO mode取出後回傳
const minioKey = `jobs/${jobId}/${filename}`;
try {
const result = await minio.getFromMinIO(minioKey);
if (!result) {
return res.status(404).json({ error: 'FILE_NOT_FOUND' });
}
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', result.body.length);
res.send(result.body);
} catch (err) {
if (err.name === 'NoSuchKey') {
return res.status(404).json({ error: 'FILE_NOT_FOUND' });
}
// eslint-disable-next-line no-console
console.error('[Scheduler] Download error:', err);
res.status(500).json({ error: 'Download failed' });
}
} else {
// Local mode從 filesystem 直接回傳
const filePath = resolveLocalDownloadPath(jobId, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'FILE_NOT_FOUND' });
}
res.download(filePath);
}
});
// -------------------------------------------------------------------------
// GET /queues/stats (對齊 server.js L527-607)
// -------------------------------------------------------------------------
router.get('/queues/stats', async (req, res) => {
try {
const queues = [
STAGE_QUEUES.onnx,
STAGE_QUEUES.bie,
STAGE_QUEUES.nef,
DONE_QUEUE,
];
const groupNames = {
[STAGE_QUEUES.onnx]: 'onnx-workers',
[STAGE_QUEUES.bie]: 'bie-workers',
[STAGE_QUEUES.nef]: 'nef-workers',
[DONE_QUEUE]: 'scheduler',
};
const stats = {};
for (const queue of queues) {
const length = await redis.xlen(queue);
let consumers = [];
let pending = 0;
let lag = 0;
const group = groupNames[queue];
if (group) {
try {
const groups = await redis.xinfo('GROUPS', queue);
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
const info = {};
for (let j = 0; j < g.length; j += 2) {
info[g[j]] = g[j + 1];
}
if (info.name === group) {
pending = parseInt(info.pending || '0', 10);
lag = parseInt(info.lag || '0', 10);
// 取得這個 group 內的 consumers
try {
const consumerList = await redis.xinfo('CONSUMERS', queue, group);
consumers = consumerList.map((c) => {
const ci = {};
for (let j = 0; j < c.length; j += 2) {
ci[c[j]] = c[j + 1];
}
return {
name: ci.name,
pending: parseInt(ci.pending || '0', 10),
idle: parseInt(ci.idle || '0', 10),
};
});
} catch {
/* no consumers yet */
}
break;
}
}
} catch {
/* group may not exist yet */
}
}
stats[queue] = { length, pending, lag, consumers };
}
// Job 摘要
const keys = await redis.keys('job:*');
const jobSummary = {
total: keys.length,
ONNX: 0,
BIE: 0,
NEF: 0,
COMPLETED: 0,
FAILED: 0,
};
for (const key of keys) {
const raw = await redis.get(key);
if (raw) {
const job = JSON.parse(raw);
if (jobSummary[job.status] !== undefined) {
jobSummary[job.status]++;
}
}
}
res.json({
timestamp: new Date().toISOString(),
queues: stats,
jobs: jobSummary,
});
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Scheduler] GET /queues/stats error:', err);
res.status(500).json({ error: err.message });
}
});
return router;
}
module.exports = { createLegacyRouter };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,440 @@
/**
* createJob validator 單元測試T5
*
* 重點
* 1. 必填欄位缺漏全部回 400 + details.fields
* 2. 副檔名只允許 .onnx / .tflitePRD F-01
* 3. user_id 不允許 / \ : .. control chars
* 4. model_id 必須 1 x 65535
* 5. platform 必須在 enum
* 6. enable_* 缺漏視為 false
* 7. metadata JSON parse + 必須是物件 array / null
*/
'use strict';
const {
validateCreateJobRequest,
ALLOWED_MODEL_EXTENSIONS,
ALLOWED_PLATFORMS,
} = require('../validators/createJob');
function makeFile(originalname, sizeBytes = 100, mimetype = 'application/octet-stream') {
return {
originalname,
buffer: Buffer.alloc(sizeBytes, 0x7f),
mimetype,
size: sizeBytes,
};
}
function happyBody(overrides = {}) {
return {
user_id: 'visionA-user-12345',
model_id: '1001',
version: '0001',
platform: '520',
enable_evaluate: 'false',
enable_sim_fp: 'false',
enable_sim_fixed: 'false',
enable_sim_hw: 'false',
...overrides,
};
}
describe('validateCreateJobRequest — happy path', () => {
it('accepts a valid payload with model.onnx + 0 ref images', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: { model: [makeFile('model.onnx')] },
});
expect(result.ok).toBe(true);
expect(result.errors).toEqual([]);
expect(result.data.userId).toBe('visionA-user-12345');
expect(result.data.parameters.model_id).toBe(1001);
expect(result.data.parameters.platform).toBe('520');
expect(result.data.input.safeFilename).toBe('model.onnx');
expect(result.data.input.extension).toBe('.onnx');
expect(result.data.refImages).toHaveLength(0);
});
it('accepts model.tflite', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: { model: [makeFile('weights.tflite')] },
});
expect(result.ok).toBe(true);
expect(result.data.input.extension).toBe('.tflite');
});
it('accepts ref_images[] with sanitization', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('model.onnx')],
ref_images: [
makeFile('img with space.jpg', 50, 'image/jpeg'),
makeFile('../../traversal.png', 50, 'image/png'),
],
},
});
expect(result.ok).toBe(true);
expect(result.data.refImages).toHaveLength(2);
expect(result.data.refImages[0].safeFilename).toBe('img_with_space.jpg');
expect(result.data.refImages[1].safeFilename).toBe('traversal.png');
});
it('parses enable_* booleans correctly', () => {
const result = validateCreateJobRequest({
body: happyBody({
enable_evaluate: 'true',
enable_sim_fp: 'true',
enable_sim_fixed: 'false',
enable_sim_hw: undefined, // 缺漏 → false
}),
files: { model: [makeFile('m.onnx')] },
});
expect(result.ok).toBe(true);
expect(result.data.parameters.enable_evaluate).toBe(true);
expect(result.data.parameters.enable_sim_fp).toBe(true);
expect(result.data.parameters.enable_sim_fixed).toBe(false);
expect(result.data.parameters.enable_sim_hw).toBe(false);
});
it('parses metadata JSON object', () => {
const result = validateCreateJobRequest({
body: happyBody({ metadata: '{"source":"visionA"}' }),
files: { model: [makeFile('m.onnx')] },
});
expect(result.ok).toBe(true);
expect(result.data.metadata).toEqual({ source: 'visionA' });
});
it('handles ref_images[] alternate key (with brackets) gracefully', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
'ref_images[]': [makeFile('img.jpg', 10, 'image/jpeg')],
},
});
expect(result.ok).toBe(true);
expect(result.data.refImages).toHaveLength(1);
});
});
describe('validateCreateJobRequest — failures', () => {
function expectErrorOnField(result, field) {
expect(result.ok).toBe(false);
expect(result.errors.map((e) => e.field)).toContain(field);
}
it('fails when user_id missing', () => {
const result = validateCreateJobRequest({
body: happyBody({ user_id: undefined }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'user_id');
});
it('fails when user_id contains slash / backslash / colon / ..', () => {
for (const bad of ['user/id', 'user\\id', 'user:id', 'user..id']) {
const result = validateCreateJobRequest({
body: happyBody({ user_id: bad }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'user_id');
}
});
it('fails when model_id is not numeric / out of range', () => {
for (const bad of ['', 'abc', '0', '65536', '-5']) {
const result = validateCreateJobRequest({
body: happyBody({ model_id: bad }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'model_id');
}
});
it('fails when platform not in enum', () => {
const result = validateCreateJobRequest({
body: happyBody({ platform: '999' }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'platform');
});
it('fails when version is empty / oversize / contains control chars', () => {
for (const bad of ['', 'a'.repeat(33), 'v1\nbad']) {
const result = validateCreateJobRequest({
body: happyBody({ version: bad }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'version');
}
});
// Sec M3version 嚴格白名單,拒絕 XSS / 特殊字元
it('fails when version contains XSS / shell metachars (Sec M3)', () => {
const xssPayloads = [
'<script>alert(1)</script>',
'<img src=x>',
'v1; rm -rf',
'v1$(id)',
'v1`whoami`',
'v1|cat',
'v1&whoami',
'v1 with space',
'v1?',
'v1*',
'v1/path',
'v1\\back',
'v1:colon',
'v1@email',
'v1#hash',
'v1%encoded',
];
for (const bad of xssPayloads) {
const result = validateCreateJobRequest({
body: happyBody({ version: bad }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'version');
}
});
it('accepts version with whitelist chars (alnum / . / _ / -)', () => {
const goodVersions = [
'v1.0.0',
'2026-04-25',
'build_42',
'beta.1',
'v1.0.0-alpha.1',
'1234567890',
'a',
];
for (const good of goodVersions) {
const result = validateCreateJobRequest({
body: happyBody({ version: good }),
files: { model: [makeFile('m.onnx')] },
});
expect(result.ok).toBe(true);
expect(result.data.parameters.version).toBe(good);
}
});
// Sec C2ref_image per-file size 超過 10MB → tooLarge 信號
it('returns tooLarge signal when ref_image exceeds 10MB (Sec C2)', () => {
const oversizedBuffer = Buffer.alloc(10 * 1024 * 1024 + 1, 0x42); // 10MB + 1 byte
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [
{
originalname: 'big.jpg',
buffer: oversizedBuffer,
mimetype: 'image/jpeg',
size: oversizedBuffer.length,
},
],
},
});
expect(result.ok).toBe(false);
expect(result.tooLarge).toBeDefined();
expect(result.tooLarge.field).toBe('ref_images[0]');
expect(result.tooLarge.size_bytes).toBe(oversizedBuffer.length);
expect(result.tooLarge.limit_bytes).toBe(10 * 1024 * 1024);
});
it('reports first oversized ref_image among many (Sec C2)', () => {
const small = Buffer.from('small');
const big = Buffer.alloc(10 * 1024 * 1024 + 100, 0x42);
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [
{ originalname: 'a.jpg', buffer: small, mimetype: 'image/jpeg' },
{ originalname: 'b.jpg', buffer: big, mimetype: 'image/jpeg' },
{ originalname: 'c.jpg', buffer: big, mimetype: 'image/jpeg' },
],
},
});
expect(result.ok).toBe(false);
expect(result.tooLarge).toBeDefined();
expect(result.tooLarge.field).toBe('ref_images[1]');
});
it('accepts ref_image at exactly 10MB (Sec C2 boundary)', () => {
const exactBuffer = Buffer.alloc(10 * 1024 * 1024, 0x42); // exactly 10MB
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [
{
originalname: 'ok.jpg',
buffer: exactBuffer,
mimetype: 'image/jpeg',
size: exactBuffer.length,
},
],
},
});
expect(result.ok).toBe(true);
expect(result.tooLarge).toBeUndefined();
});
it('fails when model file missing', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: {},
});
expectErrorOnField(result, 'model');
});
it('fails when model file extension not allowed', () => {
for (const bad of ['model.pt', 'model.h5', 'model.bin', 'model']) {
const result = validateCreateJobRequest({
body: happyBody(),
files: { model: [makeFile(bad)] },
});
expectErrorOnField(result, 'model');
}
});
it('fails when model file is empty', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: { model: [makeFile('m.onnx', 0)] },
});
expectErrorOnField(result, 'model');
});
it('fails when enable_* is not "true" / "false"', () => {
const result = validateCreateJobRequest({
body: happyBody({ enable_evaluate: 'yes' }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'enable_evaluate');
});
it('fails when metadata is not valid JSON object', () => {
for (const bad of ['{ broken', '"string"', '[1,2]', 'null']) {
const result = validateCreateJobRequest({
body: happyBody({ metadata: bad }),
files: { model: [makeFile('m.onnx')] },
});
expectErrorOnField(result, 'metadata');
}
});
it('returns multiple errors in one pass', () => {
const result = validateCreateJobRequest({
body: happyBody({
user_id: 'bad/id',
model_id: 'abc',
platform: 'XYZ',
}),
files: { model: [makeFile('m.bin')] },
});
expect(result.ok).toBe(false);
const fields = result.errors.map((e) => e.field).sort();
// 應該至少含 user_id / model_id / platform / model
expect(fields).toEqual(expect.arrayContaining(['user_id', 'model_id', 'platform', 'model']));
});
});
describe('exported constants', () => {
it('ALLOWED_MODEL_EXTENSIONS matches PRD F-01', () => {
expect(ALLOWED_MODEL_EXTENSIONS.has('.onnx')).toBe(true);
expect(ALLOWED_MODEL_EXTENSIONS.has('.tflite')).toBe(true);
expect(ALLOWED_MODEL_EXTENSIONS.has('.pt')).toBe(false);
});
it('ALLOWED_PLATFORMS contains all 5 enums', () => {
for (const p of ['520', '720', '530', '630', '730']) {
expect(ALLOWED_PLATFORMS.has(p)).toBe(true);
}
});
});
// === T10limits 注入測試D5 修復) ===
describe('validateCreateJobRequest — limits.refImageMaxBytes injection (T10)', () => {
const tinyImage = (size) => ({
originalname: 'img.jpg',
buffer: Buffer.alloc(size, 0x42),
mimetype: 'image/jpeg',
size,
});
it('uses default 10MB when limits not provided', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [tinyImage(10 * 1024 * 1024 + 1)], // 10MB + 1 byte
},
});
expect(result.ok).toBe(false);
expect(result.tooLarge).toBeDefined();
expect(result.tooLarge.limit_bytes).toBe(10 * 1024 * 1024);
});
it('respects custom refImageMaxBytes (5MB)', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [tinyImage(5 * 1024 * 1024 + 1)], // 5MB + 1 byte
},
limits: { refImageMaxBytes: 5 * 1024 * 1024 },
});
expect(result.ok).toBe(false);
expect(result.tooLarge).toBeDefined();
expect(result.tooLarge.limit_bytes).toBe(5 * 1024 * 1024);
});
it('accepts file equal to custom refImageMaxBytes (boundary)', () => {
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [tinyImage(2 * 1024 * 1024)], // exactly 2MB
},
limits: { refImageMaxBytes: 2 * 1024 * 1024 },
});
expect(result.ok).toBe(true);
expect(result.tooLarge).toBeUndefined();
});
it('falls back to default when limits.refImageMaxBytes is invalid (0 / negative)', () => {
// 6MB imagedefault 10MB OK但 limits=0 應 fallback 到 default 而非 reject 0-byte
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [tinyImage(6 * 1024 * 1024)],
},
limits: { refImageMaxBytes: 0 },
});
expect(result.ok).toBe(true);
});
it('reports fields error message uses injected limit value', () => {
// limit = 1MB上傳 2MB → tooLarge.limit_bytes 應為 1MB
const result = validateCreateJobRequest({
body: happyBody(),
files: {
model: [makeFile('m.onnx')],
ref_images: [tinyImage(2 * 1024 * 1024)],
},
limits: { refImageMaxBytes: 1 * 1024 * 1024 },
});
expect(result.ok).toBe(false);
expect(result.tooLarge.limit_bytes).toBe(1 * 1024 * 1024);
expect(result.tooLarge.size_bytes).toBe(2 * 1024 * 1024);
});
});

View File

@ -0,0 +1,932 @@
/**
* GET /api/v1/jobs/:id + GET /api/v1/jobs 整合測試T6
*
* 測試範圍
* - 401 invalid_token Authorization
* - 403 insufficient_scopetoken converter:job.read
* - GET /:id
* - 404 job_not_found不存在
* - 404 job_not_found client不洩漏存在性
* - 200 happy path完整 record + 對外狀態映射
* - ETag header 出現
* - 304 Not ModifiedIf-None-Match 命中
* - 200 + ETagIf-None-Match 不命中
* - 內部 strippingcreated_by_client_id 不應洩漏
* - GET /jobs
* - 400 validation_error user_id
* - 400 validation_erroruser_id 含禁字XSS / 路徑穿越
* - 200 happy path列表 client 過濾
* - status filterin_progress / completed / failed / all
* - limit / cursor 分頁
* - client 隔離 user_id 不會看到別 client job
* - limit > 50 400
*/
'use strict';
const express = require('express');
const { createSseService } = require('../../../services/sseService');
const { createJobService } = require('../../../services/jobService');
const { requireAuth } = require('../../../auth/middleware');
// Mock luaScripts to avoid real Redis Lua loading
jest.mock('../../../redis/luaScripts', () => ({
claimActiveJob: jest.fn(),
releaseActiveJob: jest.fn(async () => ({ ok: true, released: true })),
_internals: {
loadScript: jest.fn(),
evalScript: jest.fn(),
resetCache: jest.fn(),
},
}));
const FAKE_CONFIG = Object.freeze({
memberCenter: {
issuer: 'https://auth.test.local',
jwksUrl: 'https://auth.test.local/.well-known/jwks',
tokenUrl: '',
},
converter: {
audience: 'kneron_converter_api',
clientId: '',
clientSecret: '',
tenantId: '',
scopeWrite: 'converter:job.write',
scopeRead: 'converter:job.read',
},
fileAccessAgent: { baseUrl: '', audience: 'file_access_api' },
jwks: { cacheMaxAgeMs: 60000, cooldownMs: 30000, clockToleranceSec: 60 },
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeVerifier({ tokens }) {
return async (token) => {
const entry = tokens[token];
if (!entry) {
const err = new Error('invalid token');
err.code = 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED';
throw err;
}
if (entry.expired) {
const err = new Error('expired');
err.code = 'ERR_JWT_EXPIRED';
throw err;
}
return { payload: entry.claims };
};
}
function makeFakeRedis() {
const store = new Map();
const sets = new Map();
function pipeline() {
const ops = [];
const p = {
get(key) {
ops.push({ kind: 'get', key });
return p;
},
async exec() {
return ops.map((op) => {
if (op.kind === 'get') {
const val = store.has(op.key) ? store.get(op.key) : null;
return [null, val];
}
return [new Error('unsupported op'), null];
});
},
};
return p;
}
return {
store,
sets,
pipeline: jest.fn(pipeline),
smembers: jest.fn(async (key) => {
const s = sets.get(key);
return s ? [...s] : [];
}),
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
sadd: jest.fn(async (key, member) => {
if (!sets.has(key)) sets.set(key, new Set());
sets.get(key).add(member);
return 1;
}),
keys: jest.fn(async () => []),
xadd: jest.fn(async () => '1-0'),
xlen: jest.fn(async () => 0),
xinfo: jest.fn(async () => {
throw new Error('NOGROUP');
}),
ping: jest.fn(async () => 'PONG'),
};
}
function makeFakeMinio() {
return {
client: { _fake: true },
bucket: 'test-bucket',
endpoint: 'http://nope',
uploadToMinIO: jest.fn(async () => undefined),
getFromMinIO: jest.fn(async () => null),
deleteObject: jest.fn(async () => undefined),
};
}
/**
* 啟動 GET 端點的 app
*/
async function startApp({ tokens, rateLimit = { windowMs: 60000, max: 1000 } }) {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({
redis,
sseService,
minio,
jobDataDir: '/tmp/x',
});
const app = express();
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const { requestIdMiddleware } = require('../../../middleware/requestId');
const { errorHandler } = require('../../../middleware/errorHandler');
const { createPerClientRateLimiter } = require('../../../middleware/perClientRateLimit');
const { _internals: jobsInternals } = require('../jobs');
app.use(helmet());
app.use(requestIdMiddleware);
app.use(compression());
app.use(morgan('short'));
app.use(express.json({ limit: '10mb' }));
// v1 router with verify mock injected into requireAuth
const v1 = express.Router();
const verify = makeVerifier({ tokens });
const requireReadAuth = requireAuth(FAKE_CONFIG.converter.scopeRead, {
config: FAKE_CONFIG,
verify,
});
const perClientLimiter = createPerClientRateLimiter(rateLimit);
const getJobHandler = jobsInternals.buildGetJobHandler({ jobService });
const listJobsHandler = jobsInternals.buildListJobsHandler({ jobService });
v1.get('/jobs', requireReadAuth, perClientLimiter, listJobsHandler);
v1.get('/jobs/:id', requireReadAuth, perClientLimiter, getJobHandler);
app.use('/api/v1', v1);
app.use('/api/v1', errorHandler);
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${port}`,
redis,
minio,
jobService,
close: () => new Promise((r) => server.close(r)),
});
});
});
}
const HAPPY_TOKENS = {
'good-read-token': {
claims: {
sub: 'visionA-backend',
client_id: 'cid-A',
scope: 'converter:job.read converter:job.write',
},
},
'good-read-token-B': {
claims: {
sub: 'visionA-backend',
client_id: 'cid-B',
scope: 'converter:job.read',
},
},
'write-only-token': {
claims: {
sub: 'someone',
client_id: 'cid-A',
scope: 'converter:job.write', // 缺 read
},
},
'expired-token': {
expired: true,
claims: {},
},
};
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Auth 共用測試GET /jobs 與 GET /jobs/:id 同樣 require read scope
// ---------------------------------------------------------------------------
describe('GET /api/v1/jobs* — auth', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp({ tokens: HAPPY_TOKENS });
});
afterEach(async () => {
await ctx.close();
});
it('GET /:id returns 401 when Authorization missing', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/some-id`);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error.code).toBe('invalid_token');
expect(typeof body.error.request_id).toBe('string');
});
it('GET /jobs returns 401 when Authorization missing', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1`);
expect(res.status).toBe(401);
});
it('GET /:id returns 401 token_expired with expired token', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/some-id`, {
headers: { Authorization: 'Bearer expired-token' },
});
expect(res.status).toBe(401);
expect((await res.json()).error.code).toBe('token_expired');
});
it('GET /jobs returns 403 with write-only token (insufficient_scope)', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1`, {
headers: { Authorization: 'Bearer write-only-token' },
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error.code).toBe('insufficient_scope');
expect(body.error.details).toMatchObject({
required_scope: 'converter:job.read',
});
});
it('GET /:id returns 403 with write-only token', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/x`, {
headers: { Authorization: 'Bearer write-only-token' },
});
expect(res.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// GET /api/v1/jobs/:id
// ---------------------------------------------------------------------------
describe('GET /api/v1/jobs/:id', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp({ tokens: HAPPY_TOKENS });
});
afterEach(async () => {
await ctx.close();
});
function seedJob(jobId, overrides = {}) {
const job = {
job_id: jobId,
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'BIE',
stage: 'bie',
progress: 50,
stage_progress: 60,
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:05:30Z',
expires_at: '2026-05-02T12:00:00Z',
stage_timings: {
onnx: {
started_at: '2026-04-25T12:00:05Z',
completed_at: '2026-04-25T12:02:10Z',
},
bie: { started_at: '2026-04-25T12:02:15Z', completed_at: null },
nef: null,
},
input: {
filename: 'model.onnx',
object_key: `jobs/${jobId}/input/model.onnx`,
size_bytes: 1024,
ref_images_count: 0,
},
parameters: {
model_id: 1001,
version: '0001',
platform: '520',
enable_evaluate: false,
},
output: { bie_path: null, nef_path: null },
error: null,
metadata: { source: 'visionA' },
...overrides,
};
ctx.redis.store.set(`job:${jobId}`, JSON.stringify(job));
return job;
}
it('returns 404 job_not_found when job does not exist', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/nonexistent`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error.code).toBe('job_not_found');
expect(typeof body.error.request_id).toBe('string');
});
it('returns 404 (not 403) when job belongs to different client (no info leak)', async () => {
seedJob('foreign-job', { created_by_client_id: 'cid-B' });
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/foreign-job`, {
headers: { Authorization: 'Bearer good-read-token' }, // cid-A
});
expect(res.status).toBe(404);
const body = await res.json();
// 重要:對外 code 與 message 必須與「真不存在」完全一致
expect(body.error.code).toBe('job_not_found');
});
it('returns 200 with full job shape for owner', async () => {
seedJob('my-job');
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/my-job`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
job_id: 'my-job',
user_id: 'u1',
status: 'running', // BIE → running
stage: 'bie',
progress: 50,
stage_progress: 60,
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:05:30Z',
});
// result_object_keys 在非 completed 時應為 null
expect(body.result_object_keys).toBeNull();
// error 在非 failed 時應為 null
expect(body.error).toBeNull();
// input / parameters / metadata
expect(body.input.filename).toBe('model.onnx');
expect(body.parameters.model_id).toBe(1001);
expect(body.metadata).toEqual({ source: 'visionA' });
});
it('strips internal field created_by_client_id from response', async () => {
seedJob('my-job');
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/my-job`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const body = await res.json();
expect(body).not.toHaveProperty('created_by_client_id');
});
it('maps internal status correctly: ONNX + onnx.started_at == null → created', async () => {
seedJob('newly-created', {
status: 'ONNX',
stage: 'onnx',
stage_timings: { onnx: null, bie: null, nef: null },
});
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/newly-created`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const body = await res.json();
expect(body.status).toBe('created');
expect(body.stage).toBe('onnx');
});
it('maps internal status correctly: COMPLETED → completed/null', async () => {
seedJob('done-job', {
status: 'COMPLETED',
stage: null,
progress: 100,
output: {
onnx_path: 'jobs/done-job/output/out.onnx',
bie_path: 'jobs/done-job/output/out.bie',
nef_path: 'jobs/done-job/output/out.nef',
},
});
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/done-job`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const body = await res.json();
expect(body.status).toBe('completed');
expect(body.stage).toBeNull();
// result_object_keys 從 output fallback 轉成 v1 格式
expect(body.result_object_keys).toEqual({
onnx: 'jobs/done-job/output/out.onnx',
bie: 'jobs/done-job/output/out.bie',
nef: 'jobs/done-job/output/out.nef',
});
});
it('maps internal status correctly: FAILED → failed/<error.stage>', async () => {
seedJob('failed-job', {
status: 'FAILED',
stage: 'bie',
error: {
stage: 'bie',
code: 'quantization_failed',
message: 'BIE 量化失敗',
},
});
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/failed-job`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const body = await res.json();
expect(body.status).toBe('failed');
expect(body.stage).toBe('bie');
expect(body.error).toMatchObject({
stage: 'bie',
code: 'quantization_failed',
});
});
it('returns ETag header on 200 response', async () => {
seedJob('etag-job');
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/etag-job`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(200);
const etag = res.headers.get('etag');
expect(etag).toMatch(/^W\/"[A-Za-z0-9_-]+"$/);
});
it('returns 304 Not Modified when If-None-Match matches', async () => {
seedJob('etag-match-job');
const first = await fetch(`${ctx.baseUrl}/api/v1/jobs/etag-match-job`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const etag = first.headers.get('etag');
expect(etag).toBeTruthy();
const second = await fetch(`${ctx.baseUrl}/api/v1/jobs/etag-match-job`, {
headers: {
Authorization: 'Bearer good-read-token',
'If-None-Match': etag,
},
});
expect(second.status).toBe(304);
// 304 不應該帶 body或極短
const text = await second.text();
expect(text).toBe('');
});
it('returns 200 + new ETag when If-None-Match does not match', async () => {
seedJob('etag-mismatch-job');
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/etag-mismatch-job`, {
headers: {
Authorization: 'Bearer good-read-token',
'If-None-Match': 'W/"stale"',
},
});
expect(res.status).toBe(200);
const etag = res.headers.get('etag');
expect(etag).toMatch(/^W\/"[A-Za-z0-9_-]+"$/);
expect(etag).not.toBe('W/"stale"');
});
it('returns 304 when If-None-Match contains *', async () => {
seedJob('star-job');
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/star-job`, {
headers: {
Authorization: 'Bearer good-read-token',
'If-None-Match': '*',
},
});
expect(res.status).toBe(304);
});
});
// ---------------------------------------------------------------------------
// GET /api/v1/jobs (list)
// ---------------------------------------------------------------------------
describe('GET /api/v1/jobs (list)', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp({ tokens: HAPPY_TOKENS });
});
afterEach(async () => {
await ctx.close();
});
function seedJobs(userId, jobs) {
if (!ctx.redis.sets.has(`user:${userId}:jobs`)) {
ctx.redis.sets.set(`user:${userId}:jobs`, new Set());
}
for (const j of jobs) {
ctx.redis.sets.get(`user:${userId}:jobs`).add(j.job_id);
ctx.redis.store.set(`job:${j.job_id}`, JSON.stringify(j));
}
}
it('returns 400 validation_error when user_id missing', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('validation_error');
expect(body.error.details.fields.map((f) => f.field)).toContain('user_id');
});
it('returns 400 when user_id contains XSS chars', async () => {
const res = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=${encodeURIComponent('<script>alert(1)</script>')}`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('validation_error');
expect(body.error.details.fields.map((f) => f.field)).toContain('user_id');
});
it('returns 400 when user_id contains slash (path traversal)', async () => {
const res = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=${encodeURIComponent('../etc/passwd')}`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
expect(res.status).toBe(400);
});
it('returns 400 when user_id contains wildcard (*)', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=${encodeURIComponent('*')}`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(400);
});
it('returns 400 when user_id contains colon (Redis key injection)', async () => {
const res = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=${encodeURIComponent('u1:malicious')}`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
expect(res.status).toBe(400);
});
it('returns 400 when user_id is too long', async () => {
const long = 'a'.repeat(129);
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=${long}`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(400);
});
it('returns empty list when user has no jobs', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u-empty`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ jobs: [], total: 0, next_cursor: null });
});
it('returns jobs filtered by status=in_progress (default)', async () => {
seedJobs('u1', [
{
job_id: 'created-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'ONNX',
stage: 'onnx',
progress: 0,
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
stage_timings: { onnx: null },
},
{
job_id: 'running-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'BIE',
stage: 'bie',
progress: 50,
created_at: '2026-04-25T11:00:00Z',
updated_at: '2026-04-25T11:00:00Z',
},
{
job_id: 'completed-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'COMPLETED',
progress: 100,
created_at: '2026-04-25T10:00:00Z',
updated_at: '2026-04-25T10:00:00Z',
},
]);
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total).toBe(2);
expect(body.jobs.map((j) => j.job_id).sort()).toEqual(['created-1', 'running-1']);
});
it('filters by status=completed', async () => {
seedJobs('u1', [
{
job_id: 'completed-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'COMPLETED',
progress: 100,
created_at: '2026-04-25T10:00:00Z',
updated_at: '2026-04-25T10:00:00Z',
},
{
job_id: 'running-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'BIE',
progress: 50,
created_at: '2026-04-25T11:00:00Z',
updated_at: '2026-04-25T11:00:00Z',
},
]);
const res = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=completed`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
const body = await res.json();
expect(body.total).toBe(1);
expect(body.jobs[0].job_id).toBe('completed-1');
expect(body.jobs[0].status).toBe('completed');
});
it('filters by status=all', async () => {
seedJobs('u1', [
{
job_id: 'completed-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'COMPLETED',
created_at: '2026-04-25T10:00:00Z',
updated_at: '2026-04-25T10:00:00Z',
},
{
job_id: 'running-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'BIE',
created_at: '2026-04-25T11:00:00Z',
updated_at: '2026-04-25T11:00:00Z',
},
{
job_id: 'failed-1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'FAILED',
error: { stage: 'bie' },
created_at: '2026-04-25T09:00:00Z',
updated_at: '2026-04-25T09:00:00Z',
},
]);
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=all`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const body = await res.json();
expect(body.total).toBe(3);
});
it('returns 400 for invalid status', async () => {
const res = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=invalid_status`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.details.fields.map((f) => f.field)).toContain('status');
});
it('CRITICAL: cross-client isolation — same user_id different client gets nothing', async () => {
// user u1 在 cid-B 有 job但 cid-A 不應該看到
seedJobs('u1', [
{
job_id: 'B-job-1',
user_id: 'u1',
created_by_client_id: 'cid-B', // 屬 cid-B
status: 'BIE',
created_at: '2026-04-25T11:00:00Z',
updated_at: '2026-04-25T11:00:00Z',
},
]);
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1`, {
headers: { Authorization: 'Bearer good-read-token' }, // cid-A
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total).toBe(0);
expect(body.jobs).toEqual([]);
// 換成 cid-B 的 token 應能看到
const resB = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1`, {
headers: { Authorization: 'Bearer good-read-token-B' },
});
expect(resB.status).toBe(200);
const bodyB = await resB.json();
expect(bodyB.total).toBe(1);
expect(bodyB.jobs[0].job_id).toBe('B-job-1');
});
it('strips internal field created_by_client_id from list items', async () => {
seedJobs('u1', [
{
job_id: 'j1',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'BIE',
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
},
]);
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=all`, {
headers: { Authorization: 'Bearer good-read-token' },
});
const body = await res.json();
expect(body.jobs[0]).not.toHaveProperty('created_by_client_id');
});
it('paginates with limit + cursor', async () => {
const jobs = [];
for (let i = 1; i <= 5; i += 1) {
jobs.push({
job_id: `j${i}`,
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'BIE',
// 排序後最新到最舊j5 j4 j3 j2 j1
created_at: `2026-04-25T${10 + i}:00:00Z`,
updated_at: `2026-04-25T${10 + i}:00:00Z`,
});
}
seedJobs('u1', jobs);
const page1 = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=all&limit=2`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
const p1Body = await page1.json();
expect(p1Body.total).toBe(5);
expect(p1Body.jobs.map((j) => j.job_id)).toEqual(['j5', 'j4']);
expect(p1Body.next_cursor).toBeTruthy();
const page2 = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=all&limit=2&cursor=${encodeURIComponent(p1Body.next_cursor)}`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
const p2Body = await page2.json();
expect(p2Body.jobs.map((j) => j.job_id)).toEqual(['j3', 'j2']);
expect(p2Body.next_cursor).toBeTruthy();
const page3 = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=u1&status=all&limit=2&cursor=${encodeURIComponent(p2Body.next_cursor)}`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
const p3Body = await page3.json();
expect(p3Body.jobs.map((j) => j.job_id)).toEqual(['j1']);
expect(p3Body.next_cursor).toBeNull();
});
it('returns 400 when limit > 50', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1&limit=51`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.details.fields.map((f) => f.field)).toContain('limit');
});
it('returns 400 when limit is non-integer', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1&limit=abc`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(400);
});
it('returns 400 when limit is 0', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1&limit=0`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.status).toBe(400);
});
it('returns 400 when cursor is malformed', async () => {
const res = await fetch(
`${ctx.baseUrl}/api/v1/jobs?user_id=u1&cursor=not-valid-base64-!!!`,
{
headers: { Authorization: 'Bearer good-read-token' },
}
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.details.fields.map((f) => f.field)).toContain('cursor');
});
it('returns response with X-Request-Id header', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs?user_id=u1`, {
headers: { Authorization: 'Bearer good-read-token' },
});
expect(res.headers.get('x-request-id')).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Cursor encode/decode 邊界
// ---------------------------------------------------------------------------
describe('cursor encode/decode roundtrip (via internal helpers)', () => {
const { _internals } = require('../jobs');
const { encodeCursor, decodeCursor } = _internals;
it('encodes 0 → decodable to 0', () => {
expect(decodeCursor(encodeCursor(0))).toBe(0);
});
it('encodes 100 → decodable to 100', () => {
expect(decodeCursor(encodeCursor(100))).toBe(100);
});
it('rejects malformed cursor', () => {
expect(decodeCursor('!!!')).toBeNull();
expect(decodeCursor('')).toBeNull();
expect(decodeCursor(null)).toBeNull();
expect(decodeCursor(undefined)).toBeNull();
});
it('rejects cursor with negative offset (defense)', () => {
const malicious = Buffer.from(JSON.stringify({ offset: -1 }), 'utf8')
.toString('base64')
.replace(/=+$/, '');
expect(decodeCursor(malicious)).toBeNull();
});
it('rejects cursor with absurdly large offset (DoS protection)', () => {
const malicious = Buffer.from(JSON.stringify({ offset: 99999999 }), 'utf8')
.toString('base64')
.replace(/=+$/, '');
expect(decodeCursor(malicious)).toBeNull();
});
it('rejects cursor that is not JSON', () => {
const notJson = Buffer.from('not json at all', 'utf8')
.toString('base64')
.replace(/=+$/, '');
expect(decodeCursor(notJson)).toBeNull();
});
it('rejects cursor JSON object missing offset', () => {
const wrong = Buffer.from(JSON.stringify({ foo: 1 }), 'utf8')
.toString('base64')
.replace(/=+$/, '');
expect(decodeCursor(wrong)).toBeNull();
});
it('rejects cursor longer than 200 chars', () => {
expect(decodeCursor('a'.repeat(201))).toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,511 @@
/**
* /api/v1/* T3
*
* 測試重點
* 1. 4 v1 端點都回 501 + 統一錯誤格式 code / message / request_id
* 2. response X-Request-Id header且值與 body.error.request_id 相同
* 3. 外部送的合法 X-Request-Id 被沿用
* 4. 外部送的非法 X-Request-Id ignoreserver 自行產生
* 5. legacy 路由不受影響仍然回原本的格式
* 6. **D4 修復驗證**requireAuth + requestId middleware 串接401 response
* body 含真正的 UUID不是 null
*
* 啟動方式 createApp + 注入 mock depsapp.listen(0) fetch() 真打 HTTP
* T1 / T4 的整合測試風格一致
*/
'use strict';
const express = require('express');
const { createApp } = require('../../../app');
const { createSseService } = require('../../../services/sseService');
const { createJobService } = require('../../../services/jobService');
const { createUploader } = require('../../../middleware/upload');
const { requireAuth } = require('../../../auth/middleware');
const { requestIdMiddleware } = require('../../../middleware/requestId');
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// ---------------------------------------------------------------------------
// Helpers — 仿 legacy.integration.test.js
// ---------------------------------------------------------------------------
function makeFakeRedis() {
const store = new Map();
return {
store,
pingFails: false,
ping: jest.fn(async function () {
if (this.pingFails) throw new Error('ping failed');
return 'PONG';
}),
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
keys: jest.fn(async () => []),
xadd: jest.fn(async () => '1-0'),
xlen: jest.fn(async () => 0),
xinfo: jest.fn(async () => {
throw new Error('NOGROUP');
}),
};
}
function makeFakeMinio() {
return {
client: null,
bucket: 'test-bucket',
endpoint: 'http://nope',
uploadToMinIO: jest.fn(async () => undefined),
getFromMinIO: jest.fn(async () => null),
};
}
async function startApp() {
const redis = makeFakeRedis();
const minio = makeFakeMinio();
const sseService = createSseService();
const jobService = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
const uploader = createUploader();
const app = createApp(
{ redis, jobService, sseService, minio, uploader },
{ frontendUrl: 'http://localhost:3000' }
);
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${port}`,
close: () =>
new Promise((r) => {
server.close(() => r());
}),
});
});
});
}
// 抑制 logs保持測試輸出乾淨
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// 501 端點骨架
// ---------------------------------------------------------------------------
describe('v1 routes — 501 骨架', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp();
});
afterEach(async () => {
await ctx.close();
});
// T7 已完成promote 的 fallback message 改寫為依賴名稱清單;
// 其他 (T5/T6) 仍處於 501 「規劃於 Tx」的階段期望 message 含 task code。
// 對於已完成端點promote改驗 message 含「config」字樣提示缺漏依賴
describe.each([
['POST /api/v1/jobs', 'POST', '/api/v1/jobs', 'T5'],
['GET /api/v1/jobs', 'GET', '/api/v1/jobs', 'T6'],
['GET /api/v1/jobs/:id', 'GET', '/api/v1/jobs/abc-123', 'T6'],
['POST /api/v1/jobs/:id/promote', 'POST', '/api/v1/jobs/abc-123/promote', 'config'],
])('%s', (label, method, path, expectedKeyword) => {
it(`returns 501 not_implemented with v1 error format`, async () => {
const res = await fetch(`${ctx.baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify({}) : undefined,
});
expect(res.status).toBe(501);
const body = await res.json();
expect(body).toHaveProperty('error');
expect(body.error.code).toBe('not_implemented');
expect(typeof body.error.message).toBe('string');
expect(body.error.message.length).toBeGreaterThan(0);
// message 應含 task code未實作端點或關鍵依賴名稱promote 已實作但缺 config
expect(body.error.message).toContain(expectedKeyword);
});
it(`response.error.request_id matches X-Request-Id response header`, async () => {
const res = await fetch(`${ctx.baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify({}) : undefined,
});
const headerId = res.headers.get('x-request-id');
const body = await res.json();
expect(headerId).toMatch(UUID_V4_REGEX);
expect(body.error.request_id).toBe(headerId);
});
});
});
// ---------------------------------------------------------------------------
// X-Request-Id 處理
// ---------------------------------------------------------------------------
describe('X-Request-Id 處理', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp();
});
afterEach(async () => {
await ctx.close();
});
it('echoes external X-Request-Id when valid', async () => {
const externalId = 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee';
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs`, {
headers: { 'X-Request-Id': externalId },
});
const body = await res.json();
expect(res.headers.get('x-request-id')).toBe(externalId);
expect(body.error.request_id).toBe(externalId);
});
it('echoes external X-Request-Id with non-UUID format (e.g. trace ID)', async () => {
const externalId = 'trace-some-system-42';
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs`, {
headers: { 'X-Request-Id': externalId },
});
const body = await res.json();
expect(res.headers.get('x-request-id')).toBe(externalId);
expect(body.error.request_id).toBe(externalId);
});
it('ignores invalid external X-Request-Id (with spaces) and generates UUID', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs`, {
headers: { 'X-Request-Id': 'has invalid spaces' },
});
const body = await res.json();
const headerId = res.headers.get('x-request-id');
expect(headerId).toMatch(UUID_V4_REGEX);
expect(headerId).not.toBe('has invalid spaces');
expect(body.error.request_id).toBe(headerId);
});
it('ignores invalid external X-Request-Id (CRLF injection attempt) and generates UUID', async () => {
// node fetch 會拒絕含 CRLF 的 header 值,所以用低階 http 模組
const http = require('http');
const url = new URL(`${ctx.baseUrl}/api/v1/jobs`);
await new Promise((resolve, reject) => {
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
headers: { 'X-Request-Id': 'evil-but-no-crlf-allowed-by-fetch' },
},
(res) => {
let raw = '';
res.on('data', (c) => {
raw += c.toString();
});
res.on('end', () => {
try {
const body = JSON.parse(raw);
const headerId = res.headers['x-request-id'];
// 'evil-but-no-crlf-allowed-by-fetch' 是合法格式(只有英文 / -
// → 應被沿用
expect(headerId).toBe('evil-but-no-crlf-allowed-by-fetch');
expect(body.error.request_id).toBe(headerId);
resolve();
} catch (e) {
reject(e);
}
});
}
);
req.on('error', reject);
req.end();
});
});
it('ignores too-long X-Request-Id (>100 chars) and generates UUID', async () => {
const tooLong = 'a'.repeat(101);
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs`, {
headers: { 'X-Request-Id': tooLong },
});
const headerId = res.headers.get('x-request-id');
expect(headerId).toMatch(UUID_V4_REGEX);
expect(headerId).not.toBe(tooLong);
});
});
// ---------------------------------------------------------------------------
// Legacy 路由不受影響
// ---------------------------------------------------------------------------
describe('legacy 路由不受 v1 影響', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp();
});
afterEach(async () => {
await ctx.close();
});
it('GET /health 仍回原本格式service / status / redis', async () => {
const res = await fetch(`${ctx.baseUrl}/health`);
expect(res.status).toBe(200);
const body = await res.json();
// legacy 格式:不含 error / code / request_id
expect(body).toMatchObject({
service: 'task-scheduler',
status: 'healthy',
redis: 'connected',
});
expect(body).not.toHaveProperty('error');
// X-Request-Id header 仍然會被掛(全域 middleware
expect(res.headers.get('x-request-id')).toMatch(UUID_V4_REGEX);
});
it('legacy 404 response format unchanged', async () => {
const res = await fetch(`${ctx.baseUrl}/no-such-legacy-path`);
expect(res.status).toBe(404);
const body = await res.json();
// legacy 格式仍是 `{ error: 'string' }`,不是 v1 的 `{ error: { code, ... } }`
expect(body.error).toBe('Endpoint not found');
});
});
// ---------------------------------------------------------------------------
// Minor-1v1 prefix 下未匹配路徑回 v1 格式 404 not_found
// ---------------------------------------------------------------------------
describe('v1 catch-allMinor-1 修復):未匹配 v1 路徑回 404 not_found', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp();
});
afterEach(async () => {
await ctx.close();
});
it('GET /api/v1/foobar 回 v1 格式 404 not_found含 request_id', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/foobar`);
expect(res.status).toBe(404);
const body = await res.json();
// v1 統一格式error 是 object不是字串
expect(typeof body.error).toBe('object');
expect(body.error.code).toBe('not_found');
expect(typeof body.error.message).toBe('string');
expect(body.error.message.length).toBeGreaterThan(0);
// request_id 應為合法 UUID沒帶 X-Request-Id 時 server 自產)
expect(body.error.request_id).toMatch(UUID_V4_REGEX);
// header 與 body 內 request_id 一致
expect(res.headers.get('x-request-id')).toBe(body.error.request_id);
});
it('GET /api/v1/jobs/foobar/strange-action 回 v1 格式 404巢狀未匹配路徑', async () => {
// 此路徑不會匹配 promote路徑不以 /promote 結尾)也不會匹配 jobs 任一路由
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/foobar/strange-action`);
expect(res.status).toBe(404);
const body = await res.json();
expect(typeof body.error).toBe('object');
expect(body.error.code).toBe('not_found');
expect(body.error.request_id).toMatch(UUID_V4_REGEX);
});
it('未匹配 v1 路徑會 echo 外部 X-Request-Id驗證 request_id 貫穿)', async () => {
const externalId = 'trace-minor1-fix-123';
const res = await fetch(`${ctx.baseUrl}/api/v1/no-such-route`, {
headers: { 'X-Request-Id': externalId },
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error.code).toBe('not_found');
expect(body.error.request_id).toBe(externalId);
expect(res.headers.get('x-request-id')).toBe(externalId);
});
});
// ---------------------------------------------------------------------------
// Minor-2Phase 2 預留端點回 501 not_implemented
// ---------------------------------------------------------------------------
describe('Phase 2 預留端點Minor-2 修復):回 501 not_implemented', () => {
let ctx;
beforeEach(async () => {
ctx = await startApp();
});
afterEach(async () => {
await ctx.close();
});
it('POST /api/v1/jobs/:id/download-tokens 回 v1 格式 501 not_implemented', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/abc-123/download-tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(res.status).toBe(501);
const body = await res.json();
expect(typeof body.error).toBe('object');
expect(body.error.code).toBe('not_implemented');
expect(typeof body.error.message).toBe('string');
// 訊息應提到 Phase 2 以利 client 區分「尚未實作」vs「未來不會做」
expect(body.error.message).toContain('Phase 2');
expect(body.error.request_id).toMatch(UUID_V4_REGEX);
expect(res.headers.get('x-request-id')).toBe(body.error.request_id);
});
it('DELETE /api/v1/jobs/:id 回 v1 格式 501 not_implemented', async () => {
const res = await fetch(`${ctx.baseUrl}/api/v1/jobs/abc-123`, {
method: 'DELETE',
});
expect(res.status).toBe(501);
const body = await res.json();
expect(typeof body.error).toBe('object');
expect(body.error.code).toBe('not_implemented');
expect(body.error.message).toContain('Phase 2');
expect(body.error.request_id).toMatch(UUID_V4_REGEX);
expect(res.headers.get('x-request-id')).toBe(body.error.request_id);
});
it('Phase 2 端點不會被 catch-all 吃成 404驗證掛 501 在 catch-all 之前生效)', async () => {
// 若 Phase 2 端點未掛,會落到 catch-all 變成 404 not_found
// 此測試確保我們真的回 501 not_implemented。
const downloadRes = await fetch(
`${ctx.baseUrl}/api/v1/jobs/some-id/download-tokens`,
{ method: 'POST' }
);
expect(downloadRes.status).toBe(501);
expect((await downloadRes.json()).error.code).toBe('not_implemented');
const deleteRes = await fetch(`${ctx.baseUrl}/api/v1/jobs/some-id`, {
method: 'DELETE',
});
expect(deleteRes.status).toBe(501);
expect((await deleteRes.json()).error.code).toBe('not_implemented');
});
});
// ---------------------------------------------------------------------------
// D4 修復驗證requireAuth + requestId 串接
// ---------------------------------------------------------------------------
describe('D4 修復requireAuth + requestId middleware 串接', () => {
// 此測試獨立於 v1 router 之外,直接組裝一個簡易 app 驗證串接行為
let server;
let baseUrl;
beforeAll(async () => {
const app = express();
app.use(requestIdMiddleware);
app.get(
'/protected',
requireAuth('converter:job.write', {
config: {
memberCenter: {
issuer: 'https://auth.test.local',
jwksUrl: 'https://auth.test.local/.well-known/jwks',
tokenUrl: '',
},
converter: {
audience: 'kneron_converter_api',
clientId: '',
clientSecret: '',
tenantId: '',
scopeWrite: 'converter:job.write',
scopeRead: 'converter:job.read',
},
fileAccessAgent: { baseUrl: '', audience: 'file_access_api' },
jwks: { cacheMaxAgeMs: 60000, cooldownMs: 30000, clockToleranceSec: 60 },
},
// verify 函數一律 throw 模擬「token 無效」(此測試只關心 401 path 的 request_id)
verify: async () => {
const e = new Error('signature failed');
e.code = 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED';
throw e;
},
}),
(_req, res) => res.status(200).json({ ok: true })
);
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', resolve);
});
const addr = server.address();
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
if (server) {
await new Promise((r) => server.close(r));
}
});
it('401 response.error.request_id is a real UUID (not null) when no X-Request-Id sent', async () => {
const res = await fetch(`${baseUrl}/protected`, {
headers: { Authorization: 'Bearer invalid-token' },
});
expect(res.status).toBe(401);
const body = await res.json();
// **D4 修復的核心驗證**request_id 不再是 null
expect(body.error.request_id).not.toBeNull();
expect(body.error.request_id).toMatch(UUID_V4_REGEX);
// 而且該值與 response header X-Request-Id 一致
expect(res.headers.get('x-request-id')).toBe(body.error.request_id);
});
it('401 response.error.request_id echoes external X-Request-Id when valid', async () => {
const externalId = 'trace-d4-fix-verification-42';
const res = await fetch(`${baseUrl}/protected`, {
headers: {
Authorization: 'Bearer invalid-token',
'X-Request-Id': externalId,
},
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error.request_id).toBe(externalId);
expect(res.headers.get('x-request-id')).toBe(externalId);
});
it('401 with missing Authorization header still has real request_id (not null)', async () => {
const res = await fetch(`${baseUrl}/protected`);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error.code).toBe('invalid_token');
expect(body.error.request_id).not.toBeNull();
expect(body.error.request_id).toMatch(UUID_V4_REGEX);
});
});

View File

@ -0,0 +1,99 @@
/**
* /api/v1 router T3
*
* 職責
* 1. 組裝 v1 routerjobs / promote
* 2. v1 專用的 errorHandler在所有子 router 之後最末端
*
* 為什麼 errorHandler 掛在 v1 router 而非 app.js 全域
* - 全域 errorHandler 需處理 legacy 行為既有的 `{ error: 'string' }` 格式
* - 若把 v1 errorHandler 全域掛會改變 legacy 路徑的回應格式破壞向後相容
* - v1 router scope 內掛 errorHandler 可保證
* * v1 路徑用新格式 code / request_id / details
* * legacy 路徑維持既有格式
* - Express 4 router-level error middleware 捕捉本 router 內的 next(err)
* bubble 到外層需顯式 next(err) errorHandler 屬終態res.json 後不再 next
*
* 路由結構
* /api/v1
* /jobs POST/GETjobs router
* /jobs/:id GETjobs router
* /jobs/:id/promote POSTpromote routermergeParams :id
*
* 注意
* T3 不掛 requireAuthT5/T6/T7 實作各端點時會在各自 handler 之前加
* per-client_id rate limiterT3 計畫也尚未掛 requireAuth 順序強相關
* 留待 T5 起需要 clientId 時再加避免提前耦合
*/
'use strict';
const express = require('express');
const { createJobsRouter } = require('./jobs');
const { createPromoteRouter } = require('./promote');
const { errorHandler, ApiError } = require('../../middleware/errorHandler');
/**
* 建立 /api/v1 router
*
* @param {object} [deps] 注入給各子 routerT5 jobs router 需要
* @param {object} [deps.jobService] createJobService(...) 的回傳
* @param {object} [deps.uploader] multer instance
* @param {object} [deps.minio] minio facade
* @param {object} [deps.config] config.loadConfig() 結果auth
* @param {object} [deps.rateLimit] { windowMs, max } 覆寫 per-client_id 預設
* @param {string} [deps.storageBackend] 'minio' / 'local'T5 handler 啟動時驗證
* @returns {import('express').Router}
*/
function createV1Router(deps = {}) {
const router = express.Router();
// /api/v1/jobs/:id/promote — 獨立 router 以利 T7 集中管理 FAA 相依
// **必須**先掛 promote 再掛 jobs避免 jobs router 的 GET /:id 把
// `/abc-123/promote` 之類的路徑誤吃Express 是 first-match-wins
// 注:實際上 GET /jobs/:id 是 GET 不匹配 POST所以即使順序顛倒也安全
// 但為了清楚意圖(特殊路徑優先),先掛 promote。
//
// T7把 jobService / minio / faaClient / config / rateLimit 透傳給 promote router
// 缺任一 dep 時 promote router 會 fallback 到 501與 jobs.js 同設計)。
const promoteRouter = createPromoteRouter({
jobService: deps.jobService,
minio: deps.minio,
faaClient: deps.faaClient,
config: deps.config,
rateLimit: deps.rateLimit,
});
router.use('/jobs/:id/promote', promoteRouter);
// /api/v1/jobs/* — POST / GET / GET :id
const jobsRouter = createJobsRouter(deps);
router.use('/jobs', jobsRouter);
// v1 prefix 下未匹配路徑的 catch-allMinor-1 修復)
//
// 為什麼需要:
// 未掛此 catch-all 時,`/api/v1/foobar` 會 fall through 出 v1 router、被全域
// `app.use('*', ...)` 接到,回 legacy 格式 `{"error":"Endpoint not found"}`。
// 這違反 TDD §1.2「所有 4xx/5xx 回應使用統一格式」——對 v1 client 是格式不一致。
//
// 為什麼放在這個位置:
// - 必須在所有 router.use(...) 之後(讓真實路由先有機會匹配)
// - 必須在 errorHandler 之前(這是普通 middlewareerrorHandler 才是 4-arg
//
// 為什麼用 next(new ApiError(...)) 而非直接 res.status(404).json(...)
// 統一走 errorHandler 輸出,可保證錯誤格式(含 request_id、log 行為)一致。
router.use((req, res, next) => {
return next(
new ApiError(404, 'not_found', `路徑不存在:${req.method} ${req.originalUrl}`)
);
});
// 注意errorHandler **必須**放在所有 route 之後
// Express 4 的 error middleware 規則4 個參數才會被當作 error handler
router.use(errorHandler);
return router;
}
module.exports = { createV1Router };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,621 @@
/**
* /api/v1/jobs/:id/promote T7
*
* 流程對齊 TDD §1.4.5§2.10§6.1-§6.5tasks-phase1.md §2 T7
*
* 1. requireAuth('converter:job.write')
* 401 invalid_token / 403 insufficient_scope含主動 destroy 連線T1 M2
* ok 繼續
*
* 2. validate bodytargets 非空source {onnx, bie, nef}target_object_key 安全
* 失敗 400 validation_error / 422 invalid_object_key
* ok 繼續
*
* 3. job:{id}
* 不存在 / 不屬於 client 404 job_not_found不洩漏存在性
* ok 繼續
*
* 4. 冪等性兩個層級
* a. job.promoted === true直接回 200 + 既有 promoted_object_keys
* 不重打 FAA不查 MinIO
* b. 非冪等命中狀態檢查status !== 'completed' 409 job_not_ready_for_promote
*
* 5. 對每個 target**序列執行**避免 FAA 端並發壓力
* a. job.output[source] / job.result_object_keys[source] 存在 否則 409 source_not_available
* b. minio.headObject(sourceKey) size + contentType
* c. faa.putFile(targetKey, () => minio.getObjectStream(sourceKey).stream, { contentLength, contentType })
* streamFactory 形式重試時可拿新 stream
* d. 收集 { source, target_object_key, size_bytes, file_access_agent_etag, promoted_at }
*
* 6. 全部成功 jobService.markPromoted(jobId, ...) 200 + { job_id, promoted: [...] }
* part-failure stream 模式下難以原子化Phase 1 有失敗就 throw 並回 502
*
* 重要決策
* - **流程上不接受 client 指定 NAS 命名格式以外的東西**caller target_object_key
* 但會 sanity check `..` `\\` 絕對路徑VisionA 自己決定 key 命名TDD §6.1
* - **大檔 stream** streamFactory pattern 確保重試時能拿新 streamHTTP body 不可 replay
* - **不洩露**FAA 內部錯誤 message 不直接傳給 v1 client統一轉成 502 / 503 + 文案
*
* 認證
* T7 階段掛 `requireAuth('converter:job.write')` POST /jobs scope
*/
'use strict';
const express = require('express');
const { ApiError } = require('../../middleware/errorHandler');
const { requireAuth } = require('../../auth/middleware');
const { createPerClientRateLimiter } = require('../../middleware/perClientRateLimit');
const { createFaaClient } = require('../../fileAccessAgent/client');
const {
FAAClientError,
FAAUnauthorizedError,
FAAServerError,
FAATimeoutError,
} = require('../../fileAccessAgent/errors');
// ---------------------------------------------------------------------------
// 常數
// ---------------------------------------------------------------------------
const VALID_SOURCES = Object.freeze(new Set(['onnx', 'bie', 'nef']));
/** target_object_key 上限(防 oversized request body。 */
const MAX_TARGET_KEY_LENGTH = 1024;
/** 一個 promote request 最多幾個 target防 abuse。 */
const MAX_TARGETS = 10;
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
/**
* 結構化 log不洩 token / FAA 細節
*
* @param {object} fields
*/
function logEvent(fields) {
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
service: 'task-scheduler',
timestamp: new Date().toISOString(),
...fields,
})
);
}
/**
* target_object_key 安全性
*
* 拒絕
* - 空字串
* - `..`路徑穿越
* - `\\` 反斜線Windows 路徑URL 注入
* - control chars / null byte
* - leading `/`避免被 FAA 解讀為絕對路徑
* - 超過 MAX_TARGET_KEY_LENGTH
* - `?`URL query 注入FAA 端可能誤把後段視為查詢參數
* - `#`URL fragment同樣會破壞 buildUrl 行為
* - `%`雙重編碼攻擊client %2E%2E 會在 FAA 解碼後變 ..
*
* @param {unknown} key
* @returns {boolean}
*/
function isValidTargetKey(key) {
if (typeof key !== 'string') return false;
if (key.length === 0 || key.length > MAX_TARGET_KEY_LENGTH) return false;
if (key.startsWith('/')) return false;
if (key.includes('..')) return false;
if (key.includes('\\')) return false;
if (key.includes('\0')) return false;
// 拒控制字元(\x00 - \x1F、\x7F— eslint no-control-regex 預設 ok
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F]/.test(key)) return false;
// 拒 URL 結構字元 — 防 query / fragment / 雙重編碼攻擊
if (key.includes('?')) return false;
if (key.includes('#')) return false;
if (key.includes('%')) return false;
return true;
}
/**
* promote request body
*
* @param {unknown} body
* @returns {{ ok: true, targets: Array<{ source: string, target_object_key: string }> }
* | { ok: false, status: number, code: string, message: string, details?: object }}
*/
function validatePromoteBody(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return {
ok: false,
status: 400,
code: 'validation_error',
message: 'request body 必須為 JSON 物件',
};
}
const targets = body.targets;
if (!Array.isArray(targets)) {
return {
ok: false,
status: 400,
code: 'validation_error',
message: 'targets 欄位必須為陣列',
details: { fields: [{ field: 'targets', message: 'must be an array' }] },
};
}
if (targets.length === 0) {
return {
ok: false,
status: 400,
code: 'validation_error',
message: 'targets 不可為空',
details: { fields: [{ field: 'targets', message: 'must contain at least 1 item' }] },
};
}
if (targets.length > MAX_TARGETS) {
return {
ok: false,
status: 400,
code: 'validation_error',
message: `targets 數量超過上限 ${MAX_TARGETS}`,
details: { fields: [{ field: 'targets', message: `max ${MAX_TARGETS}` }] },
};
}
const validated = [];
const fieldErrors = [];
// 同時擋重複(同 source 多次 promote 對 client 沒意義,避免處理混亂)
const seenSources = new Set();
for (let i = 0; i < targets.length; i += 1) {
const t = targets[i];
if (!t || typeof t !== 'object') {
fieldErrors.push({
field: `targets[${i}]`,
message: 'must be an object',
});
continue;
}
const source = t.source;
if (typeof source !== 'string' || !VALID_SOURCES.has(source)) {
fieldErrors.push({
field: `targets[${i}].source`,
message: `must be one of: ${[...VALID_SOURCES].join(', ')}`,
});
continue;
}
if (seenSources.has(source)) {
fieldErrors.push({
field: `targets[${i}].source`,
message: `duplicate source '${source}' in same request`,
});
continue;
}
seenSources.add(source);
if (!isValidTargetKey(t.target_object_key)) {
// 422 invalid_object_keyTDD §14
return {
ok: false,
status: 422,
code: 'invalid_object_key',
message: 'target_object_key 格式不合法',
details: {
field: `targets[${i}].target_object_key`,
reason:
'不可為空、不可含 .. / 反斜線 / 控制字元 / 開頭斜線 / ? / # / %;長度 ≤ 1024',
},
};
}
validated.push({
source,
target_object_key: t.target_object_key,
});
}
if (fieldErrors.length > 0) {
return {
ok: false,
status: 400,
code: 'validation_error',
message: 'targets 格式錯誤',
details: { fields: fieldErrors },
};
}
return { ok: true, targets: validated };
}
/**
* job 取出指定 source Converter Bucket object key
*
* 對應既有 server.js / T6 jobs.js 的兩種寫法
* - `result_object_keys.{source}`新格式 / T9
* - `output.bie_path` / `output.nef_path` / `output.onnx_path`舊格式 / 既有
*
* @param {object} job
* @param {string} source
* @returns {string|null}
*/
function getJobOutputKey(job, source) {
if (
job.result_object_keys &&
typeof job.result_object_keys === 'object' &&
typeof job.result_object_keys[source] === 'string' &&
job.result_object_keys[source].length > 0
) {
return job.result_object_keys[source];
}
if (job.output && typeof job.output === 'object') {
const k = job.output[`${source}_path`];
if (typeof k === 'string' && k.length > 0) return k;
}
return null;
}
/**
* FAA error 轉成 v1 ApiError不洩露內部細節
*
* @param {Error} err
* @param {string} requestId
*/
function classifyFaaError(err) {
if (err instanceof FAAUnauthorizedError) {
// 已重試一次仍 401 → token 真的不行 → 503 auth_service_unavailable
return new ApiError(
503,
'auth_service_unavailable',
'認證服務目前無法簽發必要 token請稍後重試'
);
}
if (err instanceof FAAClientError) {
// 4xx 非 401 — 對 client 而言,這通常是 target_object_key 被 FAA 拒
// 不重試也不洩漏 FAA 內部 error_code
return new ApiError(
502,
'file_gateway_unavailable',
'檔案存取服務拒絕此請求'
);
}
// 5xx / timeout — 全部都已重試完,仍失敗 → 502
if (
err instanceof FAAServerError ||
err instanceof FAATimeoutError
) {
return new ApiError(
502,
'file_gateway_unavailable',
'檔案存取服務暫時無法使用,請稍後重試'
);
}
// 不該發生 — 一律 500
return new ApiError(500, 'internal_error', 'promote 過程發生未預期錯誤');
}
// ---------------------------------------------------------------------------
// 真實 handler
// ---------------------------------------------------------------------------
/**
* 建立 promote handler
*
* @param {object} deps
* @param {object} deps.jobService - createJobService 結果
* @param {object} deps.minio - createMinioFacade 結果
* @param {object} deps.faaClient - createFaaClient 結果
*/
function buildPromoteHandler(deps) {
const { jobService, minio, faaClient } = deps;
if (!jobService) throw new Error('[promote] deps.jobService is required');
if (!minio) throw new Error('[promote] deps.minio is required');
if (!faaClient) throw new Error('[promote] deps.faaClient is required');
return async function promoteHandler(req, res, next) {
const startedAtMs = Date.now();
try {
// 1. 驗 jobId path param
const jobId = req.params && req.params.id;
if (typeof jobId !== 'string' || jobId === '') {
return next(new ApiError(404, 'job_not_found', 'Job 不存在'));
}
// 2. 驗 body
const validation = validatePromoteBody(req.body);
if (!validation.ok) {
return next(
new ApiError(
validation.status,
validation.code,
validation.message,
validation.details
)
);
}
const { targets } = validation;
// 3. 讀 job + client 隔離(同 GET /:id 邏輯)
const job = await jobService.getJob(jobId);
const clientId =
req.auth && typeof req.auth.clientId === 'string'
? req.auth.clientId
: null;
if (!job || (clientId && job.created_by_client_id && job.created_by_client_id !== clientId)) {
logEvent({
level: 'INFO',
action: 'promote.not_found',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
duration_ms: Date.now() - startedAtMs,
});
return next(new ApiError(404, 'job_not_found', 'Job 不存在'));
}
// 4a. 冪等性 short-circuit
// 已 promoted 就直接回 既有 promoted_object_keys不重打 FAA、不重新讀 MinIO
if (job.promoted === true && Array.isArray(job.promoted_object_keys)) {
logEvent({
level: 'INFO',
action: 'promote.idempotent_hit',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
existing_count: job.promoted_object_keys.length,
duration_ms: Date.now() - startedAtMs,
});
return res.status(200).json({
job_id: jobId,
promoted: job.promoted_object_keys,
});
}
// 4b. 狀態檢查:必須 COMPLETED 才能 promote
// 內部 status 仍用大寫此處直接比對promote 是 internal 概念,不需 statusMapper
if (job.status !== 'COMPLETED') {
logEvent({
level: 'INFO',
action: 'promote.not_ready',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
internal_status: job.status,
duration_ms: Date.now() - startedAtMs,
});
return next(
new ApiError(
409,
'job_not_ready_for_promote',
'Job 尚未完成,無法 promote',
{ current_status: job.status || null }
)
);
}
// 5. 序列 promote 每個 target
// 為什麼序列:
// - FAA 端對單一 client 並發可能有限制;序列保守
// - 失敗時容易判斷哪個 target 已成功(雖然 Phase 1 採全失敗 502
// - 大檔串流並發會讓記憶體 / CPU 壓力放大
const promotedResults = [];
for (let i = 0; i < targets.length; i += 1) {
const target = targets[i];
// 5a. 驗 source 在 job 中存在
const sourceKey = getJobOutputKey(job, target.source);
if (!sourceKey) {
logEvent({
level: 'INFO',
action: 'promote.source_not_available',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
source: target.source,
});
return next(
new ApiError(
409,
'source_not_available',
`Job 沒有 ${target.source} 階段的結果可 promote`,
{ source: target.source }
)
);
}
// 5b. HEAD 取 size + contentTypefetch PUT 必填 Content-Length
let head;
try {
head = await minio.headObject(sourceKey);
} catch (err) {
// 不 log err.message — 可能含 MinIO endpoint / region / object key 等內部資訊。
// 改 log err.name / err.code 用於分類aws-sdk 的 NoSuchKey、NetworkingError 等)。
logEvent({
level: 'ERROR',
action: 'promote.minio_head_failed',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
source: target.source,
error_name: err && err.name ? err.name : 'unknown',
error_code: err && err.code ? err.code : null,
});
return next(
new ApiError(
502,
'storage_unavailable',
'無法讀取結果檔 metadata請稍後重試'
)
);
}
if (!head || typeof head.contentLength !== 'number') {
logEvent({
level: 'ERROR',
action: 'promote.minio_head_no_size',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
source: target.source,
});
return next(
new ApiError(
502,
'storage_unavailable',
'結果檔 metadata 不完整,請稍後重試'
)
);
}
// 5c. 呼叫 faaClient.putFile傳 streamFactory重試時能拿新 stream
// 為什麼用 factory
// HTTP body 不可 replay如果 attempt #1 5xx 失敗attempt #2 必須拿新 stream。
// factory 每次 attempt 才呼叫 minio.getObjectStream保證 stream 是新的。
const streamFactory = async () => {
const got = await minio.getObjectStream(sourceKey);
if (!got || !got.stream) {
// 這不該發生HEAD 已成功),保險起見 throw 讓 FAA timeout 路徑走 5xx 重試
throw new Error(
`[promote] minio.getObjectStream(${sourceKey}) returned no stream`
);
}
return got.stream;
};
let putMeta;
try {
putMeta = await faaClient.putFile(target.target_object_key, streamFactory, {
contentLength: head.contentLength,
contentType: head.contentType || 'application/octet-stream',
});
} catch (err) {
logEvent({
level: 'WARN',
action: 'promote.faa_put_failed',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
source: target.source,
error_name: err && err.name ? err.name : 'unknown',
error_status: err && typeof err.status === 'number' ? err.status : null,
});
// 不洩漏 FAA 內部錯誤
return next(classifyFaaError(err));
}
const promotedAt = new Date().toISOString();
promotedResults.push({
source: target.source,
target_object_key: target.target_object_key,
size_bytes: putMeta.sizeBytes != null ? putMeta.sizeBytes : head.contentLength,
file_access_agent_etag: putMeta.etag || null,
promoted_at: promotedAt,
});
}
// 6. 全部成功 → 寫回 job record冪等支援
const finalPromotedAt = new Date().toISOString();
try {
await jobService.markPromoted(jobId, {
promotedAt: finalPromotedAt,
promotedKeys: promotedResults,
});
} catch (err) {
// FAA 已成功(檔案在 NAS 上)但 Redis 寫失敗 — log ERROR 不影響 client 回應
// 因為 promote 的「主要副作用」(檔案搬到 NAS已完成下次 promote 同 job 時
// markPromoted 會再嘗試FAA 那邊重新 PUT 是冪等的)。
// 不 log err.message — 可能含 Redis URL / key 等內部資訊;只 log 分類用 name/code。
logEvent({
level: 'ERROR',
action: 'promote.mark_failed',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
error_name: err && err.name ? err.name : 'unknown',
error_code: err && err.code ? err.code : null,
});
// 仍回 200檔案實際已搬完但 client 後續呼叫不會走 idempotent path
}
logEvent({
level: 'INFO',
action: 'promote.success',
request_id: req.requestId,
job_id: jobId,
client_id: clientId,
target_count: promotedResults.length,
duration_ms: Date.now() - startedAtMs,
});
return res.status(200).json({
job_id: jobId,
promoted: promotedResults,
});
} catch (err) {
return next(err);
}
};
}
/**
* 建立 promote router
*
* @param {object} [deps]
* @param {object} [deps.jobService]
* @param {object} [deps.minio]
* @param {object} [deps.faaClient]
* @param {object} [deps.config]
* @param {object} [deps.rateLimit]
*/
function createPromoteRouter(deps = {}) {
const router = express.Router({ mergeParams: true });
const { jobService, minio, faaClient, config, rateLimit } = deps;
// 缺 deps 的情境(單元測試或 createApp 沒注入 config 時)→ 501 fallback。
// message 列出實際缺漏的依賴方便維運排查不再寫死「T7」之類版本字眼
if (!jobService || !minio || !faaClient || !config) {
const missing = [];
if (!jobService) missing.push('jobService');
if (!minio) missing.push('minio');
if (!faaClient) missing.push('faaClient');
if (!config) missing.push('config');
const missingList = missing.join(', ');
router.post('/', (req, res, next) => {
return next(
new ApiError(
501,
'not_implemented',
`POST /api/v1/jobs/:id/promote 端點需要 jobService / minio / faaClient / config 注入;當前環境配置不完整,缺漏依賴:${missingList}`
)
);
});
return router;
}
const requireWriteAuth = requireAuth(config.converter.scopeWrite, { config });
const perClientLimiter = createPerClientRateLimiter(rateLimit || {});
const handler = buildPromoteHandler({ jobService, minio, faaClient });
// 順序鎖死requireAuth → perClientRateLimit → JSON 已由 app.use(express.json) 全域 parse → handler
router.post('/', requireWriteAuth, perClientLimiter, handler);
return router;
}
module.exports = {
createPromoteRouter,
// 內部暴露給單元測試 / createApp wiring
_internals: {
buildPromoteHandler,
validatePromoteBody,
isValidTargetKey,
getJobOutputKey,
classifyFaaError,
VALID_SOURCES,
MAX_TARGET_KEY_LENGTH,
MAX_TARGETS,
},
// 為 wiring 簡便:暴露 createFaaClient保持 promote.js 是 FAA 客戶端的單一接觸點)
createFaaClient,
};

View File

@ -0,0 +1,331 @@
/**
* POST /api/v1/jobs validatorT5
*
* 對齊 TDD §1.4.2 / Review §4.1 #2 doc-review m6/m7
*
* 規則摘要
* - model file 必填副檔名 {`.onnx`, `.tflite`}PRD §4.4**** TDD §1.4.2 6
* 理由見 doc-review m6 PRD F-01 為準因為 PRD 才是 user-facing
* - ref_images[] 可選每張獨立 sanitize不限副檔名 size multer limit 把關
* - user_id 必填1-128 chars不含 `/` `\` `..` `:` 控制字元
* - model_id 必填 int 1 x 65535
* - version 必填1-32 chars
* - platform 必填enum: 520 / 720 / 530 / 630 / 730
* - enable_* 可選'true' / 'false' 字串轉 boolean缺漏視為 falsedoc-review m7
* - metadata 可選若有則必須是合法 JSON 物件字串
*
* 設計原則
* - validator 只負責靜態驗證(欄位存在 + 格式) STORAGE_BACKEND / 衝突等
* runtime 條件由 handler 處理
* - 一律回 `{ ok, errors, data }` 而非 throw方便 handler 統一收集所有錯誤
* - errors 形狀對齊 TDD §1.5 details.field`[{ field, message }]`
*
* 安全
* - 所有 string 都先 sanitizetrim / 控制字元檢查
* - 副檔名比對前先 lowercase避免 `MODEL.ONNX` 被當作未知格式
* - parseInt base 10 + 檢查 NaN避免 `0x` 這類前綴炸進來
*/
'use strict';
const {
sanitizeFilename,
getExtension,
validateUserId,
} = require('../../../utils/sanitize');
/**
* model 副檔名白名單 PRD §4.4 對齊
*
* 為什麼不採 TDD §1.4.2 6
* doc-review m6 已標明 TDD PRD 不一致PRD §4.1 F-01 / §4.4 US-08 明確
* 支援 `.onnx` / `.tflite`本實作選 PRD 為準因為它代表 user 看到的合約
*/
const ALLOWED_MODEL_EXTENSIONS = new Set(['.onnx', '.tflite']);
/**
* platform enum對齊 TDD §1.4.2
*/
const ALLOWED_PLATFORMS = new Set(['520', '720', '530', '630', '730']);
/**
* version 字串白名單Sec M3 修正
*
* 為什麼用白名單
* - version 會出現在 jobRecordlog 與未來 API response `parameters.version`
* 欄位若允許 `<script>...</script>` 之類字元下游消費者admin UI日誌
* 檢視工具可能存在 XSS 風險
* - 接受字元英數字 / `.` / `_` / `-`足以涵蓋所有合理的 version naming
* `v1.0.0``2026-04-25``build_42`
*/
const VERSION_WHITELIST = /^[A-Za-z0-9._-]+$/;
/**
* 單張 ref_image 的大小上限預設值Sec C2 修正T10 改為可由 env / opts 覆寫
*
* 為什麼用 10MB
* - ref_images 校正用樣本calibration samples通常是低解析度圖片
* 單張 < 1MB 是常見情境10MB 足以覆蓋極端 case
* - multer `limits.fileSize` per-file 通用上限500MB for model 100
* ref_images 500MB 50GB 單請求 OOM kill用此 per-file 上限阻擋
* - 超過時回 413 file_too_large語意對齊 TDD §14
*
* T10此值可由 `MULTIPART_REF_IMAGE_MAX_BYTES` env 覆寫validator 第二參數
* `opts.refImageMaxBytes` 也可注入route 層由 config 透傳
*/
const DEFAULT_MAX_REF_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
/**
* 為向後相容既有測試 import const保留 export 名稱
* @deprecated 請改用 `DEFAULT_MAX_REF_IMAGE_SIZE_BYTES` validator opts 注入
*/
const MAX_REF_IMAGE_SIZE_BYTES = DEFAULT_MAX_REF_IMAGE_SIZE_BYTES;
/**
* 共用 push helper 統一 errors 格式為 `{ field, message }`
*
* @param {{field: string, message: string}[]} errors
* @param {string} field
* @param {string} message
*/
function pushError(errors, field, message) {
errors.push({ field, message });
}
/**
* multipart files / fields `{ ok, errors, data }`
*
* `data.input` 存放成功 sanitize 後可直接寫 MinIO 的資訊handler 會用此 data
* object_key job record
*
* @param {object} args
* @param {object} args.body req.bodymulter multipart fields
* @param {object|undefined} args.files req.filesmulter fields 解出來的物件
* @param {object} [args.limits] T10 起可注入缺漏時用預設值
* @param {number} [args.limits.refImageMaxBytes] 單張 ref_image 大小上限bytes
* @returns {{
* ok: boolean,
* errors: Array<{field: string, message: string}>,
* data?: {
* userId: string,
* parameters: {
* model_id: number,
* version: string,
* platform: string,
* enable_evaluate: boolean,
* enable_sim_fp: boolean,
* enable_sim_fixed: boolean,
* enable_sim_hw: boolean,
* },
* metadata: object | null,
* input: {
* file: object, // multer file object含 buffer
* safeFilename: string,
* extension: string,
* },
* refImages: Array<{
* file: object,
* safeFilename: string,
* }>,
* }
* }}
*/
function validateCreateJobRequest({ body, files, limits } = {}) {
const errors = [];
// T10refImage 上限由 opts.limits.refImageMaxBytes 注入;缺漏時 fallback 到
// DEFAULT_MAX_REF_IMAGE_SIZE_BYTES保持向後相容
// 不接受非正數(避免 0 / 負數讓所有 ref_images 都被 reject
const refImageMaxBytes =
limits &&
Number.isInteger(limits.refImageMaxBytes) &&
limits.refImageMaxBytes > 0
? limits.refImageMaxBytes
: DEFAULT_MAX_REF_IMAGE_SIZE_BYTES;
// 1. user_id
const userIdRaw = body && typeof body.user_id === 'string' ? body.user_id : '';
const userId = validateUserId(userIdRaw);
if (!userId) {
pushError(errors, 'user_id', 'user_id 必填1-128 字元,不含 / \\ : 或 ..');
}
// 2. model_id
let modelIdInt = null;
const modelIdRaw =
body && typeof body.model_id === 'string' ? body.model_id.trim() : '';
if (modelIdRaw === '') {
pushError(errors, 'model_id', 'model_id 必填');
} else if (!/^\d+$/.test(modelIdRaw)) {
pushError(errors, 'model_id', 'model_id 必須為非負整數');
} else {
modelIdInt = parseInt(modelIdRaw, 10);
if (modelIdInt < 1 || modelIdInt > 65535) {
pushError(errors, 'model_id', 'model_id 範圍必須在 1 ~ 65535');
modelIdInt = null;
}
}
// 3. versionSec M3嚴格白名單
const versionRaw =
body && typeof body.version === 'string' ? body.version.trim() : '';
if (versionRaw === '' || versionRaw.length > 32) {
pushError(errors, 'version', 'version 必填,最多 32 字元');
} else if (!VERSION_WHITELIST.test(versionRaw)) {
pushError(
errors,
'version',
'version 僅可包含英數字、`.`、`_`、`-`'
);
}
// 4. platform
const platformRaw =
body && typeof body.platform === 'string' ? body.platform.trim() : '';
if (!ALLOWED_PLATFORMS.has(platformRaw)) {
pushError(
errors,
'platform',
`platform 必須為 ${[...ALLOWED_PLATFORMS].join(' / ')} 之一`
);
}
// 5. enable_* booleans —— 缺漏視為 falsedoc-review m7
function parseBoolean(field) {
const raw = body && body[field];
if (raw === undefined || raw === null || raw === '') return false;
if (raw === 'true') return true;
if (raw === 'false') return false;
pushError(errors, field, `${field} 必須為 'true' 或 'false'(字串)`);
return false;
}
const enableEvaluate = parseBoolean('enable_evaluate');
const enableSimFp = parseBoolean('enable_sim_fp');
const enableSimFixed = parseBoolean('enable_sim_fixed');
const enableSimHw = parseBoolean('enable_sim_hw');
// 6. metadata可選
let metadata = null;
const metadataRaw = body && body.metadata;
if (metadataRaw !== undefined && metadataRaw !== null && metadataRaw !== '') {
if (typeof metadataRaw !== 'string') {
pushError(errors, 'metadata', 'metadata 必須為合法 JSON 物件字串');
} else {
try {
const parsed = JSON.parse(metadataRaw);
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
pushError(errors, 'metadata', 'metadata 必須為合法 JSON 物件(非 array / 非 null');
} else {
metadata = parsed;
}
} catch (_) {
pushError(errors, 'metadata', 'metadata 必須為合法 JSON 物件字串');
}
}
}
// 7. model file必填
// multer 在 fields config 是 `{ name: 'model', maxCount: 1 }`,所以 req.files.model 是陣列
const modelArr = files && files.model;
const modelFile = Array.isArray(modelArr) && modelArr.length > 0 ? modelArr[0] : null;
let safeModelFilename = '';
let modelExt = '';
if (!modelFile) {
pushError(errors, 'model', 'model 檔案為必填');
} else {
safeModelFilename = sanitizeFilename(modelFile.originalname || 'model');
modelExt = getExtension(safeModelFilename);
if (!ALLOWED_MODEL_EXTENSIONS.has(modelExt)) {
pushError(
errors,
'model',
`不支援的模型副檔名(${modelExt || '無'}),僅接受 ${[...ALLOWED_MODEL_EXTENSIONS].join(' / ')}`
);
}
if (!modelFile.buffer || modelFile.buffer.length === 0) {
pushError(errors, 'model', 'model 檔案為空');
}
}
// 8. ref_images[] (optional)
// multer fields name 是 `ref_images[]`(與 server.js 既有對齊),但 multer 會
// 把括號吃掉,所以 req.files 的 key 也叫 `ref_images`。同時對齊 legacy.js L82。
const refImagesArr =
files && (files.ref_images || files['ref_images[]']);
const refImages = Array.isArray(refImagesArr) ? refImagesArr : [];
// Sec C2per-file size 檢查multer fileSize 用 model 上限 500MB但 ref_images
// 100 張 × 500MB = 50GB 單請求 OOM。用 per-file 10MB 阻擋T10env 可調整)。
// 任一張超標即視為 413 file_too_large語意對齊 TDD §14
let oversizedRefImage = null; // { index, size }
for (let idx = 0; idx < refImages.length; idx += 1) {
const file = refImages[idx];
const size =
file && file.buffer && typeof file.buffer.length === 'number'
? file.buffer.length
: 0;
if (size > refImageMaxBytes) {
oversizedRefImage = { index: idx, size };
break; // 第一張 oversized 即停(避免遍歷大量 files
}
}
const safeRefImages = refImages.map((file, idx) => ({
file,
safeFilename: sanitizeFilename(file.originalname || `image_${idx}.bin`),
}));
// 優先回 413 file_too_large語意比 400 validation_error 更精確)
if (oversizedRefImage) {
return {
ok: false,
errors,
tooLarge: {
field: `ref_images[${oversizedRefImage.index}]`,
size_bytes: oversizedRefImage.size,
limit_bytes: refImageMaxBytes,
},
};
}
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
errors: [],
data: {
userId,
parameters: {
model_id: modelIdInt,
version: versionRaw,
platform: platformRaw,
enable_evaluate: enableEvaluate,
enable_sim_fp: enableSimFp,
enable_sim_fixed: enableSimFixed,
enable_sim_hw: enableSimHw,
},
metadata,
input: {
file: modelFile,
safeFilename: safeModelFilename,
extension: modelExt,
},
refImages: safeRefImages,
},
};
}
module.exports = {
validateCreateJobRequest,
ALLOWED_MODEL_EXTENSIONS,
ALLOWED_PLATFORMS,
VERSION_WHITELIST,
MAX_REF_IMAGE_SIZE_BYTES, // 向後相容(既有測試 import
DEFAULT_MAX_REF_IMAGE_SIZE_BYTES,
};

View File

@ -0,0 +1,605 @@
/**
* Unit tests healthServiceT8
*
* 涵蓋範圍
* 1. snapshot 形狀service / status / version / timestamp / dependencies
* 2. Redis status 判定'ready' connected其他 disconnected
* 3. 整體狀態判定矩陣healthy / degraded / unhealthy
* 4. 第一次啟動 cache 未填 MC / FAA = 'pending'
* 5. background polling 寫入 cacherunOnce
* 6. probeHttp 行為200 / 404 / 5xx / network error / timeout / abort
* 7. start() 冪等
* 8. stop() 清掉 intervalabort in-flight fetch
* 9. polling 重疊保護inFlight 跳過
* 10. URL 缺漏 fallback unreachable
* 11. 錯誤訊息 / log 不洩漏 endpoint URL
*/
'use strict';
const {
createHealthService,
DEP_STATE,
OVERALL_STATE,
SERVICE_NAME,
SERVICE_VERSION,
_internals,
} = require('../healthService');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeFakeRedis(status = 'ready') {
return { status };
}
/**
* 建立可控 fetch mock
* - 預設兩個 url 都回 200
* - 個別 URL 可指定 response reject
*/
function makeFetchMock(handlers = {}) {
return jest.fn(async (url, opts) => {
const handler = handlers[url];
if (!handler) {
// 預設 200 OK
return { status: 200, ok: true };
}
if (handler instanceof Error) throw handler;
if (typeof handler === 'function') return handler(url, opts);
return handler;
});
}
// 抑制 healthService 內部 console.logjsdom 環境也適用)
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('createHealthService — basic contract', () => {
it('throws when deps is missing', () => {
expect(() => createHealthService()).toThrow(/deps is required/);
});
it('throws when deps.redis is missing', () => {
expect(() => createHealthService({})).toThrow(/deps\.redis is required/);
});
it('returns expected interface', () => {
const svc = createHealthService({ redis: makeFakeRedis() });
expect(typeof svc.start).toBe('function');
expect(typeof svc.stop).toBe('function');
expect(typeof svc.getHealth).toBe('function');
expect(typeof svc.isUnhealthy).toBe('function');
});
});
describe('getHealth — snapshot shape and constants', () => {
it('returns snapshot with correct top-level fields', () => {
const svc = createHealthService({ redis: makeFakeRedis() });
const snap = svc.getHealth();
expect(snap).toEqual(
expect.objectContaining({
service: SERVICE_NAME,
version: SERVICE_VERSION,
status: expect.any(String),
timestamp: expect.any(String),
dependencies: expect.objectContaining({
redis: expect.any(String),
member_center: expect.any(String),
file_access_agent: expect.any(String),
}),
})
);
// ISO 8601 timestamp粗略驗證
expect(snap.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
});
describe('classifyRedisStatus / Redis dependency', () => {
it("treats 'ready' as connected", () => {
expect(_internals.classifyRedisStatus({ status: 'ready' })).toBe(DEP_STATE.CONNECTED);
});
it.each([
'wait',
'connecting',
'connect',
'reconnecting',
'close',
'end',
undefined,
null,
'',
])('treats %p as disconnected', (status) => {
expect(_internals.classifyRedisStatus({ status })).toBe(DEP_STATE.DISCONNECTED);
});
it('treats null redis as disconnected', () => {
expect(_internals.classifyRedisStatus(null)).toBe(DEP_STATE.DISCONNECTED);
});
it('reflects redis disconnected in snapshot', () => {
const svc = createHealthService({ redis: makeFakeRedis('connecting') });
const snap = svc.getHealth();
expect(snap.dependencies.redis).toBe(DEP_STATE.DISCONNECTED);
expect(snap.status).toBe(OVERALL_STATE.UNHEALTHY);
expect(svc.isUnhealthy()).toBe(true);
});
});
describe('deriveOverallStatus — status matrix', () => {
const { deriveOverallStatus } = _internals;
it('healthy when all deps OK', () => {
expect(
deriveOverallStatus({
redis: DEP_STATE.CONNECTED,
memberCenter: DEP_STATE.REACHABLE,
fileAccessAgent: DEP_STATE.REACHABLE,
})
).toBe(OVERALL_STATE.HEALTHY);
});
it('unhealthy when redis disconnected (regardless of MC/FAA)', () => {
expect(
deriveOverallStatus({
redis: DEP_STATE.DISCONNECTED,
memberCenter: DEP_STATE.REACHABLE,
fileAccessAgent: DEP_STATE.REACHABLE,
})
).toBe(OVERALL_STATE.UNHEALTHY);
expect(
deriveOverallStatus({
redis: DEP_STATE.DISCONNECTED,
memberCenter: DEP_STATE.UNREACHABLE,
fileAccessAgent: DEP_STATE.UNREACHABLE,
})
).toBe(OVERALL_STATE.UNHEALTHY);
});
it('degraded when redis OK but MC unreachable', () => {
expect(
deriveOverallStatus({
redis: DEP_STATE.CONNECTED,
memberCenter: DEP_STATE.UNREACHABLE,
fileAccessAgent: DEP_STATE.REACHABLE,
})
).toBe(OVERALL_STATE.DEGRADED);
});
it('degraded when redis OK but FAA unreachable', () => {
expect(
deriveOverallStatus({
redis: DEP_STATE.CONNECTED,
memberCenter: DEP_STATE.REACHABLE,
fileAccessAgent: DEP_STATE.UNREACHABLE,
})
).toBe(OVERALL_STATE.DEGRADED);
});
it('degraded when MC/FAA pending (cache not warmed)', () => {
expect(
deriveOverallStatus({
redis: DEP_STATE.CONNECTED,
memberCenter: DEP_STATE.PENDING,
fileAccessAgent: DEP_STATE.PENDING,
})
).toBe(OVERALL_STATE.DEGRADED);
});
});
describe('initial cache state — pending before first poll', () => {
it('snapshots return pending before start()', () => {
const svc = createHealthService({ redis: makeFakeRedis() });
const snap = svc.getHealth();
expect(snap.dependencies.member_center).toBe(DEP_STATE.PENDING);
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.PENDING);
// redis 已 ready但其他兩個 pending → degraded仍 200 OK
expect(snap.status).toBe(OVERALL_STATE.DEGRADED);
expect(svc.isUnhealthy()).toBe(false);
});
});
describe('runOnce — background polling fills cache', () => {
it('writes both deps reachable when both 200', async () => {
const fetch = makeFetchMock({
'https://mc/.well-known/jwks': { status: 200, ok: true },
'https://faa.example/health': { status: 200, ok: true },
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/.well-known/jwks',
fileAccessAgentProbeUrl: 'https://faa.example/health',
fetch,
});
await svc._runOnce();
expect(fetch).toHaveBeenCalledTimes(2);
const snap = svc.getHealth();
expect(snap.dependencies.member_center).toBe(DEP_STATE.REACHABLE);
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.REACHABLE);
expect(snap.status).toBe(OVERALL_STATE.HEALTHY);
});
it('treats 4xx (e.g. 404) as reachable', async () => {
const fetch = makeFetchMock({
'https://mc/.well-known/jwks': { status: 200, ok: true },
'https://faa.example/health': { status: 404, ok: false }, // FAA 沒實作 /health
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/.well-known/jwks',
fileAccessAgentProbeUrl: 'https://faa.example/health',
fetch,
});
await svc._runOnce();
const snap = svc.getHealth();
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.REACHABLE);
expect(snap.status).toBe(OVERALL_STATE.HEALTHY);
});
it('marks 5xx as unreachable', async () => {
const fetch = makeFetchMock({
'https://mc/.well-known/jwks': { status: 503, ok: false },
'https://faa.example/health': { status: 200, ok: true },
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/.well-known/jwks',
fileAccessAgentProbeUrl: 'https://faa.example/health',
fetch,
});
await svc._runOnce();
const snap = svc.getHealth();
expect(snap.dependencies.member_center).toBe(DEP_STATE.UNREACHABLE);
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.REACHABLE);
expect(snap.status).toBe(OVERALL_STATE.DEGRADED);
});
it('marks network error as unreachable (one bad does not affect the other)', async () => {
const fetch = jest.fn(async (url) => {
if (url.includes('mc')) {
const err = new Error('ECONNREFUSED 1.2.3.4:80');
err.code = 'ECONNREFUSED';
throw err;
}
return { status: 200, ok: true };
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/.well-known/jwks',
fileAccessAgentProbeUrl: 'https://faa.example/health',
fetch,
});
await svc._runOnce();
const snap = svc.getHealth();
expect(snap.dependencies.member_center).toBe(DEP_STATE.UNREACHABLE);
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.REACHABLE);
});
it('treats fetch promise that never resolves as timeout (probeTimeoutMs honored)', async () => {
// fetch 回一個永遠不 resolve 的 promise但會 listen abort signal
const fetch = jest.fn((_url, opts) => {
return new Promise((_resolve, reject) => {
if (opts && opts.signal) {
opts.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}
});
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/.well-known/jwks',
fileAccessAgentProbeUrl: 'https://faa.example/health',
fetch,
probeTimeoutMs: 30, // 加快測試
});
await svc._runOnce();
const snap = svc.getHealth();
expect(snap.dependencies.member_center).toBe(DEP_STATE.UNREACHABLE);
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.UNREACHABLE);
});
it('falls back to unreachable when probe URL is missing in config', async () => {
const fetch = makeFetchMock();
const svc = createHealthService({
redis: makeFakeRedis(),
// 沒給任何 URLconfig / override 皆無)
fetch,
});
await svc._runOnce();
const snap = svc.getHealth();
expect(snap.dependencies.member_center).toBe(DEP_STATE.UNREACHABLE);
expect(snap.dependencies.file_access_agent).toBe(DEP_STATE.UNREACHABLE);
// 真的沒 URL → 應該完全沒 fetch
expect(fetch).not.toHaveBeenCalled();
});
it('uses config.memberCenter.jwksUrl and config.fileAccessAgent.baseUrl/health automatically', async () => {
const fetch = makeFetchMock();
const svc = createHealthService({
redis: makeFakeRedis(),
config: {
memberCenter: { jwksUrl: 'https://auth/.well-known/jwks' },
fileAccessAgent: { baseUrl: 'https://faa.internal/' }, // trailing slash 應被 trim
},
fetch,
});
await svc._runOnce();
const calledUrls = fetch.mock.calls.map((c) => c[0]).sort();
expect(calledUrls).toEqual(
['https://auth/.well-known/jwks', 'https://faa.internal/health'].sort()
);
});
});
describe('inFlight protection — slow probe does not double-fire', () => {
it('skips runOnce if previous still running', async () => {
// 兩階段 fetch mock前兩次第一波回掛起的 promise之後立即 resolve 200
const pending = [];
let callCount = 0;
const fetch = jest.fn((_url, opts) => {
callCount += 1;
if (callCount <= 2) {
return new Promise((resolve, reject) => {
pending.push(resolve);
if (opts && opts.signal) {
opts.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}
});
}
return Promise.resolve({ status: 200, ok: true });
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/jwks',
fileAccessAgentProbeUrl: 'https://faa/health',
fetch,
probeTimeoutMs: 50_000, // 不被 timer 提早 abort
});
// 第一次 runOnce → 兩個 fetch 都掛起
const first = svc._runOnce();
// microtask flush確保 Promise.all 已 schedule 起兩個 fetch
await Promise.resolve();
await Promise.resolve();
expect(fetch).toHaveBeenCalledTimes(2);
// 第二次同時呼叫 → 應該立即 returninFlight 為 true不發新 fetch
await svc._runOnce();
expect(fetch).toHaveBeenCalledTimes(2);
// 解開第一波的兩個 fetch讓第一次 runOnce 完成
pending.forEach((r) => r({ status: 200, ok: true }));
pending.length = 0;
await first;
// 再次 runOnce → 第二波(已 resolve 200→ 新的兩個 fetch
await svc._runOnce();
expect(fetch).toHaveBeenCalledTimes(4);
});
});
describe('start / stop lifecycle', () => {
it('start() is idempotent (no double interval)', () => {
let intervalCount = 0;
const fakeSetInterval = jest.fn(() => {
intervalCount += 1;
return { unref: jest.fn() };
});
const fakeClearInterval = jest.fn();
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/jwks',
fileAccessAgentProbeUrl: 'https://faa/health',
fetch: makeFetchMock(),
setIntervalFn: fakeSetInterval,
clearIntervalFn: fakeClearInterval,
});
svc.start();
svc.start(); // second call should be noop
svc.start();
expect(intervalCount).toBe(1);
});
it('stop() clears interval and aborts in-flight fetch', async () => {
let abortCount = 0;
const fetch = jest.fn((_url, opts) => {
return new Promise((_res, reject) => {
if (opts && opts.signal) {
opts.signal.addEventListener('abort', () => {
abortCount += 1;
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}
});
});
const fakeSetInterval = jest.fn(() => ({ unref: jest.fn() }));
const fakeClearInterval = jest.fn();
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: 'https://mc/jwks',
fileAccessAgentProbeUrl: 'https://faa/health',
fetch,
setIntervalFn: fakeSetInterval,
clearIntervalFn: fakeClearInterval,
probeTimeoutMs: 5000,
});
svc.start();
// 給 microtask 一個 tick 觸發 initial poll
await Promise.resolve();
await Promise.resolve();
svc.stop();
expect(fakeClearInterval).toHaveBeenCalled();
// 等 in-flight promise rejection settle
await Promise.resolve();
await Promise.resolve();
expect(abortCount).toBeGreaterThanOrEqual(1);
});
it('stop() before start() is a noop', () => {
const svc = createHealthService({ redis: makeFakeRedis() });
expect(() => svc.stop()).not.toThrow();
});
});
describe('probeHttp — direct unit', () => {
const { probeHttp } = _internals;
it('returns reachable for 200', async () => {
const fetchImpl = jest.fn(async () => ({ status: 200, ok: true }));
const result = await probeHttp('https://x', {
fetchImpl,
timeoutMs: 100,
setTimeoutFn: globalThis.setTimeout,
clearTimeoutFn: globalThis.clearTimeout,
});
expect(result).toBe(DEP_STATE.REACHABLE);
});
it('returns reachable for 404 (service alive, route missing)', async () => {
const fetchImpl = jest.fn(async () => ({ status: 404, ok: false }));
const result = await probeHttp('https://x', {
fetchImpl,
timeoutMs: 100,
setTimeoutFn: globalThis.setTimeout,
clearTimeoutFn: globalThis.clearTimeout,
});
expect(result).toBe(DEP_STATE.REACHABLE);
});
it('returns reachable for 401 (auth needed but service alive)', async () => {
const fetchImpl = jest.fn(async () => ({ status: 401, ok: false }));
const result = await probeHttp('https://x', {
fetchImpl,
timeoutMs: 100,
setTimeoutFn: globalThis.setTimeout,
clearTimeoutFn: globalThis.clearTimeout,
});
expect(result).toBe(DEP_STATE.REACHABLE);
});
it('returns unreachable for 500', async () => {
const fetchImpl = jest.fn(async () => ({ status: 500, ok: false }));
const result = await probeHttp('https://x', {
fetchImpl,
timeoutMs: 100,
setTimeoutFn: globalThis.setTimeout,
clearTimeoutFn: globalThis.clearTimeout,
});
expect(result).toBe(DEP_STATE.UNREACHABLE);
});
it('returns unreachable for thrown network error', async () => {
const fetchImpl = jest.fn(async () => {
const err = new Error('ECONNREFUSED');
err.code = 'ECONNREFUSED';
throw err;
});
const result = await probeHttp('https://x', {
fetchImpl,
timeoutMs: 100,
setTimeoutFn: globalThis.setTimeout,
clearTimeoutFn: globalThis.clearTimeout,
});
expect(result).toBe(DEP_STATE.UNREACHABLE);
});
it('returns unreachable when master signal already aborted', async () => {
const ac = new AbortController();
ac.abort();
const fetchImpl = jest.fn();
const result = await probeHttp('https://x', {
fetchImpl,
timeoutMs: 100,
setTimeoutFn: globalThis.setTimeout,
clearTimeoutFn: globalThis.clearTimeout,
signal: ac.signal,
});
expect(result).toBe(DEP_STATE.UNREACHABLE);
expect(fetchImpl).not.toHaveBeenCalled();
});
});
describe('security — no sensitive data leakage', () => {
it('logs and snapshot do not contain probe URLs', async () => {
const logCalls = [];
const origLog = console.log;
const origWarn = console.warn;
const origError = console.error;
console.log = (msg) => logCalls.push(msg);
console.warn = (msg) => logCalls.push(msg);
console.error = (msg) => logCalls.push(msg);
try {
const SECRET_MC_URL = 'https://internal-secret-mc.example/.well-known/jwks';
const SECRET_FAA_URL = 'https://nas-internal-files.example/health';
const fetch = makeFetchMock({
[SECRET_MC_URL]: { status: 503, ok: false },
[SECRET_FAA_URL]: new Error('Connection refused to nas-internal-files.example:9999'),
});
const svc = createHealthService({
redis: makeFakeRedis(),
memberCenterProbeUrl: SECRET_MC_URL,
fileAccessAgentProbeUrl: SECRET_FAA_URL,
fetch,
});
await svc._runOnce();
const snap = svc.getHealth();
const allLogs = logCalls.join('\n');
// 1) snapshot 不應含 URL
const snapStr = JSON.stringify(snap);
expect(snapStr).not.toContain('internal-secret-mc');
expect(snapStr).not.toContain('nas-internal-files');
expect(snapStr).not.toContain('9999');
// 2) log預期格式為 structured JSON不含 URL
expect(allLogs).not.toContain('internal-secret-mc');
expect(allLogs).not.toContain('nas-internal-files');
expect(allLogs).not.toContain('9999');
} finally {
console.log = origLog;
console.warn = origWarn;
console.error = origError;
}
});
});

View File

@ -0,0 +1,418 @@
/**
* jobService T5 介面單元測試T5
*
* 重點
* 1. writeInputToMinIO 上傳 model + ref_images 並回 object_keys
* 2. writeInputToMinIO minio.client null throw
* 3. writeInputToMinIO model 寫失敗 throw
* 4. claimActiveAndCreate 成功時呼叫 sseService
* 5. claimActiveAndCreate 衝突時不呼叫 sseService
* 6. cleanupInputObjects fire-and-forgetfail throw
* 7. getActiveJob 整合
*/
'use strict';
const { createJobService } = require('../jobService');
// Mock luaScripts to control claim/release outcome without spinning up real Redis Lua
jest.mock('../../redis/luaScripts', () => ({
claimActiveJob: jest.fn(),
releaseActiveJob: jest.fn(),
_internals: {
loadScript: jest.fn(),
evalScript: jest.fn(),
resetCache: jest.fn(),
},
}));
const { claimActiveJob, releaseActiveJob } = require('../../redis/luaScripts');
function makeFakeRedis() {
const store = new Map();
return {
store,
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
xadd: jest.fn(async () => '1-0'),
};
}
function makeFakeSseService() {
return { sendSSE: jest.fn() };
}
function makeFakeMinio({ uploadFails = false, deleteFails = false } = {}) {
const uploaded = [];
const deleted = [];
return {
client: { _fake: true },
bucket: 'test-bucket',
endpoint: 'http://nope',
uploadToMinIO: jest.fn(async (key, body, contentType) => {
if (uploadFails) throw new Error('storage down');
uploaded.push({ key, contentType, size: body.length });
}),
getFromMinIO: jest.fn(async () => null),
deleteObject: jest.fn(async (key) => {
if (deleteFails) throw new Error('delete failed');
deleted.push(key);
}),
_uploaded: uploaded,
_deleted: deleted,
};
}
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
claimActiveJob.mockReset();
releaseActiveJob.mockReset();
});
describe('jobService.writeInputToMinIO', () => {
it('uploads model + ref_images and returns object keys', async () => {
const minio = makeFakeMinio();
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio,
});
const modelFile = {
buffer: Buffer.from('model-bytes'),
mimetype: 'application/octet-stream',
};
const refImages = [
{ file: { buffer: Buffer.from('img1'), mimetype: 'image/jpeg' }, safeFilename: 'a.jpg' },
{ file: { buffer: Buffer.from('img2'), mimetype: 'image/png' }, safeFilename: 'b.png' },
];
const result = await svc.writeInputToMinIO(
'job-123',
modelFile,
'model.onnx',
refImages
);
expect(result.inputObjectKey).toBe('jobs/job-123/input/model.onnx');
expect(result.refImageObjectKeys).toEqual([
'jobs/job-123/ref_images/0_a.jpg',
'jobs/job-123/ref_images/1_b.png',
]);
expect(result.uploadedKeys).toHaveLength(3);
expect(minio.uploadToMinIO).toHaveBeenCalledTimes(3);
});
it('uploads with no ref_images', async () => {
const minio = makeFakeMinio();
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio,
});
const result = await svc.writeInputToMinIO(
'j',
{ buffer: Buffer.from('x') },
'm.onnx',
[]
);
expect(result.uploadedKeys).toHaveLength(1);
});
it('throws when minio dep missing', async () => {
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
});
await expect(
svc.writeInputToMinIO('j', { buffer: Buffer.from('x') }, 'm.onnx', [])
).rejects.toThrow(/minio/);
});
it('throws when minio.client null', async () => {
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: { client: null, uploadToMinIO: jest.fn() },
});
await expect(
svc.writeInputToMinIO('j', { buffer: Buffer.from('x') }, 'm.onnx', [])
).rejects.toThrow(/STORAGE_BACKEND/);
});
it('propagates upload errors', async () => {
const minio = makeFakeMinio({ uploadFails: true });
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio,
});
await expect(
svc.writeInputToMinIO('j', { buffer: Buffer.from('x') }, 'm.onnx', [])
).rejects.toThrow(/storage down/);
});
});
describe('jobService.claimActiveAndCreate', () => {
it('triggers sendSSE on success', async () => {
claimActiveJob.mockResolvedValueOnce({ ok: true });
const sse = makeFakeSseService();
const svc = createJobService({
redis: makeFakeRedis(),
sseService: sse,
minio: makeFakeMinio(),
});
const jobRecord = { job_id: 'j-1', status: 'ONNX' };
const result = await svc.claimActiveAndCreate({
userId: 'u',
jobId: 'j-1',
jobRecord,
ttlSeconds: 100,
});
expect(result).toEqual({ ok: true });
expect(sse.sendSSE).toHaveBeenCalledWith('j-1', jobRecord);
});
it('does NOT trigger sendSSE on conflict', async () => {
claimActiveJob.mockResolvedValueOnce({
ok: false,
conflict: true,
activeJobId: 'old-id',
});
const sse = makeFakeSseService();
const svc = createJobService({
redis: makeFakeRedis(),
sseService: sse,
minio: makeFakeMinio(),
});
const result = await svc.claimActiveAndCreate({
userId: 'u',
jobId: 'new-id',
jobRecord: { job_id: 'new-id' },
ttlSeconds: 100,
});
expect(result.conflict).toBe(true);
expect(result.activeJobId).toBe('old-id');
expect(sse.sendSSE).not.toHaveBeenCalled();
});
it('serializes jobRecord to JSON for Lua', async () => {
claimActiveJob.mockResolvedValueOnce({ ok: true });
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
const jobRecord = { job_id: 'j-1', extra: { nested: 1 } };
await svc.claimActiveAndCreate({
userId: 'u',
jobId: 'j-1',
jobRecord,
ttlSeconds: 100,
});
const args = claimActiveJob.mock.calls[0][1];
expect(args.jobJson).toBe(JSON.stringify(jobRecord));
expect(args.userId).toBe('u');
expect(args.jobId).toBe('j-1');
expect(args.ttlSeconds).toBe(100);
});
});
describe('jobService.getActiveJob', () => {
it('returns null both when no active job', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
const res = await svc.getActiveJob('u');
expect(res).toEqual({ activeJobId: null, job: null });
});
it('returns job when active job exists', async () => {
const redis = makeFakeRedis();
redis.store.set('user:u:active_job', 'j-1');
redis.store.set('job:j-1', JSON.stringify({ job_id: 'j-1', stage: 'bie' }));
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
const res = await svc.getActiveJob('u');
expect(res.activeJobId).toBe('j-1');
expect(res.job).toEqual({ job_id: 'j-1', stage: 'bie' });
});
it('returns activeJobId but null job if Redis stale', async () => {
const redis = makeFakeRedis();
redis.store.set('user:u:active_job', 'j-orphan');
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
const res = await svc.getActiveJob('u');
expect(res.activeJobId).toBe('j-orphan');
expect(res.job).toBeNull();
});
});
describe('jobService.cleanupInputObjects', () => {
it('does nothing for empty array', async () => {
const minio = makeFakeMinio();
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio,
});
await svc.cleanupInputObjects([]);
expect(minio.deleteObject).not.toHaveBeenCalled();
});
it('calls deleteObject for each key', async () => {
const minio = makeFakeMinio();
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio,
});
await svc.cleanupInputObjects(['k1', 'k2', 'k3']);
expect(minio.deleteObject).toHaveBeenCalledTimes(3);
expect(minio._deleted).toEqual(['k1', 'k2', 'k3']);
});
it('does not throw when deleteObject fails (fire-and-forget)', async () => {
const minio = makeFakeMinio({ deleteFails: true });
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio,
});
// 不該 throw
await expect(svc.cleanupInputObjects(['k1', 'k2'])).resolves.toBeUndefined();
});
it('skips when minio missing', async () => {
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
});
await expect(svc.cleanupInputObjects(['k1'])).resolves.toBeUndefined();
});
});
// Sec M4getActiveJobIdpre-check 用,純 GET 不讀 record
describe('jobService.getActiveJobId (Sec M4 pre-check)', () => {
it('returns active job id when set', async () => {
const redis = makeFakeRedis();
redis.store.set('user:u:active_job', 'j-1');
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
expect(await svc.getActiveJobId('u')).toBe('j-1');
});
it('returns null when no active job', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
expect(await svc.getActiveJobId('u')).toBeNull();
});
it('only reads active_job key (does NOT read job:{} record)', async () => {
const redis = makeFakeRedis();
redis.store.set('user:u:active_job', 'j-1');
redis.store.set('job:j-1', JSON.stringify({ a: 1 }));
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
await svc.getActiveJobId('u');
// 應該只 GET 一次(不讀 job record
expect(redis.get).toHaveBeenCalledTimes(1);
expect(redis.get).toHaveBeenCalledWith('user:u:active_job');
});
});
// Sec M2 + Reviewer Major-2releaseActiveJob補償釋放
describe('jobService.releaseActiveJob (Sec M2)', () => {
it('calls Lua releaseActiveJob with correct args', async () => {
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
const result = await svc.releaseActiveJob('alice', 'job-xyz');
expect(result).toEqual({ released: true });
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
const args = releaseActiveJob.mock.calls[0][1];
expect(args.userId).toBe('alice');
expect(args.jobId).toBe('job-xyz');
});
it('returns released=false on NOOP (active_job mismatch)', async () => {
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: false });
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
const result = await svc.releaseActiveJob('u', 'orphan');
expect(result).toEqual({ released: false });
});
it('propagates Lua errors', async () => {
releaseActiveJob.mockRejectedValueOnce(new Error('Redis down'));
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
await expect(svc.releaseActiveJob('u', 'j')).rejects.toThrow(/Redis down/);
});
});
describe('jobService._internals (object key naming)', () => {
it('buildInputObjectKey aligns with TDD §6.1', () => {
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
expect(svc._internals.buildInputObjectKey('j-1', 'model.onnx')).toBe(
'jobs/j-1/input/model.onnx'
);
});
it('buildRefImageObjectKey prefixes index to avoid name collisions', () => {
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
minio: makeFakeMinio(),
});
expect(svc._internals.buildRefImageObjectKey('j-1', 0, 'a.jpg')).toBe(
'jobs/j-1/ref_images/0_a.jpg'
);
expect(svc._internals.buildRefImageObjectKey('j-1', 1, 'a.jpg')).toBe(
'jobs/j-1/ref_images/1_a.jpg'
);
});
});

View File

@ -0,0 +1,427 @@
/**
* jobService T6 介面單元測試listJobsByUser + computeEtag
*
* 範圍
* - listJobsByUserSMEMBERS + pipeline GET + client filter + status filter +
* sort + 分頁
* - computeEtag updated_at ETag不同 updated_at 不同 ETag
* - 邊界user jobrecord 損壞cross-client 隔離
*/
'use strict';
// 阻斷實際 Lua script load不需要打 Redis
jest.mock('../../redis/luaScripts', () => ({
claimActiveJob: jest.fn(),
releaseActiveJob: jest.fn(),
_internals: {
loadScript: jest.fn(),
evalScript: jest.fn(),
resetCache: jest.fn(),
},
}));
const { createJobService } = require('../jobService');
// ---------------------------------------------------------------------------
// Fake Redis with SMEMBERS + pipeline support
// ---------------------------------------------------------------------------
function makeFakeRedis() {
const store = new Map();
const sets = new Map(); // key -> Set<string>
function pipeline() {
const ops = [];
const p = {
get(key) {
ops.push({ kind: 'get', key });
return p;
},
// 我們的 pipeline 沒用到別的 op但保留 fluent
async exec() {
return ops.map((op) => {
if (op.kind === 'get') {
const val = store.has(op.key) ? store.get(op.key) : null;
return [null, val];
}
return [new Error('unsupported op'), null];
});
},
};
return p;
}
return {
store,
sets,
pipeline: jest.fn(pipeline),
smembers: jest.fn(async (key) => {
const s = sets.get(key);
return s ? [...s] : [];
}),
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
sadd: jest.fn(async (key, member) => {
if (!sets.has(key)) sets.set(key, new Set());
sets.get(key).add(member);
return 1;
}),
};
}
const sseService = { sendSSE: () => {} };
function makeJob(overrides = {}) {
return {
job_id: overrides.job_id || 'jid-default',
user_id: 'u1',
created_by_client_id: 'cid-A',
status: 'ONNX',
stage: 'onnx',
progress: 0,
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
expires_at: '2026-05-02T12:00:00Z',
stage_timings: { onnx: null, bie: null, nef: null },
...overrides,
};
}
beforeAll(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// listJobsByUser
// ---------------------------------------------------------------------------
describe('listJobsByUser', () => {
let redis;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
svc = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
});
it('returns empty when user has no jobs', async () => {
const result = await svc.listJobsByUser({
userId: 'u-empty',
clientId: 'cid-A',
});
expect(result).toEqual({ jobs: [], total: 0, nextOffset: null });
expect(redis.smembers).toHaveBeenCalledWith('user:u-empty:jobs');
});
it('throws when userId missing', async () => {
await expect(
svc.listJobsByUser({ clientId: 'cid-A' })
).rejects.toThrow(/userId/);
});
it('throws when clientId missing', async () => {
await expect(
svc.listJobsByUser({ userId: 'u1' })
).rejects.toThrow(/clientId/);
});
it('returns jobs for user, filtering by client_id (security)', async () => {
redis.sets.set('user:u1:jobs', new Set(['j1', 'j2', 'j3']));
redis.store.set(
'job:j1',
JSON.stringify(makeJob({ job_id: 'j1', created_by_client_id: 'cid-A' }))
);
redis.store.set(
'job:j2',
JSON.stringify(makeJob({ job_id: 'j2', created_by_client_id: 'cid-B' }))
);
redis.store.set(
'job:j3',
JSON.stringify(makeJob({ job_id: 'j3', created_by_client_id: 'cid-A' }))
);
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
});
// 只有 j1 / j3 屬於 cid-A
expect(result.total).toBe(2);
expect(result.jobs.map((j) => j.job_id).sort()).toEqual(['j1', 'j3']);
});
it('filters by status=in_progress (created + running)', async () => {
redis.sets.set('user:u1:jobs', new Set(['created-j', 'running-j', 'completed-j', 'failed-j']));
// created
redis.store.set(
'job:created-j',
JSON.stringify(
makeJob({ job_id: 'created-j', status: 'ONNX', stage_timings: { onnx: null } })
)
);
// running (BIE)
redis.store.set(
'job:running-j',
JSON.stringify(makeJob({ job_id: 'running-j', status: 'BIE', stage: 'bie' }))
);
// completed
redis.store.set(
'job:completed-j',
JSON.stringify(makeJob({ job_id: 'completed-j', status: 'COMPLETED', stage: null }))
);
// failed
redis.store.set(
'job:failed-j',
JSON.stringify(makeJob({ job_id: 'failed-j', status: 'FAILED', error: { stage: 'bie' } }))
);
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'in_progress',
});
expect(result.total).toBe(2);
expect(result.jobs.map((j) => j.job_id).sort()).toEqual(['created-j', 'running-j']);
});
it('filters by status=completed', async () => {
redis.sets.set('user:u1:jobs', new Set(['j1', 'j2']));
redis.store.set(
'job:j1',
JSON.stringify(makeJob({ job_id: 'j1', status: 'COMPLETED' }))
);
redis.store.set(
'job:j2',
JSON.stringify(makeJob({ job_id: 'j2', status: 'BIE' }))
);
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'completed',
});
expect(result.total).toBe(1);
expect(result.jobs[0].job_id).toBe('j1');
});
it('filters by status=failed', async () => {
redis.sets.set('user:u1:jobs', new Set(['j1', 'j2']));
redis.store.set(
'job:j1',
JSON.stringify(makeJob({ job_id: 'j1', status: 'FAILED', error: { stage: 'bie' } }))
);
redis.store.set(
'job:j2',
JSON.stringify(makeJob({ job_id: 'j2', status: 'COMPLETED' }))
);
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'failed',
});
expect(result.total).toBe(1);
expect(result.jobs[0].job_id).toBe('j1');
});
it('returns all jobs when status=all', async () => {
redis.sets.set('user:u1:jobs', new Set(['j1', 'j2', 'j3']));
redis.store.set(
'job:j1',
JSON.stringify(makeJob({ job_id: 'j1', status: 'COMPLETED' }))
);
redis.store.set(
'job:j2',
JSON.stringify(makeJob({ job_id: 'j2', status: 'BIE' }))
);
redis.store.set(
'job:j3',
JSON.stringify(makeJob({ job_id: 'j3', status: 'FAILED' }))
);
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
});
expect(result.total).toBe(3);
});
it('sorts by created_at descending (newest first)', async () => {
redis.sets.set('user:u1:jobs', new Set(['old', 'mid', 'new']));
redis.store.set(
'job:old',
JSON.stringify(makeJob({ job_id: 'old', created_at: '2026-04-25T10:00:00Z' }))
);
redis.store.set(
'job:mid',
JSON.stringify(makeJob({ job_id: 'mid', created_at: '2026-04-25T11:00:00Z' }))
);
redis.store.set(
'job:new',
JSON.stringify(makeJob({ job_id: 'new', created_at: '2026-04-25T12:00:00Z' }))
);
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
});
expect(result.jobs.map((j) => j.job_id)).toEqual(['new', 'mid', 'old']);
});
it('paginates with limit and offset', async () => {
redis.sets.set('user:u1:jobs', new Set(['j1', 'j2', 'j3', 'j4', 'j5']));
for (let i = 1; i <= 5; i += 1) {
redis.store.set(
`job:j${i}`,
JSON.stringify(
makeJob({
job_id: `j${i}`,
// 排序後descj5 / j4 / j3 / j2 / j1
created_at: `2026-04-25T${10 + i}:00:00Z`,
})
)
);
}
const page1 = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
limit: 2,
offset: 0,
});
expect(page1.total).toBe(5);
expect(page1.jobs.map((j) => j.job_id)).toEqual(['j5', 'j4']);
expect(page1.nextOffset).toBe(2);
const page2 = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
limit: 2,
offset: 2,
});
expect(page2.jobs.map((j) => j.job_id)).toEqual(['j3', 'j2']);
expect(page2.nextOffset).toBe(4);
const page3 = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
limit: 2,
offset: 4,
});
expect(page3.jobs.map((j) => j.job_id)).toEqual(['j1']);
expect(page3.nextOffset).toBeNull();
});
it('caps limit at 50', async () => {
redis.sets.set('user:u1:jobs', new Set(['j1']));
redis.store.set('job:j1', JSON.stringify(makeJob({ job_id: 'j1' })));
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
limit: 100, // 超過 50
});
// 不會 failmax 50這裡只有 1 個 job
expect(result.total).toBe(1);
});
it('handles missing job records (race: SMEMBER 有但 GET 沒)', async () => {
redis.sets.set('user:u1:jobs', new Set(['ghost', 'real']));
// ghost 沒對應 job:ghost
redis.store.set('job:real', JSON.stringify(makeJob({ job_id: 'real' })));
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
});
expect(result.total).toBe(1);
expect(result.jobs[0].job_id).toBe('real');
});
it('handles corrupt JSON gracefully (logs + skips)', async () => {
redis.sets.set('user:u1:jobs', new Set(['bad', 'good']));
redis.store.set('job:bad', '{not valid json}');
redis.store.set('job:good', JSON.stringify(makeJob({ job_id: 'good' })));
const result = await svc.listJobsByUser({
userId: 'u1',
clientId: 'cid-A',
status: 'all',
});
expect(result.total).toBe(1);
expect(result.jobs[0].job_id).toBe('good');
});
});
// ---------------------------------------------------------------------------
// computeEtag
// ---------------------------------------------------------------------------
describe('computeEtag', () => {
let svc;
beforeEach(() => {
const redis = makeFakeRedis();
svc = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
});
it('returns weak ETag in W/"..." format', () => {
const etag = svc.computeEtag({ updated_at: '2026-04-25T12:00:00Z' });
expect(etag).toMatch(/^W\/"[A-Za-z0-9_-]+"$/);
});
it('produces stable ETag for same updated_at', () => {
const e1 = svc.computeEtag({ updated_at: '2026-04-25T12:00:00Z' });
const e2 = svc.computeEtag({ updated_at: '2026-04-25T12:00:00Z' });
expect(e1).toBe(e2);
});
it('produces different ETag for different updated_at', () => {
const e1 = svc.computeEtag({ updated_at: '2026-04-25T12:00:00Z' });
const e2 = svc.computeEtag({ updated_at: '2026-04-25T12:00:01Z' });
expect(e1).not.toBe(e2);
});
it('handles missing updated_at gracefully', () => {
const etag = svc.computeEtag({});
// 空 updated_at 仍應回有效 ETaghash of empty string
expect(etag).toMatch(/^W\/"[A-Za-z0-9_-]+"$/);
});
it('handles null/undefined input', () => {
expect(svc.computeEtag(null)).toMatch(/^W\/"[A-Za-z0-9_-]+"$/);
expect(svc.computeEtag(undefined)).toMatch(/^W\/"[A-Za-z0-9_-]+"$/);
});
it('hash portion does not include `+` `/` `=` (base64url)', () => {
// 試多次以增加碰到 + / = 字元的機會
// 注意W/"..." 的 W/ 是 RFC 7232 weak ETag 標示,是合法字元
// 我們只檢查引號內的 hash 部分
const ETAG_RE = /^W\/"([^"]+)"$/;
for (let i = 0; i < 10; i += 1) {
const etag = svc.computeEtag({ updated_at: `iter-${i}-${Math.random()}` });
const match = etag.match(ETAG_RE);
expect(match).not.toBeNull();
const hash = match[1];
expect(hash).not.toContain('+');
expect(hash).not.toContain('/');
expect(hash).not.toContain('=');
}
});
});

View File

@ -0,0 +1,198 @@
/**
* jobService T7 介面單元測試markPromoted
*
* 範圍
* - markPromoted寫入 promoted: true / promoted_at / promoted_object_keys
* - markPromotedjob 不存在 null throw
* - markPromotedinput validationjobId / args 必填
* - markPromoted自動更新 updated_at透過 setJob
* - markPromoted透過 SSE 廣播透過 setJob
*/
'use strict';
// 阻斷實際 Lua script load
jest.mock('../../redis/luaScripts', () => ({
claimActiveJob: jest.fn(),
releaseActiveJob: jest.fn(),
_internals: {
loadScript: jest.fn(),
evalScript: jest.fn(),
resetCache: jest.fn(),
},
}));
const { createJobService } = require('../jobService');
function makeFakeRedis() {
const store = new Map();
return {
store,
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
};
}
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
describe('markPromoted', () => {
let redis;
let sseSent;
let sseService;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sseSent = [];
sseService = {
sendSSE: jest.fn((jobId, payload) => {
sseSent.push({ jobId, payload });
}),
};
svc = createJobService({ redis, sseService, jobDataDir: '/tmp/x' });
});
it('returns null when job does not exist', async () => {
const result = await svc.markPromoted('nonexistent-job', {
promotedAt: '2026-04-25T13:00:00Z',
promotedKeys: [],
});
expect(result).toBeNull();
// 沒寫 set
expect(redis.set).not.toHaveBeenCalled();
});
it('writes promoted flags to job record + auto updated_at', async () => {
const baseJob = {
job_id: 'j1',
status: 'COMPLETED',
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:30:00Z',
};
redis.store.set('job:j1', JSON.stringify(baseJob));
const promotedAt = '2026-04-25T14:00:00Z';
const promotedKeys = [
{
source: 'nef',
target_object_key: 'visionA/u1/m1/v1/out.nef',
size_bytes: 1234,
file_access_agent_etag: 'etag',
promoted_at: promotedAt,
},
];
const updated = await svc.markPromoted('j1', { promotedAt, promotedKeys });
expect(updated).not.toBeNull();
expect(updated.promoted).toBe(true);
expect(updated.promoted_at).toBe(promotedAt);
expect(updated.promoted_object_keys).toEqual(promotedKeys);
// updated_at 已被 setJob 自動更新(不再等於原本的 12:30:00
expect(updated.updated_at).not.toBe('2026-04-25T12:30:00Z');
expect(typeof updated.updated_at).toBe('string');
// 已寫回 Redis
const stored = JSON.parse(redis.store.get('job:j1'));
expect(stored.promoted).toBe(true);
expect(stored.promoted_at).toBe(promotedAt);
// SSE 已廣播
expect(sseService.sendSSE).toHaveBeenCalledWith('j1', expect.any(Object));
});
it('preserves other fields (status / output / parameters / error)', async () => {
const baseJob = {
job_id: 'j2',
status: 'COMPLETED',
stage: null,
progress: 100,
output: { nef_path: 'jobs/j2/output/out.nef' },
parameters: { model_id: 1001 },
error: null,
};
redis.store.set('job:j2', JSON.stringify(baseJob));
await svc.markPromoted('j2', {
promotedAt: '2026-04-25T14:00:00Z',
promotedKeys: [
{
source: 'nef',
target_object_key: 'a/b.nef',
size_bytes: 1,
file_access_agent_etag: 'e',
promoted_at: '2026-04-25T14:00:00Z',
},
],
});
const stored = JSON.parse(redis.store.get('job:j2'));
expect(stored.status).toBe('COMPLETED'); // 不該改
expect(stored.stage).toBeNull();
expect(stored.progress).toBe(100);
expect(stored.output).toEqual({ nef_path: 'jobs/j2/output/out.nef' });
expect(stored.parameters).toEqual({ model_id: 1001 });
expect(stored.error).toBeNull();
});
it('overwrites existing promoted_object_keys atomically (re-promote)', async () => {
// 模擬 job 已有舊 promoted record雖然 promote handler 走冪等不會走到,
// 但 jobService 介面層仍需支援被多次呼叫的安全性)
const baseJob = {
job_id: 'j3',
status: 'COMPLETED',
promoted: true,
promoted_at: '2026-04-25T12:00:00Z',
promoted_object_keys: [
{ source: 'nef', target_object_key: 'old/path.nef' },
],
};
redis.store.set('job:j3', JSON.stringify(baseJob));
const newPromotedKeys = [
{ source: 'nef', target_object_key: 'new/path.nef' },
{ source: 'bie', target_object_key: 'new/path.bie' },
];
await svc.markPromoted('j3', {
promotedAt: '2026-04-25T15:00:00Z',
promotedKeys: newPromotedKeys,
});
const stored = JSON.parse(redis.store.get('job:j3'));
expect(stored.promoted_at).toBe('2026-04-25T15:00:00Z');
expect(stored.promoted_object_keys).toEqual(newPromotedKeys);
expect(stored.promoted_object_keys).toHaveLength(2);
});
it('throws when jobId missing or empty', async () => {
await expect(
svc.markPromoted('', { promotedAt: 'x', promotedKeys: [] })
).rejects.toThrow(/jobId/);
await expect(
svc.markPromoted(null, { promotedAt: 'x', promotedKeys: [] })
).rejects.toThrow(/jobId/);
});
it('throws when args missing or wrong shape', async () => {
await expect(svc.markPromoted('j1', null)).rejects.toThrow(/args/);
await expect(svc.markPromoted('j1', undefined)).rejects.toThrow(/args/);
await expect(svc.markPromoted('j1', {})).rejects.toThrow(/promotedAt/);
await expect(
svc.markPromoted('j1', { promotedAt: 'x' })
).rejects.toThrow(/promotedKeys/);
await expect(
svc.markPromoted('j1', { promotedAt: 'x', promotedKeys: 'not-array' })
).rejects.toThrow(/promotedKeys/);
});
});

View File

@ -0,0 +1,402 @@
/**
* T9 整合測試完整生命週期 + release_active_job 釋放確認
*
* 範圍
* 1. 完整 e2e job onnx done bie done nef done completed
* active_job DEL透過 release Lua 觸發
* 2. failed active_job 也被 DEL
* 3. race condition user job 完成後下一個 job 立刻能建立
*
* jobService.t9.test.js 的差異
* - t9.test.js unit test advanceJob / failJob 行為單獨驗證
* - 本檔是 integration模擬完整 worker done event 流程驗證
* stage_timings + release 在三階段切換中都正確
*
* Mock 策略
* - fake Redisin-memory Map 模擬 GET/SET
* - jest.mock('luaScripts') release / claim 都用 stateful mock
* 模擬真實 Lua 行為不只 mock 回應值
*
* 為什麼不打真 Redis
* - Phase 1 測試金字塔integration mock 也算 integrationcovers 多個
* module 的協作e2e Redis 留給 Testing Agent E2E 測試
* - CI 不依賴 Redis container跑得更快
*/
'use strict';
// 用 stateful mock 模擬 Lua 行為
jest.mock('../../redis/luaScripts', () => {
// module-level state每個測試 reset
const state = {
activeJobMap: new Map(), // userId → jobId模擬 user:{u}:active_job
jobMap: new Map(), // jobId → jobJson模擬 job:{id}
userJobsMap: new Map(), // userId → Set<jobId>(模擬 user:{u}:jobs
};
return {
claimActiveJob: jest.fn(async (_redis, { userId, jobId, jobJson }) => {
const existing = state.activeJobMap.get(userId);
if (existing) {
return { ok: false, conflict: true, activeJobId: existing };
}
state.activeJobMap.set(userId, jobId);
state.jobMap.set(jobId, jobJson);
if (!state.userJobsMap.has(userId)) state.userJobsMap.set(userId, new Set());
state.userJobsMap.get(userId).add(jobId);
return { ok: true };
}),
releaseActiveJob: jest.fn(async (_redis, { userId, jobId }) => {
const current = state.activeJobMap.get(userId);
if (current !== jobId) {
// Lua atomic guardactive_job 不等於 ARGV[1] → NOOP
return { ok: true, released: false };
}
state.activeJobMap.delete(userId);
state.jobMap.delete(jobId);
const set = state.userJobsMap.get(userId);
if (set) set.delete(jobId);
return { ok: true, released: true };
}),
_internals: {
loadScript: jest.fn(),
evalScript: jest.fn(),
resetCache: jest.fn(),
_state: state, // 暴露給測試 reset
},
};
});
const { claimActiveJob, releaseActiveJob, _internals } = require('../../redis/luaScripts');
const { createJobService } = require('../jobService');
function makeFakeRedis() {
const store = new Map();
const xaddCalls = [];
return {
store,
xaddCalls,
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
xadd: jest.fn(async (queue, _id, _field, value) => {
xaddCalls.push([queue, value]);
return '1-0';
}),
};
}
function makeFakeSseService() {
return { sendSSE: jest.fn() };
}
beforeAll(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
// 清空 stateful mock state
_internals._state.activeJobMap.clear();
_internals._state.jobMap.clear();
_internals._state.userJobsMap.clear();
claimActiveJob.mockClear();
releaseActiveJob.mockClear();
});
// ---------------------------------------------------------------------------
// 完整 e2e 流程
// ---------------------------------------------------------------------------
describe('T9 e2e — 完整生命週期 onnx → bie → nef → COMPLETED', () => {
it('progresses through all stages with stage_timings + releases active_job', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
jobDataDir: '/data/jobs',
});
// === 建 job ===
// 模擬 v1 POST /api/v1/jobsclaimActiveAndCreate 寫入完整 record含 onnx.started_at
const userId = 'alice';
const jobId = 'job-e2e-001';
const initialJob = {
job_id: jobId,
user_id: userId,
created_by_client_id: 'visionA',
created_at: '2026-04-25T10:00:00Z',
status: 'ONNX',
stage: 'onnx',
progress: 0,
stage_timings: {
// 對齊 v1 routes/jobs.js 的初始化
onnx: { started_at: '2026-04-25T10:00:00Z', completed_at: null },
bie: { started_at: null, completed_at: null },
nef: { started_at: null, completed_at: null },
},
output: { bie_path: null, nef_path: null },
error: null,
};
// 用 jobService 寫入(同時放入 mock Lua state
await svc.claimActiveAndCreate({
userId,
jobId,
jobRecord: initialJob,
ttlSeconds: 604800,
});
// 寫入 redis store 給 advance 流程讀
redis.store.set(`job:${jobId}`, JSON.stringify(initialJob));
// === 階段 1onnx 完成 ===
await svc.advanceJob(jobId, 'onnx');
let stored = JSON.parse(redis.store.get(`job:${jobId}`));
expect(stored.status).toBe('BIE');
expect(stored.stage).toBe('bie');
expect(stored.progress).toBe(33);
expect(stored.stage_timings.onnx.completed_at).not.toBeNull();
expect(stored.stage_timings.bie.started_at).not.toBeNull();
expect(stored.stage_timings.bie.completed_at).toBeNull();
expect(stored.stage_timings.nef.started_at).toBeNull();
// 中間階段不 release
expect(releaseActiveJob).not.toHaveBeenCalled();
// active_job 仍指向當前
expect(_internals._state.activeJobMap.get(userId)).toBe(jobId);
// === 階段 2bie 完成 ===
await svc.advanceJob(jobId, 'bie');
stored = JSON.parse(redis.store.get(`job:${jobId}`));
expect(stored.status).toBe('NEF');
expect(stored.stage).toBe('nef');
expect(stored.progress).toBe(67);
expect(stored.stage_timings.bie.completed_at).not.toBeNull();
expect(stored.stage_timings.nef.started_at).not.toBeNull();
expect(releaseActiveJob).not.toHaveBeenCalled();
expect(_internals._state.activeJobMap.get(userId)).toBe(jobId);
// === 階段 3nef 完成 → COMPLETED ===
await svc.advanceJob(jobId, 'nef');
stored = JSON.parse(redis.store.get(`job:${jobId}`));
expect(stored.status).toBe('COMPLETED');
expect(stored.stage).toBeNull();
expect(stored.progress).toBe(100);
expect(stored.stage_timings.nef.completed_at).not.toBeNull();
// ★ 終態釋放 active_job
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
expect(releaseActiveJob.mock.calls[0][1]).toEqual({
userId: 'alice',
jobId,
});
// active_job 已被 DEL
expect(_internals._state.activeJobMap.has(userId)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// FAILED 終態
// ---------------------------------------------------------------------------
describe('T9 e2e — FAILED 終態 release_active_job', () => {
it('releases active_job when worker reports failure', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
});
const userId = 'bob';
const jobId = 'job-fail-001';
const initialJob = {
job_id: jobId,
user_id: userId,
status: 'BIE',
stage: 'bie',
progress: 33,
stage_timings: {
onnx: { started_at: '2026-04-25T10:00:00Z', completed_at: '2026-04-25T10:05:00Z' },
bie: { started_at: '2026-04-25T10:05:00Z', completed_at: null },
nef: { started_at: null, completed_at: null },
},
};
await svc.claimActiveAndCreate({
userId,
jobId,
jobRecord: initialJob,
ttlSeconds: 604800,
});
redis.store.set(`job:${jobId}`, JSON.stringify(initialJob));
await svc.failJob(jobId, 'bie', 'quantization error');
const stored = JSON.parse(redis.store.get(`job:${jobId}`));
expect(stored.status).toBe('FAILED');
expect(stored.error).toEqual({ step: 'bie', reason: 'quantization error' });
expect(stored.stage_timings.bie.completed_at).not.toBeNull();
// ★ active_job 已被 DEL
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
expect(_internals._state.activeJobMap.has(userId)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Race scenariouser 完成 job 後立刻可建新 job
// ---------------------------------------------------------------------------
describe('T9 e2e — user 完成 job 後可立即建新 job (active_job 已釋放)', () => {
it('allows user to claim new job immediately after previous job completes', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
});
const userId = 'charlie';
const firstJobId = 'job-001';
const secondJobId = 'job-002';
// === 建第一個 job ===
await svc.claimActiveAndCreate({
userId,
jobId: firstJobId,
jobRecord: { job_id: firstJobId, user_id: userId, status: 'NEF', stage: 'nef' },
ttlSeconds: 604800,
});
// 模擬 NEF 完成的 record有完整 stage_timings 走過所有階段)
redis.store.set(
`job:${firstJobId}`,
JSON.stringify({
job_id: firstJobId,
user_id: userId,
status: 'NEF',
stage: 'nef',
progress: 67,
stage_timings: {
onnx: { started_at: 'tA', completed_at: 'tA' },
bie: { started_at: 'tB', completed_at: 'tB' },
nef: { started_at: 'tC', completed_at: null },
},
})
);
// === 第一個 job 完成 ===
await svc.advanceJob(firstJobId, 'nef');
expect(_internals._state.activeJobMap.has(userId)).toBe(false);
// === 第二個 job 可立即 claim ===
const claimResult = await svc.claimActiveAndCreate({
userId,
jobId: secondJobId,
jobRecord: { job_id: secondJobId, user_id: userId, status: 'ONNX', stage: 'onnx' },
ttlSeconds: 604800,
});
expect(claimResult.ok).toBe(true);
expect(_internals._state.activeJobMap.get(userId)).toBe(secondJobId);
});
it('blocks user with active_job (race window: complete then re-claim before release runs)', async () => {
// 這個 case 模擬「在 NEF 完成的 race window假設別人搶到了 active_job」
// 預期 Lua 的 atomic guard 會 NOOP不誤刪別人的鎖
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
});
const userId = 'dave';
const oldJobId = 'job-old';
const newJobId = 'job-new'; // 另一隻手「搶」進去的 job
// 起始active_job 是 oldJobId
await svc.claimActiveAndCreate({
userId,
jobId: oldJobId,
jobRecord: { job_id: oldJobId, user_id: userId, status: 'NEF', stage: 'nef' },
ttlSeconds: 604800,
});
redis.store.set(
`job:${oldJobId}`,
JSON.stringify({
job_id: oldJobId,
user_id: userId,
status: 'NEF',
stage: 'nef',
})
);
// 模擬:別的 process 已經把 active_job 改寫為 newJobIdrace
_internals._state.activeJobMap.set(userId, newJobId);
// oldJobId 完成 → 嘗試 release但 Lua atomic guard 會發現 active_job 不等於 oldJobId
await svc.advanceJob(oldJobId, 'nef');
// active_job 仍是 newJobId未被誤刪
expect(_internals._state.activeJobMap.get(userId)).toBe(newJobId);
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
// released=falseNOOP
const result = await releaseActiveJob.mock.results[0].value;
expect(result.released).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Legacy backward compatlegacy job 不影響 release沒對應 active_job key
// ---------------------------------------------------------------------------
describe('T9 backward compat — legacy job (no user_id) 終態時不嘗試 release', () => {
it('legacy COMPLETED does not invoke release Lua', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
});
// legacy job沒有 user_idserver.js POST /jobs 建的)
redis.store.set(
'job:legacy-1',
JSON.stringify({
job_id: 'legacy-1',
// user_id 缺
status: 'NEF',
stage: 'nef',
progress: 67,
})
);
await svc.advanceJob('legacy-1', 'nef');
expect(releaseActiveJob).not.toHaveBeenCalled();
const stored = JSON.parse(redis.store.get('job:legacy-1'));
expect(stored.status).toBe('COMPLETED');
// stage_timings 結構仍正確初始化
expect(stored.stage_timings.nef.completed_at).not.toBeNull();
});
it('legacy FAILED does not invoke release Lua', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
});
redis.store.set(
'job:legacy-2',
JSON.stringify({
job_id: 'legacy-2',
// 無 user_id
status: 'BIE',
stage: 'bie',
})
);
await svc.failJob('legacy-2', 'bie', 'oom');
expect(releaseActiveJob).not.toHaveBeenCalled();
const stored = JSON.parse(redis.store.get('job:legacy-2'));
expect(stored.status).toBe('FAILED');
});
});

View File

@ -0,0 +1,620 @@
/**
* jobService T9 介面單元測試 stage_timings + 終態 release_active_job
*
* 範圍
* 1. advanceJob 寫入 stage_timings.{completedStage}.completed_at
* 2. advanceJob 推進到下一階段時寫 stage_timings.{nextStage}.started_at
* 3. advanceJob 達到 COMPLETED 時呼叫 release_active_job若有 user_id
* 4. failJob 寫入 stage_timings.{step}.completed_at
* 5. failJob 呼叫 release_active_job若有 user_id
* 6. legacy job user_id終態時不呼叫 release避免無效 NOOP
* 7. release Lua 失敗不阻塞 advance / failfire-and-forget + log
* 8. stage_timings 結構初始化 / fallback stage_timings 時也能寫
* 9. 失敗時其他 stage 仍維持 null只標 fail stage 已結束
* 10. ISO 8601 時間格式正確
*/
'use strict';
// Mock luaScripts控制 release 結果而不啟動真 Redis Lua
jest.mock('../../redis/luaScripts', () => ({
claimActiveJob: jest.fn(),
releaseActiveJob: jest.fn(),
_internals: {
loadScript: jest.fn(),
evalScript: jest.fn(),
resetCache: jest.fn(),
},
}));
const { releaseActiveJob } = require('../../redis/luaScripts');
const { createJobService } = require('../jobService');
function makeFakeRedis() {
const store = new Map();
const xaddCalls = [];
return {
store,
xaddCalls,
get: jest.fn(async (key) => (store.has(key) ? store.get(key) : null)),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
xadd: jest.fn(async (queue, _id, _field, value) => {
xaddCalls.push([queue, value]);
return '1-0';
}),
};
}
function makeFakeSseService() {
return { sendSSE: jest.fn() };
}
beforeAll(() => {
// 抑制 console 雜訊jobService.releaseActiveJobOnTerminal 會 log
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
releaseActiveJob.mockReset();
});
// ---------------------------------------------------------------------------
// advanceJob — stage_timings 寫入
// ---------------------------------------------------------------------------
describe('jobService.advanceJob — stage_timings (T9)', () => {
let redis;
let sse;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sse = makeFakeSseService();
svc = createJobService({ redis, sseService: sse, jobDataDir: '/data/jobs' });
});
it('writes stage_timings.onnx.completed_at + bie.started_at when advancing onnx → bie', async () => {
// v1 場景:建 job 已寫過 onnx.started_at由 createJobHandler 寫入)
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'ONNX',
stage: 'onnx',
progress: 0,
created_at: '2026-04-25T12:00:00Z',
stage_timings: {
onnx: { started_at: '2026-04-25T12:00:00Z', completed_at: null },
bie: { started_at: null, completed_at: null },
nef: { started_at: null, completed_at: null },
},
})
);
const t0 = Date.now() - 1;
await svc.advanceJob('j', 'onnx');
const t1 = Date.now() + 1;
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('BIE');
expect(stored.stage).toBe('bie');
expect(stored.progress).toBe(33);
// onnx.completed_at 在 [t0, t1] 區間
const onnxCompleted = stored.stage_timings.onnx.completed_at;
expect(typeof onnxCompleted).toBe('string');
expect(new Date(onnxCompleted).getTime()).toBeGreaterThanOrEqual(t0);
expect(new Date(onnxCompleted).getTime()).toBeLessThanOrEqual(t1);
// onnx.started_at 保留(沒被覆寫)
expect(stored.stage_timings.onnx.started_at).toBe('2026-04-25T12:00:00Z');
// bie.started_at 在 [t0, t1]
const bieStarted = stored.stage_timings.bie.started_at;
expect(typeof bieStarted).toBe('string');
expect(new Date(bieStarted).getTime()).toBeGreaterThanOrEqual(t0);
expect(new Date(bieStarted).getTime()).toBeLessThanOrEqual(t1);
expect(stored.stage_timings.bie.completed_at).toBeNull();
// nef 仍未開工
expect(stored.stage_timings.nef.started_at).toBeNull();
expect(stored.stage_timings.nef.completed_at).toBeNull();
});
it('writes stage_timings.bie.completed_at + nef.started_at when advancing bie → nef', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'BIE',
stage: 'bie',
progress: 33,
created_at: 'tA',
stage_timings: {
onnx: { started_at: '2026-04-25T12:00:00Z', completed_at: '2026-04-25T12:05:00Z' },
bie: { started_at: '2026-04-25T12:05:00Z', completed_at: null },
nef: { started_at: null, completed_at: null },
},
})
);
await svc.advanceJob('j', 'bie');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('NEF');
expect(stored.stage).toBe('nef');
expect(stored.progress).toBe(67);
expect(stored.stage_timings.bie.completed_at).not.toBeNull();
expect(stored.stage_timings.nef.started_at).not.toBeNull();
expect(stored.stage_timings.nef.completed_at).toBeNull();
});
it('writes stage_timings.nef.completed_at when reaching COMPLETED', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'NEF',
stage: 'nef',
progress: 67,
created_at: 'tA',
stage_timings: {
onnx: { started_at: 'tA', completed_at: 'tA' },
bie: { started_at: 'tA', completed_at: 'tA' },
nef: { started_at: 'tA', completed_at: null },
},
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
await svc.advanceJob('j', 'nef');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('COMPLETED');
expect(stored.stage).toBeNull();
expect(stored.progress).toBe(100);
expect(stored.stage_timings.nef.completed_at).not.toBeNull();
// nef.completed_at 是有效的 ISO 8601
expect(stored.stage_timings.nef.completed_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('initializes stage_timings struct when missing (legacy job)', async () => {
// legacy job 沒寫 stage_timings 欄位
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
// user_id 缺漏legacy
status: 'ONNX',
stage: 'onnx',
progress: 0,
created_at: 'tA',
})
);
await svc.advanceJob('j', 'onnx');
const stored = JSON.parse(redis.store.get('job:j'));
// stage_timings 結構被初始化
expect(stored.stage_timings).toBeDefined();
expect(stored.stage_timings.onnx.completed_at).not.toBeNull();
expect(stored.stage_timings.bie.started_at).not.toBeNull();
expect(stored.stage_timings.nef.started_at).toBeNull();
expect(stored.stage_timings.nef.completed_at).toBeNull();
});
});
// ---------------------------------------------------------------------------
// failJob — stage_timings 寫入
// ---------------------------------------------------------------------------
describe('jobService.failJob — stage_timings (T9)', () => {
let redis;
let sse;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sse = makeFakeSseService();
svc = createJobService({ redis, sseService: sse });
});
it('writes stage_timings.{step}.completed_at on failure', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'BIE',
stage: 'bie',
stage_timings: {
onnx: { started_at: 'tA', completed_at: 'tA' },
bie: { started_at: 'tB', completed_at: null },
nef: { started_at: null, completed_at: null },
},
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
const t0 = Date.now() - 1;
await svc.failJob('j', 'bie', 'quantization timeout');
const t1 = Date.now() + 1;
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('FAILED');
expect(stored.error).toEqual({ step: 'bie', reason: 'quantization timeout' });
// bie.completed_at 在 [t0, t1] 區間
const bieCompleted = stored.stage_timings.bie.completed_at;
expect(typeof bieCompleted).toBe('string');
expect(new Date(bieCompleted).getTime()).toBeGreaterThanOrEqual(t0);
expect(new Date(bieCompleted).getTime()).toBeLessThanOrEqual(t1);
});
it('keeps other stages null on failure (only marks failed stage as ended)', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'BIE',
stage: 'bie',
stage_timings: {
onnx: { started_at: 'tA', completed_at: 'tA' },
bie: { started_at: 'tB', completed_at: null },
nef: { started_at: null, completed_at: null },
},
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
await svc.failJob('j', 'bie', 'reason');
const stored = JSON.parse(redis.store.get('job:j'));
// 其他 stage 維持 null不一次填補
expect(stored.stage_timings.nef.started_at).toBeNull();
expect(stored.stage_timings.nef.completed_at).toBeNull();
// onnx 維持原樣
expect(stored.stage_timings.onnx.started_at).toBe('tA');
expect(stored.stage_timings.onnx.completed_at).toBe('tA');
});
it('does NOT touch stage_timings if step is unknown', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'BIE',
stage: 'bie',
stage_timings: {
onnx: { started_at: 'tA', completed_at: null },
bie: null,
nef: null,
},
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
await svc.failJob('j', 'unknown-step', 'reason');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('FAILED');
// unknown step 不寫入任何 stage_timingsonnx 應維持原樣
expect(stored.stage_timings.onnx.started_at).toBe('tA');
expect(stored.stage_timings.onnx.completed_at).toBeNull();
});
});
// ---------------------------------------------------------------------------
// release_active_job 觸發點COMPLETED / FAILED + user_id 處理)
// ---------------------------------------------------------------------------
describe('jobService.advanceJob — release_active_job on COMPLETED (T9)', () => {
let redis;
let sse;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sse = makeFakeSseService();
svc = createJobService({ redis, sseService: sse });
});
function setupNefJob(overrides = {}) {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'NEF',
stage: 'nef',
progress: 67,
stage_timings: {
onnx: { started_at: 'tA', completed_at: 'tA' },
bie: { started_at: 'tB', completed_at: 'tB' },
nef: { started_at: 'tC', completed_at: null },
},
...overrides,
})
);
}
it('calls release_active_job with userId + jobId when v1 job completes', async () => {
setupNefJob({ user_id: 'alice' });
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
await svc.advanceJob('j', 'nef');
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
const args = releaseActiveJob.mock.calls[0][1];
expect(args.userId).toBe('alice');
expect(args.jobId).toBe('j');
});
it('does NOT call release for legacy job (no user_id)', async () => {
setupNefJob({ user_id: null });
await svc.advanceJob('j', 'nef');
expect(releaseActiveJob).not.toHaveBeenCalled();
// 但 job 仍正常被標 COMPLETED
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('COMPLETED');
});
it('does NOT call release when user_id is empty string (web-anonymous fallback)', async () => {
setupNefJob({ user_id: '' });
await svc.advanceJob('j', 'nef');
expect(releaseActiveJob).not.toHaveBeenCalled();
});
it('does NOT call release when user_id is missing (undefined)', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
// user_id 完全缺漏
status: 'NEF',
stage: 'nef',
progress: 67,
stage_timings: {
onnx: { started_at: 'tA', completed_at: 'tA' },
bie: { started_at: 'tB', completed_at: 'tB' },
nef: { started_at: 'tC', completed_at: null },
},
})
);
await svc.advanceJob('j', 'nef');
expect(releaseActiveJob).not.toHaveBeenCalled();
});
it('does NOT call release on intermediate stage advancement (onnx → bie)', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u-1',
status: 'ONNX',
stage: 'onnx',
progress: 0,
stage_timings: {
onnx: { started_at: 'tA', completed_at: null },
bie: { started_at: null, completed_at: null },
nef: { started_at: null, completed_at: null },
},
})
);
await svc.advanceJob('j', 'onnx');
expect(releaseActiveJob).not.toHaveBeenCalled();
});
it('does NOT throw when release Lua throws (fire-and-forget)', async () => {
setupNefJob({ user_id: 'alice' });
releaseActiveJob.mockRejectedValueOnce(new Error('Redis down'));
// advance 不應 throw即便 release 失敗
await expect(svc.advanceJob('j', 'nef')).resolves.toBeUndefined();
// job 仍應已標 COMPLETEDadvance 邏輯先於 release
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('COMPLETED');
});
it('does not throw when release returns NOOP (atomic guard hit)', async () => {
setupNefJob({ user_id: 'alice' });
// active_job 已被別人改寫Lua atomic guard 回 NOOP
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: false });
await expect(svc.advanceJob('j', 'nef')).resolves.toBeUndefined();
// 仍呼叫 release讓 Lua 自己決定 NOOP但不 throw
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
});
});
describe('jobService.failJob — release_active_job on FAILED (T9)', () => {
let redis;
let sse;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sse = makeFakeSseService();
svc = createJobService({ redis, sseService: sse });
});
it('calls release_active_job when v1 job fails', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'bob',
status: 'BIE',
stage: 'bie',
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
await svc.failJob('j', 'bie', 'oom');
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
const args = releaseActiveJob.mock.calls[0][1];
expect(args.userId).toBe('bob');
expect(args.jobId).toBe('j');
});
it('does NOT call release for legacy job on failure', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
// 無 user_id
status: 'BIE',
stage: 'bie',
})
);
await svc.failJob('j', 'bie', 'oom');
expect(releaseActiveJob).not.toHaveBeenCalled();
});
it('does NOT throw when release Lua throws on failure', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'bob',
status: 'BIE',
stage: 'bie',
})
);
releaseActiveJob.mockRejectedValueOnce(new Error('Redis down'));
await expect(svc.failJob('j', 'bie', 'reason')).resolves.toBeUndefined();
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('FAILED');
});
});
// ---------------------------------------------------------------------------
// Race scenarioreleaseActiveJob 的 atomic guard 行為
// ---------------------------------------------------------------------------
describe('release_active_job atomic guard scenario (T9 + Lua)', () => {
// 為什麼這個測試重要:
// T9 完成 / 失敗時呼叫 release_active_job.lua該 Lua 內部會 GET → 比對 →
// DEL如果 active_job 還是當前 jobId。本 spec 驗證 jobService 把正確的
// userId / jobId 傳給 Lua讓 Lua 自己做 atomic 判斷。
// 實際 atomic 行為由 luaScripts.test.js 的 release_active_job.lua sanity
// check 驗證;此測試聚焦 jobService 的 wiring。
let redis;
let sse;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sse = makeFakeSseService();
svc = createJobService({ redis, sseService: sse });
});
it('passes the completing job_id to release Lua (not some other id)', async () => {
redis.store.set(
'job:j-A',
JSON.stringify({
job_id: 'j-A',
user_id: 'shared-user',
status: 'NEF',
stage: 'nef',
progress: 67,
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: true });
await svc.advanceJob('j-A', 'nef');
expect(releaseActiveJob).toHaveBeenCalledTimes(1);
const args = releaseActiveJob.mock.calls[0][1];
expect(args.userId).toBe('shared-user');
// 關鍵:傳的是「正在完成的 job_id」Lua 才能判斷 active_job 是否仍指向自己
expect(args.jobId).toBe('j-A');
});
it('handles released=false (Lua NOOP) without confusion', async () => {
// 模擬j-A 完成的瞬間user 已搶到下一個 job j-B 並寫入 active_job
// → Lua GET active_job 回 'j-B' ≠ 'j-A' → NOOP
redis.store.set(
'job:j-A',
JSON.stringify({
job_id: 'j-A',
user_id: 'shared-user',
status: 'NEF',
stage: 'nef',
})
);
releaseActiveJob.mockResolvedValueOnce({ ok: true, released: false });
await svc.advanceJob('j-A', 'nef');
// jobService 不該 throw 也不該嘗試 retryLua 的 NOOP 是正確的「保護其他 holder」
const stored = JSON.parse(redis.store.get('job:j-A'));
expect(stored.status).toBe('COMPLETED');
});
});
// ---------------------------------------------------------------------------
// stage_timings ISO 8601 格式驗證
// ---------------------------------------------------------------------------
describe('stage_timings ISO 8601 format (T9)', () => {
it('written timestamps are valid ISO 8601 strings', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
});
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
user_id: 'u',
status: 'ONNX',
stage: 'onnx',
progress: 0,
stage_timings: {
onnx: { started_at: '2026-04-25T12:00:00.000Z', completed_at: null },
bie: { started_at: null, completed_at: null },
nef: { started_at: null, completed_at: null },
},
})
);
await svc.advanceJob('j', 'onnx');
const stored = JSON.parse(redis.store.get('job:j'));
// onnx.completed_at 與 bie.started_at 必為 ISO 8601 (含 milli + Z)
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
expect(stored.stage_timings.onnx.completed_at).toMatch(ISO_8601_RE);
expect(stored.stage_timings.bie.started_at).toMatch(ISO_8601_RE);
// 而且能成功 parse
expect(Number.isFinite(new Date(stored.stage_timings.onnx.completed_at).getTime())).toBe(true);
expect(Number.isFinite(new Date(stored.stage_timings.bie.started_at).getTime())).toBe(true);
});
});

View File

@ -0,0 +1,324 @@
/**
* jobService 單元測試T4
*
* 重點
* 1. getJob / setJob 正確讀寫 Redis
* 2. setJob 自動更新 updated_at + 觸發 sseService.sendSSE
* 3. enqueueStage message JSON 寫入正確 stream
* 4. advanceJob 的階段轉移 / 進度計算 / 完成判定
* 5. failJob FAILED + error 物件
*
* 採依賴注入jest fn mock redis + sseService不需真 Redis
*/
'use strict';
const path = require('path');
const {
createJobService,
STAGES,
STAGE_QUEUES,
DONE_QUEUE,
DONE_GROUP,
} = require('../jobService');
/** 建立一個 in-memory 假 Redis client。 */
function makeFakeRedis() {
const store = new Map();
/** @type {Array<[string, string]>} 記錄 xadd 呼叫:[queue, message] */
const xaddCalls = [];
return {
store,
xaddCalls,
get: jest.fn(async (key) => {
return store.has(key) ? store.get(key) : null;
}),
set: jest.fn(async (key, value) => {
store.set(key, value);
return 'OK';
}),
xadd: jest.fn(async (queue, _id, _field, value) => {
xaddCalls.push([queue, value]);
return '1-0';
}),
};
}
function makeFakeSseService() {
return {
sendSSE: jest.fn(),
};
}
beforeAll(() => {
// 抑制 console.log 雜訊jobService 對齊 server.js 會 log
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
describe('jobService — exported constants', () => {
it('STAGES order matches legacy server.js', () => {
expect(STAGES).toEqual(['onnx', 'bie', 'nef']);
});
it('STAGE_QUEUES uses queue:<stage> keys', () => {
expect(STAGE_QUEUES).toEqual({
onnx: 'queue:onnx',
bie: 'queue:bie',
nef: 'queue:nef',
});
});
it('DONE_QUEUE / DONE_GROUP match legacy', () => {
expect(DONE_QUEUE).toBe('queue:done');
expect(DONE_GROUP).toBe('scheduler');
});
});
describe('jobService factory — argument validation', () => {
it('throws if redis is missing', () => {
expect(() => createJobService({ sseService: makeFakeSseService() })).toThrow(/redis/i);
});
it('throws if sseService is missing', () => {
expect(() => createJobService({ redis: makeFakeRedis() })).toThrow(/sseService/i);
});
it('throws if sseService.sendSSE is not a function', () => {
expect(() =>
createJobService({ redis: makeFakeRedis(), sseService: {} })
).toThrow(/sendSSE/);
});
});
describe('jobService.getJob / setJob', () => {
it('returns null when key does not exist', async () => {
const redis = makeFakeRedis();
const svc = createJobService({ redis, sseService: makeFakeSseService() });
const result = await svc.getJob('missing');
expect(result).toBeNull();
expect(redis.get).toHaveBeenCalledWith('job:missing');
});
it('parses stored JSON', async () => {
const redis = makeFakeRedis();
redis.store.set('job:abc', JSON.stringify({ job_id: 'abc', status: 'ONNX' }));
const svc = createJobService({ redis, sseService: makeFakeSseService() });
const result = await svc.getJob('abc');
expect(result).toEqual({ job_id: 'abc', status: 'ONNX' });
});
it('setJob updates updated_at, writes JSON, and triggers sendSSE', async () => {
const redis = makeFakeRedis();
const sse = makeFakeSseService();
const svc = createJobService({ redis, sseService: sse });
const before = Date.now() - 1;
const job = { job_id: 'abc', status: 'ONNX' };
await svc.setJob('abc', job);
const after = Date.now() + 1;
// updated_at 已被自動寫入
expect(typeof job.updated_at).toBe('string');
const updatedAtMs = new Date(job.updated_at).getTime();
expect(updatedAtMs).toBeGreaterThanOrEqual(before);
expect(updatedAtMs).toBeLessThanOrEqual(after);
// 寫入正確 key + 內容
expect(redis.set).toHaveBeenCalledWith('job:abc', JSON.stringify(job));
expect(redis.store.get('job:abc')).toBe(JSON.stringify(job));
// 通知 SSE
expect(sse.sendSSE).toHaveBeenCalledWith('abc', job);
});
});
describe('jobService.enqueueStage', () => {
it('writes message to correct stream with input_dir derived from JOB_DATA_DIR', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
jobDataDir: '/tmp/jobs-test',
});
const job = {
job_id: 'job-1',
created_at: '2026-04-25T00:00:00Z',
parameters: { model_id: 1001 },
};
await svc.enqueueStage('onnx', job);
expect(redis.xadd).toHaveBeenCalledTimes(1);
const [queue, , , value] = redis.xadd.mock.calls[0];
expect(queue).toBe(STAGE_QUEUES.onnx);
const message = JSON.parse(value);
expect(message).toEqual({
job_id: 'job-1',
created_at: '2026-04-25T00:00:00Z',
input_dir: path.join('/tmp/jobs-test', 'job-1'),
parameters: { model_id: 1001 },
});
});
it('throws when stage is unknown', async () => {
const svc = createJobService({
redis: makeFakeRedis(),
sseService: makeFakeSseService(),
});
await expect(svc.enqueueStage('xxx', { job_id: 'a' })).rejects.toThrow(/Unknown stage/);
});
it('falls back to empty parameters object', async () => {
const redis = makeFakeRedis();
const svc = createJobService({
redis,
sseService: makeFakeSseService(),
jobDataDir: '/data/jobs',
});
const job = { job_id: 'job-2', created_at: 't' };
await svc.enqueueStage('bie', job);
const [, , , value] = redis.xadd.mock.calls[0];
expect(JSON.parse(value).parameters).toEqual({});
});
});
describe('jobService.advanceJob', () => {
let redis;
let sse;
let svc;
beforeEach(() => {
redis = makeFakeRedis();
sse = makeFakeSseService();
svc = createJobService({ redis, sseService: sse, jobDataDir: '/data/jobs' });
});
it('does nothing when job is missing', async () => {
await svc.advanceJob('missing', 'onnx');
expect(redis.set).not.toHaveBeenCalled();
expect(redis.xadd).not.toHaveBeenCalled();
});
it('does nothing when stage is unknown', async () => {
redis.store.set(
'job:abc',
JSON.stringify({ job_id: 'abc', status: 'ONNX', stage: 'onnx' })
);
await svc.advanceJob('abc', 'INVALID');
expect(redis.set).not.toHaveBeenCalled();
});
it('advances onnx → bie with progress=33 and enqueues to queue:bie', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
status: 'ONNX',
stage: 'onnx',
progress: 0,
created_at: 'tA',
})
);
await svc.advanceJob('j', 'onnx');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('BIE');
expect(stored.stage).toBe('bie');
expect(stored.progress).toBe(33); // round(1/3 * 100)
expect(redis.xadd).toHaveBeenCalledTimes(1);
expect(redis.xadd.mock.calls[0][0]).toBe(STAGE_QUEUES.bie);
});
it('advances bie → nef with progress=67 (matches legacy rounding)', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
status: 'BIE',
stage: 'bie',
progress: 33,
created_at: 'tA',
})
);
await svc.advanceJob('j', 'bie');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('NEF');
expect(stored.stage).toBe('nef');
expect(stored.progress).toBe(67);
expect(redis.xadd.mock.calls[0][0]).toBe(STAGE_QUEUES.nef);
});
it('on completing nef, sets COMPLETED + stage=null + progress=100, no enqueue', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
status: 'NEF',
stage: 'nef',
progress: 67,
created_at: 'tA',
})
);
await svc.advanceJob('j', 'nef');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('COMPLETED');
expect(stored.stage).toBeNull();
expect(stored.progress).toBe(100);
// 沒有再 enqueue
expect(redis.xadd).not.toHaveBeenCalled();
});
it('triggers SSE on each transition', async () => {
redis.store.set(
'job:j',
JSON.stringify({
job_id: 'j',
status: 'ONNX',
stage: 'onnx',
progress: 0,
})
);
await svc.advanceJob('j', 'onnx');
expect(sse.sendSSE).toHaveBeenCalledTimes(1);
expect(sse.sendSSE.mock.calls[0][0]).toBe('j');
});
});
describe('jobService.failJob', () => {
it('does nothing when job is missing', async () => {
const redis = makeFakeRedis();
const svc = createJobService({ redis, sseService: makeFakeSseService() });
await svc.failJob('missing', 'onnx', 'oom');
expect(redis.set).not.toHaveBeenCalled();
});
it('sets FAILED and error object', async () => {
const redis = makeFakeRedis();
const sse = makeFakeSseService();
redis.store.set(
'job:j',
JSON.stringify({ job_id: 'j', status: 'BIE', error: null })
);
const svc = createJobService({ redis, sseService: sse });
await svc.failJob('j', 'bie', 'quantization timeout');
const stored = JSON.parse(redis.store.get('job:j'));
expect(stored.status).toBe('FAILED');
expect(stored.error).toEqual({ step: 'bie', reason: 'quantization timeout' });
expect(sse.sendSSE).toHaveBeenCalledWith('j', stored);
});
});

View File

@ -0,0 +1,169 @@
/**
* sseService 單元測試T4
*
* 著重驗證
* 1. sendSSE 對指定 jobId 的所有 listener 廣播
* 2. 沒有 listener sendSSE 不會 throw
* 3. registerSseClient headers / 立即推送 / heartbeat / cleanup
*/
'use strict';
const { EventEmitter } = require('events');
const { createSseService } = require('../sseService');
/** 簡易 res mock記錄 writeHead / write / setHeader 呼叫。 */
function makeRes() {
const res = new EventEmitter();
res.headers = {};
res.writeHead = jest.fn((status, headers) => {
res.statusCode = status;
if (headers) Object.assign(res.headers, headers);
return res;
});
res.write = jest.fn();
res.setHeader = jest.fn((k, v) => {
res.headers[k] = v;
});
return res;
}
/** 簡易 req mock可觸發 'close' 事件。 */
function makeReq() {
return new EventEmitter();
}
describe('sseService', () => {
/** 累積測試中註冊的 req 以便 afterEach 統一觸發 'close',避免 setInterval 殘留。 */
const createdReqs = [];
function trackReq() {
const r = makeReq();
createdReqs.push(r);
return r;
}
afterEach(() => {
while (createdReqs.length > 0) {
const r = createdReqs.shift();
r.emit('close');
}
});
describe('sendSSE', () => {
it('does nothing when no clients are registered for jobId', () => {
const svc = createSseService();
// 不該 throw
expect(() => svc.sendSSE('job-x', { hello: 'world' })).not.toThrow();
});
it('writes JSON SSE payload to all registered listeners', () => {
const svc = createSseService();
const res1 = makeRes();
const res2 = makeRes();
const req1 = trackReq();
const req2 = trackReq();
svc.registerSseClient('job-1', { status: 'ONNX' }, res1, req1);
svc.registerSseClient('job-1', { status: 'ONNX' }, res2, req2);
// 清掉 register 時的 initial write 紀錄
res1.write.mockClear();
res2.write.mockClear();
svc.sendSSE('job-1', { progress: 50 });
const expected = `data: ${JSON.stringify({ progress: 50 })}\n\n`;
expect(res1.write).toHaveBeenCalledWith(expected);
expect(res2.write).toHaveBeenCalledWith(expected);
});
it('does not broadcast to listeners of other jobs', () => {
const svc = createSseService();
const resA = makeRes();
const resB = makeRes();
svc.registerSseClient('job-A', { s: 1 }, resA, trackReq());
svc.registerSseClient('job-B', { s: 1 }, resB, trackReq());
resA.write.mockClear();
resB.write.mockClear();
svc.sendSSE('job-A', { progress: 100 });
expect(resA.write).toHaveBeenCalledTimes(1);
expect(resB.write).not.toHaveBeenCalled();
});
});
describe('registerSseClient', () => {
afterEach(() => {
jest.useRealTimers();
});
it('writes SSE headers and initial state immediately', () => {
const svc = createSseService();
const res = makeRes();
const req = trackReq();
const initial = { job_id: 'j-1', status: 'ONNX' };
svc.registerSseClient('j-1', initial, res, req);
expect(res.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(res.write).toHaveBeenCalledWith(`data: ${JSON.stringify(initial)}\n\n`);
});
it('removes listener when req emits close, and removes the jobId entry when last listener leaves', () => {
const svc = createSseService();
const res = makeRes();
const req = makeReq(); // 不 track因為下面就會 close
svc.registerSseClient('j-2', { x: 1 }, res, req);
expect(svc._getClientsMap().has('j-2')).toBe(true);
expect(svc._getClientsMap().get('j-2').size).toBe(1);
req.emit('close');
expect(svc._getClientsMap().has('j-2')).toBe(false);
});
it('keeps the jobId entry when there are remaining listeners', () => {
const svc = createSseService();
const res1 = makeRes();
const res2 = makeRes();
const req1 = makeReq();
const req2 = trackReq(); // req1 close 後留下 req2 — afterEach 統一清
svc.registerSseClient('j-3', { x: 1 }, res1, req1);
svc.registerSseClient('j-3', { x: 1 }, res2, req2);
req1.emit('close');
const map = svc._getClientsMap();
expect(map.has('j-3')).toBe(true);
expect(map.get('j-3').size).toBe(1);
expect(map.get('j-3').has(res2)).toBe(true);
});
it('emits heartbeat every 15s', () => {
jest.useFakeTimers();
const svc = createSseService();
const res = makeRes();
const req = makeReq();
svc.registerSseClient('j-4', { x: 1 }, res, req);
res.write.mockClear();
jest.advanceTimersByTime(15000);
expect(res.write).toHaveBeenCalledWith(': heartbeat\n\n');
jest.advanceTimersByTime(15000);
expect(res.write).toHaveBeenCalledTimes(2);
// close 後 heartbeat 停止
req.emit('close');
res.write.mockClear();
jest.advanceTimersByTime(60000);
expect(res.write).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,260 @@
/**
* statusMapper 單元測試T6
*
* 範圍
* - toExternalStatus 對所有合法 internal status 的映射
* - ONNX 階段的 created vs running 邊界stage_timings.onnx.started_at
* - FAILED 階段的 error.stage / error.step / job.stage fallback 順序
* - 防禦性 fallbacknull / undefined / 未知 status
*/
'use strict';
const {
toExternalStatus,
isInProgress,
EXTERNAL_STATUS,
EXTERNAL_STAGE,
} = require('../statusMapper');
describe('toExternalStatus', () => {
describe('CREATED 階段ONNX + onnx.started_at == null', () => {
it('returns created/onnx when stage_timings is null', () => {
const job = { status: 'ONNX', stage: 'onnx', stage_timings: null };
expect(toExternalStatus(job)).toEqual({
status: 'created',
stage: 'onnx',
});
});
it('returns created/onnx when stage_timings.onnx is null', () => {
const job = {
status: 'ONNX',
stage: 'onnx',
stage_timings: { onnx: null, bie: null, nef: null },
};
expect(toExternalStatus(job)).toEqual({
status: 'created',
stage: 'onnx',
});
});
it('returns created/onnx when stage_timings.onnx.started_at is null', () => {
const job = {
status: 'ONNX',
stage: 'onnx',
stage_timings: { onnx: { started_at: null, completed_at: null } },
};
expect(toExternalStatus(job)).toEqual({
status: 'created',
stage: 'onnx',
});
});
});
describe('RUNNING 階段', () => {
it('returns running/onnx when ONNX + onnx.started_at present', () => {
const job = {
status: 'ONNX',
stage: 'onnx',
stage_timings: {
onnx: { started_at: '2026-04-25T12:00:05Z', completed_at: null },
},
};
expect(toExternalStatus(job)).toEqual({
status: 'running',
stage: 'onnx',
});
});
it('returns running/bie when status is BIE', () => {
const job = { status: 'BIE', stage: 'bie' };
expect(toExternalStatus(job)).toEqual({
status: 'running',
stage: 'bie',
});
});
it('returns running/nef when status is NEF', () => {
const job = { status: 'NEF', stage: 'nef' };
expect(toExternalStatus(job)).toEqual({
status: 'running',
stage: 'nef',
});
});
// BIE/NEF 不看 stage_timings.onnx
it('returns running/bie regardless of onnx.started_at being null', () => {
const job = {
status: 'BIE',
stage: 'bie',
stage_timings: { onnx: null },
};
expect(toExternalStatus(job)).toEqual({
status: 'running',
stage: 'bie',
});
});
});
describe('COMPLETED 階段', () => {
it('returns completed/null', () => {
const job = { status: 'COMPLETED', stage: null };
expect(toExternalStatus(job)).toEqual({
status: 'completed',
stage: null,
});
});
it('returns completed/null even if stage somehow has value', () => {
// 即使 record 異常仍堅持回 stage=null對外 contract
const job = { status: 'COMPLETED', stage: 'nef' };
expect(toExternalStatus(job)).toEqual({
status: 'completed',
stage: null,
});
});
});
describe('FAILED 階段', () => {
it('uses error.stage when present', () => {
const job = {
status: 'FAILED',
stage: 'bie',
error: { stage: 'bie', code: 'quantization_failed' },
};
expect(toExternalStatus(job)).toEqual({
status: 'failed',
stage: 'bie',
});
});
it('falls back to error.step when error.stage missing', () => {
const job = {
status: 'FAILED',
stage: 'onnx',
// legacy advanceJob 用 error.step
error: { step: 'onnx', reason: 'oom' },
};
expect(toExternalStatus(job)).toEqual({
status: 'failed',
stage: 'onnx',
});
});
it('falls back to job.stage when error has neither stage nor step', () => {
const job = {
status: 'FAILED',
stage: 'nef',
error: { reason: 'unknown' },
};
expect(toExternalStatus(job)).toEqual({
status: 'failed',
stage: 'nef',
});
});
it('returns null stage when no stage info at all', () => {
const job = { status: 'FAILED', error: null };
expect(toExternalStatus(job)).toEqual({
status: 'failed',
stage: null,
});
});
});
describe('防禦性 fallback', () => {
it('returns created/null for null input', () => {
expect(toExternalStatus(null)).toEqual({
status: 'created',
stage: null,
});
});
it('returns created/null for undefined input', () => {
expect(toExternalStatus(undefined)).toEqual({
status: 'created',
stage: null,
});
});
it('returns created/null for non-object input', () => {
expect(toExternalStatus('string')).toEqual({
status: 'created',
stage: null,
});
expect(toExternalStatus(42)).toEqual({
status: 'created',
stage: null,
});
});
it('returns created/null for unknown internal status', () => {
const job = { status: 'WEIRD_UNKNOWN_STATE', stage: 'foo' };
expect(toExternalStatus(job)).toEqual({
status: 'created',
stage: null,
});
});
it('returns created/null when status is missing', () => {
const job = { stage: 'onnx' };
expect(toExternalStatus(job)).toEqual({
status: 'created',
stage: null,
});
});
});
});
describe('isInProgress', () => {
it('returns true for created', () => {
expect(isInProgress('created')).toBe(true);
});
it('returns true for running', () => {
expect(isInProgress('running')).toBe(true);
});
it('returns false for completed', () => {
expect(isInProgress('completed')).toBe(false);
});
it('returns false for failed', () => {
expect(isInProgress('failed')).toBe(false);
});
it('returns false for unknown status', () => {
expect(isInProgress('weird')).toBe(false);
expect(isInProgress('')).toBe(false);
expect(isInProgress(null)).toBe(false);
expect(isInProgress(undefined)).toBe(false);
});
});
describe('exports', () => {
it('exports EXTERNAL_STATUS constants', () => {
expect(EXTERNAL_STATUS).toEqual({
CREATED: 'created',
RUNNING: 'running',
COMPLETED: 'completed',
FAILED: 'failed',
});
});
it('exports EXTERNAL_STAGE constants', () => {
expect(EXTERNAL_STAGE).toEqual({
ONNX: 'onnx',
BIE: 'bie',
NEF: 'nef',
});
});
it('EXTERNAL_STATUS is frozen', () => {
expect(Object.isFrozen(EXTERNAL_STATUS)).toBe(true);
});
it('EXTERNAL_STAGE is frozen', () => {
expect(Object.isFrozen(EXTERNAL_STAGE)).toBe(true);
});
});

View File

@ -0,0 +1,140 @@
/**
* Done queue listener worker consumer group bootstrapT4 重構自 server.js
* L225-296
*
* 職責
* 1. `ensureWorkerGroups(redis)` 啟動時為 onnx/bie/nef worker queue 各建立
* consumer group若已存在則跳過
* 2. `listenDoneQueue(redisSub, jobService, opts)` 無限迴圈從 `queue:done`
* worker 完成事件呼叫 jobService.advanceJob / failJob
*
* 行為對齊重構不改行為
* - listenDoneQueue BLOCK 5000 等候server.js L245
* - consumerName 仍為 `scheduler-${process.pid}`L235
* - 錯誤處理connection lost 3s sleep其他 1s sleepL273-280
* - ACK 放在 try 內部 legacy 一致server.js L267 advanceJob/failJob
* throw message 不會被 ACK會成為 pending 下次 xreadgroup 重投遞
* 這是既有 at-least-once 語意T4 刻意保留不改未來任務若要改為
* in-finally-ACK 需另行評估是否影響 advanceJob 冪等性
*
* 設計取捨
* - factory `startListenDone(deps)` 回傳 `{ start, stop }`使測試能控制
* 生命週期雖然本任務不寫 listenDoneQueue 的單元測試 它是 long-running
* loop難測保留 hook 以利未來測試
* - 預設 `xreadgroupTimeoutMs` 來自 server.js 5000可覆寫供未來縮短測試
* 時間
*/
'use strict';
const { ensureConsumerGroup } = require('../redis');
const { DONE_QUEUE, DONE_GROUP, STAGE_QUEUES } = require('./jobService');
/**
* 為所有 worker stream 確保 consumer group 存在
* 對齊 server.js L287-295
*
* @param {import('ioredis').Redis} redis
*/
async function ensureWorkerGroups(redis) {
const groups = {
[STAGE_QUEUES.onnx]: 'onnx-workers',
[STAGE_QUEUES.bie]: 'bie-workers',
[STAGE_QUEUES.nef]: 'nef-workers',
};
for (const [queue, group] of Object.entries(groups)) {
await ensureConsumerGroup(redis, queue, group);
}
}
/**
* 啟動 done queue listener背景 long-running 迴圈
*
* 行為對齊 server.js L234-281
* 1. 啟動前先確保 done queue consumer group 存在
* 2. 無限迴圈 xreadgroup BLOCK 5000
* 3. 對每個訊息parse data result 推進或失敗 ACK
* 4. 任何錯誤都 catch sleep 重試 throw loop
*
* @param {object} deps
* @param {import('ioredis').Redis} deps.redis - client用於 ensureConsumerGroup
* @param {import('ioredis').Redis} deps.redisSub - blocking client
* @param {ReturnType<typeof import('./jobService').createJobService>} deps.jobService
* @param {object} [opts]
* @param {number} [opts.xreadBlockMs=5000]
* @param {string} [opts.consumerName] - 預設為 `scheduler-${process.pid}`
* @returns {{ start: () => Promise<void> }}
*/
function startListenDone(deps, opts) {
if (!deps || !deps.redis || !deps.redisSub) {
throw new Error('[doneListener] deps.redis and deps.redisSub are required');
}
if (!deps.jobService) {
throw new Error('[doneListener] deps.jobService is required');
}
const { redis, redisSub, jobService } = deps;
const xreadBlockMs = (opts && opts.xreadBlockMs) || 5000;
const consumerName = (opts && opts.consumerName) || `scheduler-${process.pid}`;
let stopped = false;
async function start() {
await ensureConsumerGroup(redis, DONE_QUEUE, DONE_GROUP);
// eslint-disable-next-line no-console
console.log(`[Scheduler] Listening on ${DONE_QUEUE} as ${consumerName}`);
while (!stopped) {
try {
const results = await redisSub.xreadgroup(
'GROUP', DONE_GROUP, consumerName,
'COUNT', 10,
'BLOCK', xreadBlockMs,
'STREAMS', DONE_QUEUE, '>'
);
if (!results) continue;
for (const [, messages] of results) {
for (const [messageId, fields] of messages) {
try {
// fields = ['data', '{...}'],對齊 server.js L254
const data = JSON.parse(fields[1]);
const { job_id, step, result, reason } = data;
// eslint-disable-next-line no-console
console.log(`[Scheduler] Done event: job=${job_id} step=${step} result=${result}`);
if (result === 'ok') {
await jobService.advanceJob(job_id, step);
} else {
await jobService.failJob(job_id, step, reason || 'Unknown error');
}
await redisSub.xack(DONE_QUEUE, DONE_GROUP, messageId);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Scheduler] Error processing done event:', err);
}
}
}
} catch (err) {
if (err.message && err.message.includes('Connection is closed')) {
// eslint-disable-next-line no-console
console.error('[Scheduler] Redis connection lost, retrying in 3s...');
await new Promise((r) => setTimeout(r, 3000));
} else {
// eslint-disable-next-line no-console
console.error('[Scheduler] Done listener error:', err);
await new Promise((r) => setTimeout(r, 1000));
}
}
}
}
return { start };
}
module.exports = {
ensureWorkerGroups,
startListenDone,
};

View File

@ -0,0 +1,480 @@
/**
* Health service /health 升級T8
*
* 目的擴充原本只 ping Redis /health加入 Member CenterJWKS 端點
* File Access Agent 兩個外部依賴的可達性檢查 /health 本身仍須立即回應
* 不可被任一依賴 hang 為此採背景 polling 30s 一次寫入 cache/health
* 直接讀 cache的設計對齊 TDD §1.4.1 / §2.12 / tasks-phase1.md §2 T8
*
* 對外介面
* const health = createHealthService({ redis, config });
* health.start(); // 啟動背景 polling
* const snapshot = health.getHealth(); // 立即回(永遠 < 5ms
* health.stop(); // graceful shutdown 用
*
* snapshot 形狀對齊 TDD §1.4.1
* {
* service: 'kneron-converter-api',
* status: 'healthy' | 'degraded' | 'unhealthy',
* version: '1.0.0',
* timestamp: '...',
* dependencies: {
* redis: 'connected' | 'disconnected',
* member_center: 'reachable' | 'unreachable' | 'pending',
* file_access_agent: 'reachable' | 'unreachable' | 'pending',
* },
* }
*
* 整體狀態判定順序很重要critical
* - Redis disconnected 'unhealthy'HTTP 503
* - 所有依賴 reachable 'healthy'HTTP 200
* - 任一非關鍵依賴 unreachable/pending 'degraded'HTTP 200
*
* 設計取捨
*
* 1. **Redis 不獨立 ping** ioredis 內建 `status` property'ready' / 'connecting' / 'close'
* / 'reconnecting' / 'end' / 'wait'即可判斷既不消耗連線也不會被慢 ping
* 阻塞/health 直接讀 status不必走 cache property sync
*
* 2. **MC / FAA 走背景 polling cache** 30s 一次併行打兩個 endpointtimeout 3s
* /health fetch cache polling 'pending'
* 'pending' 不致命整體 status 落到 'degraded'仍回 200不影響部署初期的 readiness
*
* 3. **MC JWKS endpoint 探測**JWKS URL Member Center 必有的 public endpoint
* 既不需要憑證也不會洩露任何敏感資訊另外 jose 已在內部 cache JWKS本探測
* 完全獨立用獨立 fetch不靠 jose cache避免 polling 反而干擾 jose cache
*
* 4. **FAA 探測策略**先嘗試 `${baseUrl}/health`多數服務的慣例如果回 404
* 表示沒實作此端點也視為 reachable至少網路層通只有連線失敗 / 5xx /
* timeout 才算 unreachable這比強制 FAA 必須實作 /health 來得寬容
*
* 5. **不洩漏內部資訊**log / error message 都不含 endpoint URLhostport
* 對應 T7 修過的minio_head_failed 洩漏 URL教訓snapshot 只回對外抽象狀態
* reachable / unreachable不揭露錯誤原因細節
*
* 6. **graceful shutdown**start() 是冪等的stop() 清掉 setInterval in-flight
* fetch AbortController process 能乾淨結束
*
* 7. **冪等 start**多次呼叫 start() 不會疊 setInterval若已 running 直接 return
*
* 8. **依賴注入**所有外部依賴fetch / setInterval / setTimeout / Date.now
* 都可以從 deps 注入方便單元測試 fake 時間 / mock 網路
*/
'use strict';
/* eslint-disable no-console */
// ---------------------------------------------------------------------------
// 常數
// ---------------------------------------------------------------------------
/** 對外服務識別(對齊 TDD §1.4.1 範例)。 */
const SERVICE_NAME = 'kneron-converter-api';
/** API 版本(對齊 TDD §1.4.1 範例)。 */
const SERVICE_VERSION = '1.0.0';
/** 背景 polling 週期30s對齊 TDD §1.4.1 / §2.12 / tasks-phase1.md §2 T8。 */
const DEFAULT_POLL_INTERVAL_MS = 30 * 1000;
/** 單一依賴探測的 timeout3s避免 polling 自己被 hang 住)。 */
const DEFAULT_PROBE_TIMEOUT_MS = 3 * 1000;
/** 依賴狀態 enum。 */
const DEP_STATE = Object.freeze({
CONNECTED: 'connected',
DISCONNECTED: 'disconnected',
REACHABLE: 'reachable',
UNREACHABLE: 'unreachable',
PENDING: 'pending',
});
/** 整體狀態 enum。 */
const OVERALL_STATE = Object.freeze({
HEALTHY: 'healthy',
DEGRADED: 'degraded',
UNHEALTHY: 'unhealthy',
});
// ---------------------------------------------------------------------------
// 內部 helpers
// ---------------------------------------------------------------------------
/**
* 結構化 log不洩漏 endpoint
*
* @param {'INFO'|'WARN'|'ERROR'} level
* @param {string} action
* @param {object} [fields]
*/
function logEvent(level, action, fields = {}) {
const line = JSON.stringify({
level,
service: 'health-service',
action,
timestamp: new Date().toISOString(),
...fields,
});
if (level === 'ERROR') {
console.error(line);
} else if (level === 'WARN') {
console.warn(line);
} else {
console.log(line);
}
}
/**
* ioredis status property 轉成對外狀態字串
*
* ioredis 文件'wait' | 'reconnecting' | 'connecting' | 'connect' | 'ready' | 'close' | 'end'
* 只有 'ready' 代表實際可用
*
* @param {{ status?: string }} redis
*/
function classifyRedisStatus(redis) {
if (!redis || typeof redis !== 'object') return DEP_STATE.DISCONNECTED;
// 沒有 status 就保守視為 disconnected也涵蓋了測試 mock 的情境)
if (typeof redis.status !== 'string') return DEP_STATE.DISCONNECTED;
return redis.status === 'ready' ? DEP_STATE.CONNECTED : DEP_STATE.DISCONNECTED;
}
/**
* fetch + AbortController 做一次 GET 探測
*
* 回傳語意
* - 任何 2xx / 3xx / 4xx 狀態 視為 reachable網路層通即可
* - 5xx / network error / timeout / abort unreachable
*
* 為什麼 4xx 也算 reachable
* - 例如 FAA 沒實作 `/health`會回 404這代表服務本身活著只是路由不存在
* - 401/403 同理服務在運作只是拒絕匿名請求
*
* @param {string} url
* @param {{ fetchImpl: Function, setTimeoutFn: Function, clearTimeoutFn: Function, timeoutMs: number, signal?: AbortSignal }} deps
* @returns {Promise<'reachable' | 'unreachable'>}
*/
async function probeHttp(url, deps) {
const controller = new AbortController();
// 若外部給了 master signal用於 stop()),跟著一起 abort
const onMasterAbort = () => controller.abort();
if (deps.signal) {
if (deps.signal.aborted) return DEP_STATE.UNREACHABLE;
deps.signal.addEventListener('abort', onMasterAbort, { once: true });
}
// ★ timeout 用「真實」setTimeout非注入版避免被測試 fake-timer 立即觸發
// abort理由與 fileAccessAgent/client.js attemptPut 相同)
const timer = globalThis.setTimeout(() => controller.abort(), deps.timeoutMs);
try {
const res = await deps.fetchImpl(url, {
method: 'GET',
signal: controller.signal,
});
if (res.status >= 500) {
return DEP_STATE.UNREACHABLE;
}
return DEP_STATE.REACHABLE;
} catch (_err) {
// 網路錯 / abort / DNS / 等等都視為 unreachable不把 err.message 帶出去
return DEP_STATE.UNREACHABLE;
} finally {
try {
globalThis.clearTimeout(timer);
} catch (_) {
/* noop */
}
if (deps.signal) {
try {
deps.signal.removeEventListener('abort', onMasterAbort);
} catch (_) {
/* noop */
}
}
}
}
/**
* cached 依賴狀態決定整體 status
*
* @param {{ redis: string, memberCenter: string, fileAccessAgent: string }} deps
* @returns {'healthy' | 'degraded' | 'unhealthy'}
*/
function deriveOverallStatus(deps) {
// Redis 是 criticaldisconnected → unhealthy503
if (deps.redis !== DEP_STATE.CONNECTED) {
return OVERALL_STATE.UNHEALTHY;
}
// MC / FAAunreachable 或 pending 都讓服務降級為 degraded仍 200
const mcOk = deps.memberCenter === DEP_STATE.REACHABLE;
const faaOk = deps.fileAccessAgent === DEP_STATE.REACHABLE;
if (mcOk && faaOk) return OVERALL_STATE.HEALTHY;
return OVERALL_STATE.DEGRADED;
}
// ---------------------------------------------------------------------------
// Health service factory
// ---------------------------------------------------------------------------
/**
* @typedef {Object} HealthServiceDeps
* @property {{ status?: string }} redis - ioredis client status property
* @property {{
* memberCenter: { jwksUrl: string },
* fileAccessAgent: { baseUrl: string },
* }} [config]
* @property {string} [memberCenterProbeUrl] - 覆寫 MC 探測 URL測試用
* @property {string} [fileAccessAgentProbeUrl] - 覆寫 FAA 探測 URL測試用
* @property {Function} [fetch] - 注入 fetch測試 mock
* @property {Function} [setIntervalFn] - 注入 setInterval測試 fake timer
* @property {Function} [clearIntervalFn] - 注入 clearInterval
* @property {Function} [setTimeoutFn] - 注入 setTimeout
* @property {Function} [clearTimeoutFn] - 注入 clearTimeout
* @property {Function} [now] - 注入 Date.now
* @property {number} [pollIntervalMs] - polling 週期覆寫預設 30s
* @property {number} [probeTimeoutMs] - 單一探測 timeout覆寫預設 3s
*
* @param {HealthServiceDeps} deps
*/
function createHealthService(deps) {
if (!deps || typeof deps !== 'object') {
throw new Error('[healthService] deps is required');
}
if (!deps.redis) {
throw new Error('[healthService] deps.redis is required');
}
const fetchImpl = deps.fetch || globalThis.fetch;
const setIntervalFn = deps.setIntervalFn || globalThis.setInterval;
const clearIntervalFn = deps.clearIntervalFn || globalThis.clearInterval;
const setTimeoutFn = deps.setTimeoutFn || globalThis.setTimeout;
const clearTimeoutFn = deps.clearTimeoutFn || globalThis.clearTimeout;
const nowFn = typeof deps.now === 'function' ? deps.now : Date.now;
const pollIntervalMs =
Number.isInteger(deps.pollIntervalMs) && deps.pollIntervalMs > 0
? deps.pollIntervalMs
: DEFAULT_POLL_INTERVAL_MS;
const probeTimeoutMs =
Number.isInteger(deps.probeTimeoutMs) && deps.probeTimeoutMs > 0
? deps.probeTimeoutMs
: DEFAULT_PROBE_TIMEOUT_MS;
// 計算探測 URLlazy / 顯式注入優先config 次之)
function resolveMemberCenterUrl() {
if (typeof deps.memberCenterProbeUrl === 'string' && deps.memberCenterProbeUrl !== '') {
return deps.memberCenterProbeUrl;
}
if (deps.config && deps.config.memberCenter && deps.config.memberCenter.jwksUrl) {
return deps.config.memberCenter.jwksUrl;
}
return null;
}
function resolveFaaUrl() {
if (typeof deps.fileAccessAgentProbeUrl === 'string' && deps.fileAccessAgentProbeUrl !== '') {
return deps.fileAccessAgentProbeUrl;
}
if (deps.config && deps.config.fileAccessAgent && deps.config.fileAccessAgent.baseUrl) {
const trimmed = String(deps.config.fileAccessAgent.baseUrl).replace(/\/+$/, '');
return `${trimmed}/health`;
}
return null;
}
// ----- 內部狀態cache -----
// 第一次 polling 完成前,外部依賴標 'pending'Redis 因為是 sync property每次
// 讀都即時計算(不放 cache
const cache = {
memberCenter: DEP_STATE.PENDING,
fileAccessAgent: DEP_STATE.PENDING,
lastPollAt: null, // ISO 字串,僅供 log / debug
};
let intervalHandle = null;
let masterAbort = null; // AbortController讓 stop() 能取消 in-flight fetch
let inFlight = false; // 避免 polling 重疊slow probe 撞到下次 tick
let started = false;
/**
* 進行一次依賴探測兩個依賴併行失敗單一依賴不影響另一個
*/
async function runOnce() {
if (inFlight) {
// 上次還沒結束就跳過這次(避免 slow probe 堆疊)
return;
}
inFlight = true;
// 共用一個 master abort signalstop() 一拉就同時取消兩個 fetch
if (!masterAbort) masterAbort = new AbortController();
const signal = masterAbort.signal;
const probeDeps = {
fetchImpl,
setTimeoutFn,
clearTimeoutFn,
timeoutMs: probeTimeoutMs,
signal,
};
const mcUrl = resolveMemberCenterUrl();
const faaUrl = resolveFaaUrl();
// 沒有 URL → 一律標 unreachabledev 沒設 config 時的合理 fallback
const mcPromise = mcUrl
? probeHttp(mcUrl, probeDeps).catch(() => DEP_STATE.UNREACHABLE)
: Promise.resolve(DEP_STATE.UNREACHABLE);
const faaPromise = faaUrl
? probeHttp(faaUrl, probeDeps).catch(() => DEP_STATE.UNREACHABLE)
: Promise.resolve(DEP_STATE.UNREACHABLE);
try {
const [mcResult, faaResult] = await Promise.all([mcPromise, faaPromise]);
// 若 stop() 在 fetch 期間被呼叫abort signal 已觸發 → 還是寫進 cache
// 但寫成 unreachable 是預期的caller 也已停止 polling後續沒人會看到
cache.memberCenter = mcResult;
cache.fileAccessAgent = faaResult;
cache.lastPollAt = new Date(nowFn()).toISOString();
logEvent('INFO', 'health.poll_complete', {
member_center: mcResult,
file_access_agent: faaResult,
});
} catch (err) {
// probeHttp 已在內部 catch這層只會在 Promise.all 自己出錯時走到
logEvent('ERROR', 'health.poll_unexpected_error', {
error_name: err && err.name ? err.name : 'unknown',
});
} finally {
inFlight = false;
}
}
/**
* 啟動背景 polling冪等重複呼叫無效
*/
function start() {
if (started) return;
started = true;
masterAbort = new AbortController();
// 先觸發一次(不等結果),讓 cache 在第一個 polling 週期內就盡早填好
runOnce().catch((err) => {
logEvent('ERROR', 'health.initial_poll_error', {
error_name: err && err.name ? err.name : 'unknown',
});
});
intervalHandle = setIntervalFn(() => {
runOnce().catch((err) => {
logEvent('ERROR', 'health.interval_poll_error', {
error_name: err && err.name ? err.name : 'unknown',
});
});
}, pollIntervalMs);
// 如果 setInterval 回的是 Node Timer object呼叫 unref 讓背景 polling 不阻塞 process exit
if (intervalHandle && typeof intervalHandle.unref === 'function') {
try {
intervalHandle.unref();
} catch (_) {
/* noop */
}
}
logEvent('INFO', 'health.start', { poll_interval_ms: pollIntervalMs });
}
/**
* 停止 polling並中斷任何 in-flight fetch
*/
function stop() {
if (!started) return;
started = false;
if (intervalHandle != null) {
try {
clearIntervalFn(intervalHandle);
} catch (_) {
/* noop */
}
intervalHandle = null;
}
if (masterAbort) {
try {
masterAbort.abort();
} catch (_) {
/* noop */
}
masterAbort = null;
}
logEvent('INFO', 'health.stop');
}
/**
* 立即回 health snapshot永遠 < 5ms不阻塞
*
* @returns {{
* service: string,
* status: 'healthy' | 'degraded' | 'unhealthy',
* version: string,
* timestamp: string,
* dependencies: { redis: string, member_center: string, file_access_agent: string },
* }}
*/
function getHealth() {
const redisState = classifyRedisStatus(deps.redis);
const dependencies = {
redis: redisState,
member_center: cache.memberCenter,
file_access_agent: cache.fileAccessAgent,
};
const overall = deriveOverallStatus({
redis: redisState,
memberCenter: cache.memberCenter,
fileAccessAgent: cache.fileAccessAgent,
});
return {
service: SERVICE_NAME,
status: overall,
version: SERVICE_VERSION,
timestamp: new Date(nowFn()).toISOString(),
dependencies,
};
}
/**
* 是否為不健康狀態用來決定 HTTP status code 是否回 503
*
* @returns {boolean}
*/
function isUnhealthy() {
const snapshot = getHealth();
return snapshot.status === OVERALL_STATE.UNHEALTHY;
}
return {
start,
stop,
getHealth,
isUnhealthy,
// 測試用:強制執行一次 poll正式環境不應呼叫
_runOnce: runOnce,
};
}
module.exports = {
createHealthService,
// 常數對外暴露便於測試 / 其他模組引用
SERVICE_NAME,
SERVICE_VERSION,
DEFAULT_POLL_INTERVAL_MS,
DEFAULT_PROBE_TIMEOUT_MS,
DEP_STATE,
OVERALL_STATE,
// 測試用 internal helpers
_internals: {
classifyRedisStatus,
deriveOverallStatus,
probeHttp,
},
};

View File

@ -0,0 +1,774 @@
/**
* Job CRUD + 階段推進服務T4 重構自 server.js L84-91L145-220T5 擴充
*
* 職責
* 1. STAGES / STAGE_QUEUES / DONE_QUEUE / DONE_GROUP 等常數
* 2. `getJob(jobId)` / `setJob(jobId, job)` / `enqueueStage(stage, job)`
* 3. `advanceJob(jobId, completedStage)` / `failJob(jobId, step, reason)`
* 4. T5`writeInputToMinIO(jobId, modelFile, refImages)`
* `claimActiveAndCreate({ userId, jobId, jobRecord, ttlSeconds })`
* `cleanupInputObjects(jobId, objectKeys)``getActiveJob(userId)`
*
* 行為對齊重構不改行為
* - setJob 會自動更新 `updated_at` 並透過 sseService 廣播server.js L151-156
* - enqueueStage input_dir 永遠用 `path.join(JOB_DATA_DIR, job.job_id)`
* server.js L166 注意這個路徑是給 Worker 看的**Worker 仍依此格式
* 讀檔**所以即使 STORAGE_BACKEND=minio 也保留同樣的字串Worker 會從
* MinIO input_dir 對它而言只是 metadata
* - advanceJob 的進度計算`Math.round(((nextIndex) / STAGES.length) * 100)`
* 完全不變server.js L196
* - 完成時 status='COMPLETED'stage=nullprogress=100server.js L201-204
* - 失敗時 status='FAILED' error 物件server.js L216-218
*
* 設計取捨
* - factory function `createJobService(deps)` redis sseService 注入進來
* 讓單元測試容易 mock
* - jobService 不直接 require redis.js / sseService.js避免測試時 import 觸發
* 實體連線
* - T5 minio optional dep既有 legacy 路徑沒 minio dep
* * deps.minio 存在 暴露 `writeInputToMinIO` / `cleanupInputObjects`
* * 否則該介面 throw 呼叫端應在 mount 階段就確認 storageBackend === 'minio'
*/
'use strict';
const path = require('path');
const crypto = require('crypto');
const { claimActiveJob, releaseActiveJob } = require('../redis/luaScripts');
const { toExternalStatus, isInProgress } = require('./statusMapper');
// Pipeline: fixed stage order對齊 server.js L84-91
const STAGES = ['onnx', 'bie', 'nef'];
const STAGE_QUEUES = {
onnx: 'queue:onnx',
bie: 'queue:bie',
nef: 'queue:nef',
};
const DONE_QUEUE = 'queue:done';
const DONE_GROUP = 'scheduler';
/**
* 建立 jobService instance
*
* @param {object} deps
* @param {import('ioredis').Redis} deps.redis - Redis client
* @param {{ sendSSE: (jobId: string, data: unknown) => void }} deps.sseService
* @param {string} [deps.jobDataDir] - 覆寫 JOB_DATA_DIR測試用
* @returns {object} jobService instance介面詳見回傳物件
*/
function createJobService(deps) {
if (!deps || !deps.redis) {
throw new Error('[jobService] deps.redis is required');
}
if (!deps.sseService || typeof deps.sseService.sendSSE !== 'function') {
throw new Error('[jobService] deps.sseService.sendSSE is required');
}
const { redis, sseService } = deps;
const minio = deps.minio || null; // T5可選缺則只能用 legacy CRUD 介面
const jobDataDir = deps.jobDataDir || process.env.JOB_DATA_DIR || '/data/jobs';
/**
* job record對齊 server.js L145-149
*/
async function getJob(jobId) {
const raw = await redis.get(`job:${jobId}`);
if (!raw) return null;
return JSON.parse(raw);
}
/**
* job record會自動更新 updated_at 並透過 SSE 廣播
* 對齊 server.js L151-156
*/
async function setJob(jobId, job) {
job.updated_at = new Date().toISOString();
await redis.set(`job:${jobId}`, JSON.stringify(job));
sseService.sendSSE(jobId, job);
}
/**
* 把任務送進對應 stage Redis Stream
* 對齊 server.js L161-171
*/
async function enqueueStage(stage, job) {
const queue = STAGE_QUEUES[stage];
if (!queue) {
throw new Error(`[jobService] Unknown stage: ${stage}`);
}
const message = {
job_id: job.job_id,
created_at: job.created_at,
input_dir: path.join(jobDataDir, job.job_id),
parameters: job.parameters || {},
};
await redis.xadd(queue, '*', 'data', JSON.stringify(message));
// eslint-disable-next-line no-console
console.log(`[Scheduler] Enqueued job ${job.job_id} to ${queue}`);
}
/**
* 確保 job.stage_timings 結構存在若缺漏則初始化為三個 stage 的空殼
*
* T9 引入legacy job record 沒有 stage_timings 欄位server.js 既有
* advanceJob / failJob timings 時需要先 ensure 結構避免 undefined 取值
*
* @param {object} job
*/
function ensureStageTimings(job) {
if (!job.stage_timings || typeof job.stage_timings !== 'object') {
job.stage_timings = { onnx: null, bie: null, nef: null };
}
for (const s of STAGES) {
if (!job.stage_timings[s] || typeof job.stage_timings[s] !== 'object') {
job.stage_timings[s] = { started_at: null, completed_at: null };
}
}
}
/**
* 寫入 stage_timings.{stage}.started_at不寫 Redismutate job 物件
*
* Phase 1 語意started_at 實為 enqueued_atScheduler job 推到下一階段
* queue 的時間 worker 真正開工有 queue 等待時間的差距
* 詳見 §4.1 #3 trade-off 說明T6 OpenAPI 會註明此差距
*
* @param {object} job
* @param {string} stage
*/
function recordStageStart(job, stage) {
ensureStageTimings(job);
job.stage_timings[stage].started_at = new Date().toISOString();
}
/**
* 寫入 stage_timings.{stage}.completed_at不寫 Redismutate job 物件
*
* 用於
* - advanceJobworker 上報 done event標記該 stage 完成
* - failJobworker 上報 failure標記該 stage 結束即便結果為失敗仍視為
* stage 已不再進行這樣 stage_timings 才是完整可分析的紀錄
*
* @param {object} job
* @param {string} stage
*/
function recordStageComplete(job, stage) {
ensureStageTimings(job);
job.stage_timings[stage].completed_at = new Date().toISOString();
}
/**
* Job 終態COMPLETED / FAILED時釋放 user:{user_id}:active_jobT9
*
* 為什麼用 fire-and-forgetcatch 後只 log
* - 終態邏輯本身已完成job record 已更新release 失敗最差情境是
* user 等到 7d TTL 才能建新 job 這是當前未實作前的 default 行為
* 沒有劣化
* - advanceJob / failJob done listener 呼叫 release throw 會導致
* done event ACK 重投遞 advanceJob 重複執行行為冪等但浪費資源
* - ops 來說release 失敗的 log 已足夠告警不需阻塞 advance
*
* 為什麼要 guard user_id
* legacy /jobs 建的 job 沒有 user_id user_id null / 'web-anonymous'
* 它從來沒寫過 user:{user_id}:active_job硬呼叫 release Lua 會白做工 +
* 產生不必要的 NOOP log
*
* 為什麼還呼叫 releaseActiveJobByUser即便 user_id 為非空字串
* release_active_job.lua atomic guard 會自己檢查active_job 是否真的
* 等於 jobId若不等於就 NOOP這樣即使 user_id 是非預期值例如錯誤
* 寫入也不會誤刪別人的 active_job
*
* @param {object} job
* @returns {Promise<void>}
*/
async function releaseActiveJobOnTerminal(job) {
const userId = job && typeof job.user_id === 'string' ? job.user_id : '';
const jobId = job && typeof job.job_id === 'string' ? job.job_id : '';
if (!userId || !jobId) {
// legacy / 缺欄位 → 略過(沒有 active_job key 對應)
return;
}
try {
// 注意releaseActiveJobByUser 定義在下方T5 既有 closure 內 function
// declaration會被 hoist。本 helper 是 T9 新增,刻意不挪動 T5 既有
// function 順序避免 diff 干擾 reviewer。
const result = await releaseActiveJobByUser(userId, jobId);
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
level: 'INFO',
service: 'task-scheduler',
action: 'jobs.terminal.release_active_job',
job_id: jobId,
user_id: userId,
released: result.released,
timestamp: new Date().toISOString(),
})
);
} catch (err) {
// 不阻塞 advance / fail只 log WARN
// eslint-disable-next-line no-console
console.warn(
JSON.stringify({
level: 'WARN',
service: 'task-scheduler',
action: 'jobs.terminal.release_active_job_failed',
job_id: jobId,
user_id: userId,
error: err && err.message ? err.message : 'unknown',
timestamp: new Date().toISOString(),
})
);
}
}
/**
* 推進 job 到下一階段或標記為完成
* 對齊 server.js L176-207T9 加入 stage_timings 寫入 + 終態 release
*
* T9 行為改動
* 1. stage_timings.{completedStage}.completed_at = now
* 2. 推進到下一階段時 stage_timings.{nextStage}.started_at = nowenqueued_at
* 3. 達到 COMPLETED 呼叫 release_active_job若有 user_id
*
* 為什麼 stage_timings 改動跟既有 status / stage / progress 寫入合併在同一次
* setJob原子性 Redis 看到的永遠是狀態與 timings 同步 record
*/
async function advanceJob(jobId, completedStage) {
const job = await getJob(jobId);
if (!job) {
// eslint-disable-next-line no-console
console.warn(`[Scheduler] Job ${jobId} not found, ignoring done event`);
return;
}
const currentIndex = STAGES.indexOf(completedStage);
if (currentIndex < 0) {
// eslint-disable-next-line no-console
console.warn(`[Scheduler] Unknown stage: ${completedStage}`);
return;
}
// T9標記當前 stage 完成
recordStageComplete(job, completedStage);
const nextIndex = currentIndex + 1;
if (nextIndex < STAGES.length) {
// 推進到下一階段
const nextStage = STAGES[nextIndex];
job.status = nextStage.toUpperCase();
job.stage = nextStage;
job.progress = Math.round((nextIndex / STAGES.length) * 100);
// T9標記下一 stage 已 enqueuestarted_at 為 enqueued_at 語意)
recordStageStart(job, nextStage);
await setJob(jobId, job);
await enqueueStage(nextStage, job);
} else {
// 全部完成
job.status = 'COMPLETED';
job.stage = null;
job.progress = 100;
await setJob(jobId, job);
// T9終態釋放 active_jobbest-effort仍 await 取得結果用以 log但內部已 catch 不會 throw
await releaseActiveJobOnTerminal(job);
// eslint-disable-next-line no-console
console.log(`[Scheduler] Job ${jobId} COMPLETED`);
}
}
/**
* 標記 job 為失敗
* 對齊 server.js L212-220T9 加入 stage_timings.completed_at + 終態 release
*
* T9 行為改動
* 1. stage_timings.{step}.completed_at = now fail stage 已結束
* 2. 呼叫 release_active_job若有 user_id
*
* 注意其他 stage 維持 null不一次填補所有後續 stage completed_at
* 這樣 stage_timings 才能真實反映 job 哪個 stage 失敗
*/
async function failJob(jobId, step, reason) {
const job = await getJob(jobId);
if (!job) return;
job.status = 'FAILED';
job.error = { step, reason };
// T9標記失敗 stage 已結束only 該 stage其他 stage 維持 null
if (STAGES.indexOf(step) >= 0) {
recordStageComplete(job, step);
}
await setJob(jobId, job);
// T9終態釋放 active_jobbest-effort仍 await 取得結果用以 log但內部已 catch 不會 throw
await releaseActiveJobOnTerminal(job);
// eslint-disable-next-line no-console
console.log(`[Scheduler] Job ${jobId} FAILED at ${step}: ${reason}`);
}
// ---------------------------------------------------------------------------
// T5 新增MinIO 寫入 + Lua claim active job
// ---------------------------------------------------------------------------
/**
* Object key 命名對齊 TDD §6.1
*
* 為什麼包成 helper 而非 inline 字串
* 單元測試可以驗 key 命名且未來改命名規則時集中修改
*
* @param {string} jobId
* @param {string} safeFilename sanitize model 檔名
*/
function buildInputObjectKey(jobId, safeFilename) {
return `jobs/${jobId}/input/${safeFilename}`;
}
/**
* Ref image object key對齊 TDD §6.1
* 加入 index 前綴避免同名衝突
*
* @param {string} jobId
* @param {number} index
* @param {string} safeFilename
*/
function buildRefImageObjectKey(jobId, index, safeFilename) {
return `jobs/${jobId}/ref_images/${index}_${safeFilename}`;
}
/**
* model + ref images MinIO
*
* fail-fast任一檔上傳失敗即 throw呼叫端應回 502 storage_unavailable
* **** RedisM5 方案 A 的核心失敗時 Redis 完全乾淨
*
* 並行 ref images 上傳 Promise.all 同時送多個 ref image 寫入請求
* 任一 fail Promise.all reject即便其他 chunk 已寫入也沒關係
* MinIO 7 lifecycle 會清掉這些 orphan 檔案doc-review M5 已論述此 trade-off
*
* @param {string} jobId
* @param {{ buffer: Buffer, mimetype?: string, originalname?: string }} modelFileMeta
* @param {string} safeModelFilename
* @param {Array<{ file: { buffer: Buffer, mimetype?: string }, safeFilename: string }>} refImages
* @returns {Promise<{
* inputObjectKey: string,
* refImageObjectKeys: string[],
* uploadedKeys: string[] // 全部已寫入的 key用於失敗時 cleanup
* }>}
*/
async function writeInputToMinIO(jobId, modelFileMeta, safeModelFilename, refImages) {
if (!minio || !minio.client) {
throw new Error(
'[jobService.writeInputToMinIO] minio dep is required and STORAGE_BACKEND must be minio'
);
}
const inputObjectKey = buildInputObjectKey(jobId, safeModelFilename);
const refImageObjectKeys = refImages.map((it, idx) =>
buildRefImageObjectKey(jobId, idx, it.safeFilename)
);
// 先寫 model 檔(最大檔,最有可能 failfail 時不需清 ref images
await minio.uploadToMinIO(
inputObjectKey,
modelFileMeta.buffer,
modelFileMeta.mimetype || 'application/octet-stream'
);
// ref images 並行寫入fail 時上面的 model 檔已寫入,回滾交給呼叫端
if (refImages.length > 0) {
await Promise.all(
refImages.map((it, idx) =>
minio.uploadToMinIO(
refImageObjectKeys[idx],
it.file.buffer,
it.file.mimetype || 'image/jpeg'
)
)
);
}
return {
inputObjectKey,
refImageObjectKeys,
uploadedKeys: [inputObjectKey, ...refImageObjectKeys],
};
}
/**
* 成功寫 MinIO Lua script 一次寫完整 job record + claim active job +
* SADD user:jobs對應 M5 方案 A
*
* @param {object} args
* @param {string} args.userId
* @param {string} args.jobId
* @param {object} args.jobRecord 完整 job record本函式 stringify 後傳給 Lua
* @param {number} args.ttlSeconds 三把 key TTL
* @returns {Promise<
* | { ok: true }
* | { ok: false, conflict: true, activeJobId: string }
* >}
*/
async function claimActiveAndCreate({ userId, jobId, jobRecord, ttlSeconds }) {
const jobJson = JSON.stringify(jobRecord);
const result = await claimActiveJob(redis, {
userId,
jobId,
jobJson,
ttlSeconds,
});
if (result.ok) {
// 廣播給可能存在的 SSE listener雖然 v1 client 不用 SSE但為了
// 與 legacy /jobs/:id/events 共存legacy listener 仍能即時看到
// 新建的 job state
sseService.sendSSE(jobId, jobRecord);
}
return result;
}
/**
* 釋放 active_jobSec M2 + Reviewer Major-2 修復
*
* 用於 enqueue 失敗時補償釋放鎖底層用 Lua 確保 atomic guard
* 只在 active_job 仍指向 expectedJobId 時才 DEL避免誤刪其他 job 的鎖
*
* 為什麼不直接 redis.del
* - race condition如果 active_job 在我們呼叫 release 之前已被別人改寫
* 例如 worker 完成 + claim直接 DEL 會誤刪別人的鎖
* - Lua GET + 比對 + DEL atomic單一 EVAL
*
* @param {string} userId
* @param {string} expectedJobId 必須等於當前 active_job 的值才會釋放
* @returns {Promise<{ released: boolean }>}
*/
async function releaseActiveJobByUser(userId, expectedJobId) {
const result = await releaseActiveJob(redis, {
userId,
jobId: expectedJobId,
});
return { released: result.released };
}
/**
* 取得 user 當前 active_job job_id不讀完整 record
*
* 用於 Sec M4 寫入放大 pre-check MinIO 寫入之前廉價判斷是否有 active job
* 避免 conflict request 還是上傳完 500MB 才被 Lua reject
*
* 為什麼跟 getActiveJob 拆開
* - pre-check 場景只要知道有沒有不需要 job record多一次 GET
* - conflict 流程才需要完整 record conflict payload
*
* @param {string} userId
* @returns {Promise<string|null>}
*/
async function getActiveJobId(userId) {
return redis.get(`user:${userId}:active_job`);
}
/**
* 取得 user 當前 active job job record回給 409 衝突 response
*
* 為什麼不直接讀 `user:{userId}:active_job` 後再讀 `job:{id}`
* 呼叫端拿到 conflict + activeJobId 需要 stage / progress / created_at
* 等資訊填到 v1 衝突 payloadTDD §1.5所以仍要讀 job record
*
* @param {string} userId
* @returns {Promise<{ activeJobId: string|null, job: object|null }>}
*/
async function getActiveJob(userId) {
const activeJobId = await getActiveJobId(userId);
if (!activeJobId) return { activeJobId: null, job: null };
const job = await getJob(activeJobId);
return { activeJobId, job };
}
/**
* 衝突 / 失敗時清理已寫入 MinIO input 物件
*
* 為什麼採 fire-and-forget throw
* 呼叫端已決定回 user 409 / 502 等錯誤 MinIO 失敗也不該再覆蓋這個錯誤
* Converter Bucket 7 lifecycle 會兜底清掉 orphan 檔案doc-review M5
* 失敗時只 log****改變 caller response
*
* @param {string[]} objectKeys
*/
async function cleanupInputObjects(objectKeys) {
if (!Array.isArray(objectKeys) || objectKeys.length === 0) return;
if (!minio || !minio.client || typeof minio.deleteObject !== 'function') {
// 沒有 deleteObject 介面就靜默 skip依賴 lifecycle 清)
return;
}
await Promise.allSettled(
objectKeys.map((key) =>
minio.deleteObject(key).catch((err) => {
// eslint-disable-next-line no-console
console.warn(
JSON.stringify({
level: 'WARN',
service: 'task-scheduler',
action: 'minio.cleanup_failed',
object_key: key,
error: err && err.message ? err.message : 'unknown',
timestamp: new Date().toISOString(),
})
);
})
)
);
}
// ---------------------------------------------------------------------------
// T6 新增:列表查詢 + ETag
// ---------------------------------------------------------------------------
/**
* user 索引列出該 user 的所有 job records並依 client / status / 時間過濾
*
* 為什麼用 SMEMBERS + pipeline GET而非 KEYS / SCAN
* - TDD §2.7.3 明確要求避免 KEYS *O(N) 阻塞 Redis
* - SMEMBERS 取的是 Set 索引O(N) N 是該 user job 通常 < 100
* - pipeline GET N GET 合併成一次 round-triplatency 友善
*
* Client 隔離在應用層 filter `created_by_client_id === clientId`
* - 不在 Lua Set 沒有依屬性過濾的能力
* - 不在 Redis index client_id 做二級索引Phase 1 流量不大省工
* - 安全考量即便 user_id 被攻擊者猜中client_id 不符仍然會被過濾掉
*
* 為什麼 max limit 50
* - 任務文件 §3.6 指定 max=50防大量讀取
* - 對齊 Recovery 場景visionA-backend 一次最多需要 50 in_progress jobs
* 夠用且不會引起 OOM
*
* @param {object} args
* @param {string} args.userId
* @param {string} args.clientId req.auth.clientId 必填
* @param {string} [args.status='in_progress'] `in_progress` / `completed` / `failed` / `all`
* @param {number} [args.limit=10] 1 limit 50
* @param {number} [args.offset=0] 0
* @returns {Promise<{
* jobs: object[], // 已過濾 + 排序 + 分頁的 job records
* total: number, // 過濾後(未分頁前)的總數
* nextOffset: number|null // 還有更多時為下一個 offset否則 null
* }>}
*/
async function listJobsByUser({
userId,
clientId,
status = 'in_progress',
limit = 10,
offset = 0,
}) {
if (typeof userId !== 'string' || userId === '') {
throw new Error('[listJobsByUser] userId is required');
}
if (typeof clientId !== 'string' || clientId === '') {
throw new Error('[listJobsByUser] clientId is required');
}
// 取 user 的所有 job_idSet 索引,避免 KEYS *
const jobIds = await redis.smembers(`user:${userId}:jobs`);
if (!Array.isArray(jobIds) || jobIds.length === 0) {
return { jobs: [], total: 0, nextOffset: null };
}
// pipeline GETN 次 GET 合併成一次 round-trip
const pipeline = redis.pipeline();
for (const id of jobIds) {
pipeline.get(`job:${id}`);
}
const results = await pipeline.exec();
// pipeline.exec() 回傳 [[err, value], [err, value], ...]
// 任一 err 不該整批 fail個別 skip 該 recordlog warn
const records = [];
for (let i = 0; i < results.length; i += 1) {
const entry = results[i] || [];
const err = entry[0];
const raw = entry[1];
if (err) {
// eslint-disable-next-line no-console
console.warn(
JSON.stringify({
level: 'WARN',
service: 'task-scheduler',
action: 'jobs.list.pipeline_get_error',
user_id: userId,
job_id: jobIds[i],
error: err && err.message ? err.message : 'unknown',
timestamp: new Date().toISOString(),
})
);
continue;
}
if (!raw) continue; // job 已過期或被刪除race
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
records.push(parsed);
}
} catch (_) {
// 忽略損壞 recordlog
// eslint-disable-next-line no-console
console.warn(
JSON.stringify({
level: 'WARN',
service: 'task-scheduler',
action: 'jobs.list.parse_error',
user_id: userId,
job_id: jobIds[i],
timestamp: new Date().toISOString(),
})
);
}
}
// Client 隔離(深度防禦:跨 client 不應該共用 user_id但仍然 filter
const ownedByClient = records.filter(
(r) => r && r.created_by_client_id === clientId
);
// status filter
let filtered;
if (status === 'all') {
filtered = ownedByClient;
} else {
filtered = ownedByClient.filter((r) => {
const ext = toExternalStatus(r);
if (status === 'in_progress') return isInProgress(ext.status);
return ext.status === status;
});
}
// 排序created_at desc最新在前
filtered.sort((a, b) => {
const at = a && a.created_at ? a.created_at : '';
const bt = b && b.created_at ? b.created_at : '';
// string 比較對 ISO 8601 是正確的(同時區、固定寬度)
if (bt < at) return -1;
if (bt > at) return 1;
return 0;
});
const total = filtered.length;
const safeOffset = Math.max(0, offset);
const safeLimit = Math.max(1, Math.min(50, limit));
const slice = filtered.slice(safeOffset, safeOffset + safeLimit);
const consumed = safeOffset + slice.length;
const nextOffset = consumed < total ? consumed : null;
return { jobs: slice, total, nextOffset };
}
/**
* 計算 job record weak ETag基於 updated_at
*
* 為什麼用 weak ETag
* - 同一個 updated_at 對應的 record 內容一致updated_at setJob 時更新的
* timestamp記錄變更才會改但用 weak 標示避免 byte-by-byte 比對
* - client 來說只要 If-None-Match 命中就回 304內容不變
*
* 為什麼選 sha1 而非完整 record hash
* - 計算成本小updated_at 是固定長度字串
* - 不洩漏 record 內容避免攻擊者透過 ETag 推測 record 變更頻率
*
* 為什麼還包 base64 + 截斷 16 bytes
* - sha1 hex 40 chars 太長base64url 16 bytes 22 chars 已遠超 collision 安全
* - cache key 比對成本與可讀性更友善
*
* @param {{ updated_at?: string }} job
* @returns {string} - W/"<22 chars>" 形式
*/
function computeEtag(job) {
const updatedAt = job && typeof job.updated_at === 'string' ? job.updated_at : '';
const hash = crypto
.createHash('sha1')
.update(updatedAt)
.digest('base64')
// base64url 兼容 + 去 padding避免 ETag header 含 `=` 觸發某些 client 解析問題
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
.slice(0, 22);
return `W/"${hash}"`;
}
// ---------------------------------------------------------------------------
// T7 新增promote 標記與冪等查詢
// ---------------------------------------------------------------------------
/**
* promote 結果寫回 job record提供冪等支援
*
* 為什麼把它放在 jobService而非 promote handler inline redis.set
* - setJob 已封裝 updated_at 更新與 SSE 廣播重用避免邏輯散落
* - 未來若要把 promote 結果寫入 user 索引 PromotedSet也能集中改
*
* 為什麼用 deep merge 而非整批覆寫
* - 不破壞既有欄位status / stage / progress / output / stage_timings
* - job 多次 promote譬如先 promote 一個檔再 promote 另一個能累加
*
* @param {string} jobId
* @param {{
* promotedAt: string,
* promotedKeys: Array<{ source: string, target_object_key: string, size_bytes?: number|null, file_access_agent_etag?: string|null, promoted_at: string }>
* }} args
* @returns {Promise<object|null>} 更新後的 job recordjob 不存在時回 null
*/
async function markPromoted(jobId, args) {
if (typeof jobId !== 'string' || jobId === '') {
throw new Error('[markPromoted] jobId is required');
}
if (!args || typeof args !== 'object') {
throw new Error('[markPromoted] args is required');
}
const { promotedAt, promotedKeys } = args;
if (typeof promotedAt !== 'string' || promotedAt === '') {
throw new Error('[markPromoted] args.promotedAt is required (ISO string)');
}
if (!Array.isArray(promotedKeys)) {
throw new Error('[markPromoted] args.promotedKeys must be an array');
}
const job = await getJob(jobId);
if (!job) return null;
job.promoted = true;
job.promoted_at = promotedAt;
// 整批覆寫 promoted_object_keyscaller 已在 handler 累加完整清單)
job.promoted_object_keys = promotedKeys;
await setJob(jobId, job);
return job;
}
return {
getJob,
setJob,
enqueueStage,
advanceJob,
failJob,
// T5 介面
writeInputToMinIO,
claimActiveAndCreate,
getActiveJob,
getActiveJobId, // Sec M4 寫入放大 pre-check
releaseActiveJob: releaseActiveJobByUser, // Sec M2 + Reviewer Major-2
cleanupInputObjects,
// T6 介面
listJobsByUser,
computeEtag,
// T7 介面
markPromoted,
// 暴露 helper測試 + handler 用)
_internals: { buildInputObjectKey, buildRefImageObjectKey },
};
}
module.exports = {
createJobService,
STAGES,
STAGE_QUEUES,
DONE_QUEUE,
DONE_GROUP,
};

View File

@ -0,0 +1,108 @@
/**
* SSEServer-Sent Eventsclient 管理T4 重構自 server.js L131-140 + L449-487
*
* 職責
* 1. 維護 `sseClients` Mapjob_id Set<res>每個 job 可有多個 listener
* 2. `sendSSE(jobId, data)` 廣播訊息給該 job 的所有 listener
* 3. `registerSseClient(jobId, res, req)` 處理 SSE handshakeheartbeatcleanup
*
* 行為對齊重構不改行為
* - response 格式`data: ${JSON.stringify(data)}\n\n`server.js L136
* - heartbeat 15s `: heartbeat\n\n`server.js L474-476
* - 連線關閉時自動從 Map 移除最後一個 listener 離開時刪 Map entryL479-486
* - SSE headers 完全對齊 server.js L458-462
*
* 設計取捨
* - 模組層維護單一 `sseClients` Map legacy 全域變數行為一致
* - jobService setJob 會回呼 sendSSE本模組保持被動不反向 require jobService
* 避免循環依賴
*/
'use strict';
/**
* 建立一個 SSE service instance每個 process 應該只有一個
*
* @returns {{
* sendSSE: (jobId: string, data: unknown) => void,
* registerSseClient: (jobId: string, res: import('express').Response, req: import('express').Request) => void,
* _getClientsMap: () => Map<string, Set<import('express').Response>>,
* }}
*/
function createSseService() {
/** @type {Map<string, Set<import('express').Response>>} */
const sseClients = new Map();
/**
* 廣播資料給某個 job 的所有 SSE listener
*
* 對齊 server.js L133-140
*/
function sendSSE(jobId, data) {
const clients = sseClients.get(jobId);
if (!clients) return;
const payload = `data: ${JSON.stringify(data)}\n\n`;
for (const res of clients) {
res.write(payload);
}
}
/**
* 註冊一個新的 SSE listener
*
* 行為對齊 server.js L457-486
* 1. SSE headers200 + text/event-stream + no-cache + keep-alive
* 2. 立刻把目前 job 狀態送出
* 3. 加入 sseClients Map
* 4. 啟動 15s heartbeat
* 5. req.on('close') 時清理 timer + Map 移除
*
* 呼叫端應該已經把 currentJob 透過 res.write 寫出為了測試 mock 容易
* 我們把這個邏輯內部化
*
* @param {string} jobId
* @param {object} currentJob - 立即送出的初始狀態
* @param {import('express').Response} res
* @param {import('express').Request} req
*/
function registerSseClient(jobId, currentJob, res, req) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// 立即送目前狀態
res.write(`data: ${JSON.stringify(currentJob)}\n\n`);
// 加入 Map
if (!sseClients.has(jobId)) {
sseClients.set(jobId, new Set());
}
sseClients.get(jobId).add(res);
// 心跳
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
// 清理:使用 req.on('close'),與 server.js L479 行為一致
req.on('close', () => {
clearInterval(heartbeat);
const clients = sseClients.get(jobId);
if (clients) {
clients.delete(res);
if (clients.size === 0) sseClients.delete(jobId);
}
});
}
return {
sendSSE,
registerSseClient,
/** @internal 測試用 — 取得 clients map 以驗證內部狀態 */
_getClientsMap: () => sseClients,
};
}
module.exports = { createSseService };

View File

@ -0,0 +1,134 @@
/**
* 內部 job status / stage 對外 v1 API 格式映射T6
*
* 設計背景
* - 既有 Web UI 仍依賴大寫狀態`ONNX` / `BIE` / `NEF` / `COMPLETED` / `FAILED`
* 不能改既有語意向後相容
* - v1 API 對外規格TDD §1.4.3必須回小寫語意化狀態`created` / `running` /
* `completed` / `failed`+ stage 欄位
* - 因此這裡用 pure function **單向** 內部 外部的映射不雙寫 record
*
* 映射規則對齊 TDD §2.7.1
*
* | 內部 status | stage_timings.onnx.started_at | 對外 status | 對外 stage |
* |------------|------------------------------|-----------|----------|
* | `ONNX` | null剛建尚未開工 | `created` | `onnx` |
* | `ONNX` | 有值onnx 已開工 | `running` | `onnx` |
* | `BIE` | | `running` | `bie` |
* | `NEF` | | `running` | `nef` |
* | `COMPLETED`| | `completed` | `null` |
* | `FAILED` | | `failed` | <error.stage 或最後 stage>|
*
* 為什麼純 function 而非 class
* - 無狀態不需 cache
* - 容易單元測試input output
* - 容易在多個 handler 重用GET /:idGET /jobs 列表
*/
'use strict';
/**
* 對外狀態枚舉對齊 TDD §1.4.3 status 欄位
* const 物件而非 string literal 散落程式碼避免 typo
*/
const EXTERNAL_STATUS = Object.freeze({
CREATED: 'created',
RUNNING: 'running',
COMPLETED: 'completed',
FAILED: 'failed',
});
/**
* 對外 stage 枚舉
*/
const EXTERNAL_STAGE = Object.freeze({
ONNX: 'onnx',
BIE: 'bie',
NEF: 'nef',
});
/**
* 把內部 job record 映射為 `{ status, stage }`對外 API
*
* @param {object|null} job - Redis 讀出的 job record status / stage / stage_timings
* @returns {{ status: string, stage: string|null }}
*/
function toExternalStatus(job) {
if (!job || typeof job !== 'object') {
// 防禦性 fallbackrecord 異常時回 created/null會被 caller 包成 404 或 500
return { status: EXTERNAL_STATUS.CREATED, stage: null };
}
const internalStatus = typeof job.status === 'string' ? job.status : '';
switch (internalStatus) {
case 'COMPLETED':
return { status: EXTERNAL_STATUS.COMPLETED, stage: null };
case 'FAILED': {
// 失敗 stage 的決策priority
// 1. job.error.stageworker 上報的 stage
// 2. job.error.step既有 server.js advanceJob 用法)
// 3. job.stage最後一個 stage
// 4. fallback null
const fromError =
job.error && typeof job.error === 'object'
? job.error.stage || job.error.step || null
: null;
const fallback = typeof job.stage === 'string' ? job.stage : null;
return {
status: EXTERNAL_STATUS.FAILED,
stage: fromError || fallback || null,
};
}
case 'BIE':
return { status: EXTERNAL_STATUS.RUNNING, stage: EXTERNAL_STAGE.BIE };
case 'NEF':
return { status: EXTERNAL_STATUS.RUNNING, stage: EXTERNAL_STAGE.NEF };
case 'ONNX': {
// ONNX 階段細分onnx worker 開工前 = created開工後 = running
// 判斷stage_timings.onnx.started_at == null → created
const onnxTiming =
job.stage_timings && typeof job.stage_timings === 'object'
? job.stage_timings.onnx
: null;
const onnxStartedAt =
onnxTiming && typeof onnxTiming === 'object'
? onnxTiming.started_at
: null;
if (!onnxStartedAt) {
return { status: EXTERNAL_STATUS.CREATED, stage: EXTERNAL_STAGE.ONNX };
}
return { status: EXTERNAL_STATUS.RUNNING, stage: EXTERNAL_STAGE.ONNX };
}
default:
// 未知狀態 → 視為 created避免破 API contract同時 log 給 opscaller 端做)
return { status: EXTERNAL_STATUS.CREATED, stage: null };
}
}
/**
* 判斷對外 status 是否屬於進行中recovery 場景過濾用
*
* `in_progress` = `created` `running`對齊 TDD §1.4.4 query 參數
*
* @param {string} externalStatus
* @returns {boolean}
*/
function isInProgress(externalStatus) {
return (
externalStatus === EXTERNAL_STATUS.CREATED ||
externalStatus === EXTERNAL_STATUS.RUNNING
);
}
module.exports = {
toExternalStatus,
isInProgress,
EXTERNAL_STATUS,
EXTERNAL_STAGE,
};

View File

@ -0,0 +1,81 @@
/**
* Localshared volumestorage helperT4 重構自 server.js L361-379L516-523
*
* 職責
* 提供 `STORAGE_BACKEND=local` 模式下`POST /jobs` 寫檔案到 shared volume
* 以及 `GET /jobs/:id/download/:filename` volume 讀取的對應助手
*
* 行為對齊重構不改行為
* - 目錄結構`<JOB_DATA_DIR>/<jobId>/{input,input/ref_images,logs}`
* - 檔名直接用 `originalname` sanitize legacy 行為一致
* - 同步檔案 IOfs.mkdirSync / fs.writeFileSync server.js 既有用法一致
*/
'use strict';
const fs = require('fs');
const path = require('path');
/**
* JOB_DATA_DIR對齊 server.js L31
*/
function getJobDataDir() {
return process.env.JOB_DATA_DIR || '/data/jobs';
}
/**
* 建立 job 的工作目錄input / input/ref_images / logs並寫入上傳檔案
*
* server.js L361-379 行為完全一致
* - mkdir recursive
* - originalname 作為檔名 sanitizelegacy 行為
* - synchronous fs IOwriteFileSync
*
* @param {string} jobId
* @param {{ buffer: Buffer, originalname: string }} modelFile
* @param {Array<{ buffer: Buffer, originalname: string }>} [refImages]
* @param {string} [jobDataDir] - 覆寫 JOB_DATA_DIR測試用
*/
function writeJobFilesToLocal(jobId, modelFile, refImages, jobDataDir) {
const baseDir = jobDataDir || getJobDataDir();
const jobDir = path.join(baseDir, jobId);
const inputDir = path.join(jobDir, 'input');
const refImagesDir = path.join(inputDir, 'ref_images');
const logsDir = path.join(jobDir, 'logs');
fs.mkdirSync(inputDir, { recursive: true });
fs.mkdirSync(refImagesDir, { recursive: true });
fs.mkdirSync(logsDir, { recursive: true });
const modelPath = path.join(inputDir, modelFile.originalname);
fs.writeFileSync(modelPath, modelFile.buffer);
if (refImages && refImages.length > 0) {
for (const img of refImages) {
const imgPath = path.join(refImagesDir, img.originalname);
fs.writeFileSync(imgPath, img.buffer);
}
}
}
/**
* 解析 download 路徑對齊 server.js L518
*
* 注意legacy sanitize filename呼叫端直接拿 req.params.filename 拼路徑
* 本函式維持同樣行為不額外 sanitize避免行為偏移
*
* @param {string} jobId
* @param {string} filename
* @param {string} [jobDataDir]
* @returns {string}
*/
function resolveLocalDownloadPath(jobId, filename, jobDataDir) {
const baseDir = jobDataDir || getJobDataDir();
return path.join(baseDir, jobId, filename);
}
module.exports = {
getJobDataDir,
writeJobFilesToLocal,
resolveLocalDownloadPath,
};

View File

@ -0,0 +1,229 @@
/**
* MinIOS3-compatiblestorage helperT4 重構自 server.js L34-81
*
* 職責
* 1. 依照 STORAGE_BACKEND 旗標決定是否建立 S3Client
* 2. 提供 `uploadToMinIO`buffer-based 上傳 `getFromMinIO`buffer-based 下載
*
* 行為對齊重構不改行為
* - `STORAGE_BACKEND !== 'minio'` client null呼叫 helper 時直接回傳 falsy
* server.js L57: `if (!minio) return;` 完全一致
* - getFromMinIO web-stream buffer collect 邏輯 server.js L73-80 一致
* - 預設值對齊 server.js L36-40
*/
'use strict';
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
} = require('@aws-sdk/client-s3');
/**
* process.env 讀取 MinIO 設定 server.js L35-40 行為一致
*
* @returns {{
* backend: string,
* endpoint: string,
* bucket: string,
* accessKey: string,
* secretKey: string,
* region: string,
* }}
*/
function readMinioEnv() {
return {
backend: process.env.STORAGE_BACKEND || 'local',
endpoint: process.env.MINIO_ENDPOINT_URL || 'http://192.168.0.130:9000',
bucket: process.env.MINIO_BUCKET || 'convertet-working-space',
accessKey: process.env.MINIO_ACCESS_KEY || 'convuser',
secretKey: process.env.MINIO_SECRET_KEY || '',
region: process.env.MINIO_REGION || 'us-east-1',
};
}
/**
* 建立一個 MinIO storage facade
*
* `backend !== 'minio'`回傳的 facade `client` null所有 helper 都會
* 在呼叫端的 `if (minio.client)` 分支前直接回傳 nullish 結果保留既有行為
*
* @param {object} [overrides] - 覆寫 env 設定測試用
* @returns {{
* client: import('@aws-sdk/client-s3').S3Client | null,
* bucket: string,
* endpoint: string,
* uploadToMinIO: (key: string, body: Buffer | NodeJS.ReadableStream, contentType?: string) => Promise<void>,
* getFromMinIO: (key: string) => Promise<{ body: Buffer, contentLength: number | undefined } | null>,
* headObject: (key: string) => Promise<{ contentLength: number|undefined, contentType: string|undefined } | null>,
* getObjectStream: (key: string) => Promise<{ stream: any, contentLength: number|undefined, contentType: string|undefined } | null>,
* deleteObject: (key: string) => Promise<void>,
* }}
*/
function createMinioFacade(overrides) {
const env = { ...readMinioEnv(), ...(overrides || {}) };
let client = null;
if (env.backend === 'minio') {
client = new S3Client({
endpoint: env.endpoint,
region: env.region,
credentials: {
accessKeyId: env.accessKey,
secretAccessKey: env.secretKey,
},
forcePathStyle: true, // MinIO 需要 path-style
});
}
/**
* 上傳檔案到 MinIObuffer-based行為對齊 server.js L56-64
*
* client null minio backend直接 return throw
* server.js `if (!minio) return;` 完全一致
*/
async function uploadToMinIO(key, body, contentType) {
if (!client) return;
await client.send(
new PutObjectCommand({
Bucket: env.bucket,
Key: key,
Body: body,
ContentType: contentType,
})
);
}
/**
* MinIO 下載檔案到 Buffer行為對齊 server.js L66-81
*
* client null minio backend回傳 null
*
* @returns {Promise<{ body: Buffer, contentLength: number | undefined } | null>}
*/
async function getFromMinIO(key) {
if (!client) return null;
const response = await client.send(
new GetObjectCommand({
Bucket: env.bucket,
Key: key,
})
);
// AWS SDK v3 的 Body 在 Node 18 是 web stream逐 chunk 收集成 Buffer
const chunks = [];
for await (const chunk of response.Body) {
chunks.push(chunk);
}
return {
body: Buffer.concat(chunks),
contentLength: response.ContentLength,
};
}
/**
* 取得 MinIO 物件的 metadataHEAD不下載 body
*
* 用途T7 promote
* PUT FAA fetch body stream必須先知道 Content-Length
* AWS SDK GetObjectCommand 回的 ContentLength 雖可用但要先呼叫 send 才知道
* send 會啟動 stream一旦消費就無法重來HEAD 是廉價的單一 round-trip
* 先取 size + contentType 後再啟動 GetObjectCommand stream保證一次性消費
*
* client null minio backend null
*
* @param {string} key
* @returns {Promise<{ contentLength: number|undefined, contentType: string|undefined } | null>}
*/
async function headObject(key) {
if (!client) return null;
const response = await client.send(
new HeadObjectCommand({
Bucket: env.bucket,
Key: key,
})
);
return {
contentLength: response.ContentLength,
contentType: response.ContentType,
};
}
/**
* 取得 MinIO 物件的 stream + metadataT7 promote
*
* 為什麼分離 stream-based 與既有 buffer-based getFromMinIO
* - 既有 `getFromMinIO` 把整個 body 收集成 Buffer不適合 1GB 大檔OOM 風險
* - T7 promote 需要把 stream 直接 pipe fetch PUT bodyduplex: 'half'
* - 兩個 helper 並存呼叫端依用途選擇
*
* AWS SDK v3 Node 18+ GetObjectCommand response
* - response.Body Web ReadableStreamNode 18+ Node Readable舊版
* - 我們直接回原始 stream不做轉換caller Readable.toWeb 或直接傳給 fetch
*
* client null minio backend null
*
* @param {string} key
* @returns {Promise<{
* stream: NodeJS.ReadableStream | ReadableStream,
* contentLength: number|undefined,
* contentType: string|undefined,
* } | null>}
*/
async function getObjectStream(key) {
if (!client) return null;
const response = await client.send(
new GetObjectCommand({
Bucket: env.bucket,
Key: key,
})
);
return {
stream: response.Body,
contentLength: response.ContentLength,
contentType: response.ContentType,
};
}
/**
* 刪除 MinIO 物件T5M5 方案 A 衝突清檔用
*
* 行為說明
* - client null minio backend靜默 skip throw
* - S3 SDK key 不存在**不會** throwDeleteObject 是冪等的
* 所以本函式不需處理 NoSuchKey
* - 其他錯誤網路 / 權限 throw呼叫端可視情況 log 或忽略
*
* 注意呼叫端應在 fire-and-forget 模式下使用不影響主流程 response
*
* @param {string} key
*/
async function deleteObject(key) {
if (!client) return;
await client.send(
new DeleteObjectCommand({
Bucket: env.bucket,
Key: key,
})
);
}
return {
client,
bucket: env.bucket,
endpoint: env.endpoint,
uploadToMinIO,
getFromMinIO,
headObject,
getObjectStream,
deleteObject,
};
}
module.exports = {
createMinioFacade,
// 暴露給測試
_internals: { readMinioEnv },
};

View File

@ -0,0 +1,194 @@
/**
* sanitize utils 單元測試T5
*
* 重點
* 1. sanitizeFilename 各類惡意輸入path traversal / NUL / 控制字元 / 超長
* 2. validateUserId 邊界值
* 3. validateTargetObjectKey T7 T5 一起測完整
*/
'use strict';
const {
sanitizeFilename,
getExtension,
validateUserId,
validateTargetObjectKey,
} = require('../sanitize');
describe('sanitizeFilename', () => {
it('returns "file" for non-string input', () => {
expect(sanitizeFilename(undefined)).toBe('file');
expect(sanitizeFilename(null)).toBe('file');
expect(sanitizeFilename(123)).toBe('file');
});
it('keeps simple filename intact', () => {
expect(sanitizeFilename('model.onnx')).toBe('model.onnx');
expect(sanitizeFilename('weights_v2.tflite')).toBe('weights_v2.tflite');
});
it('strips path traversal segments', () => {
expect(sanitizeFilename('../etc/passwd')).toBe('passwd');
expect(sanitizeFilename('../../etc/passwd')).toBe('passwd');
expect(sanitizeFilename('foo/bar/baz.onnx')).toBe('baz.onnx');
expect(sanitizeFilename('C:\\Windows\\System32\\evil.dll')).toBe('evil.dll');
});
it('strips NUL byte and everything after', () => {
// path.basename 會先去掉 path 部分,所以 evil.bin 是 base'\0evilappend.txt' 會被截
expect(sanitizeFilename('evil.bin\0.png')).toBe('evil.bin');
});
it('replaces control chars with underscore', () => {
expect(sanitizeFilename('weird\x07name.bin')).toBe('weird_name.bin');
expect(sanitizeFilename('cr\rlf\nname.txt')).toBe('cr_lf_name.txt');
});
it('replaces non-allowed chars with underscore', () => {
expect(sanitizeFilename('name with spaces.bin')).toBe('name_with_spaces.bin');
expect(sanitizeFilename('name;injection.bin')).toBe('name_injection.bin');
expect(sanitizeFilename('semi:colon.bin')).toBe('semi_colon.bin');
});
it('removes leading dots (avoid hidden files)', () => {
expect(sanitizeFilename('.htaccess')).toBe('htaccess');
expect(sanitizeFilename('..hidden.bin')).toBe('hidden.bin');
});
it('returns "file" for empty / dot-only inputs', () => {
expect(sanitizeFilename('')).toBe('file');
expect(sanitizeFilename('.')).toBe('file');
expect(sanitizeFilename('..')).toBe('file');
expect(sanitizeFilename(' ')).toBe('file'); // trim 後變空
});
it('truncates names longer than 200 chars while preserving extension', () => {
const longBase = 'a'.repeat(300);
const result = sanitizeFilename(`${longBase}.onnx`);
expect(result.length).toBeLessThanOrEqual(200);
expect(result.endsWith('.onnx')).toBe(true);
});
it('truncates very long names without extension to 200 chars', () => {
const long = 'b'.repeat(300);
expect(sanitizeFilename(long).length).toBe(200);
});
});
describe('getExtension', () => {
it('returns lowercase extension with dot', () => {
expect(getExtension('model.ONNX')).toBe('.onnx');
expect(getExtension('weights.TFLite')).toBe('.tflite');
});
it('returns empty string for files without extension', () => {
expect(getExtension('weights')).toBe('');
expect(getExtension('weights.')).toBe('');
expect(getExtension('.hidden')).toBe(''); // dot at start is not ext
});
it('returns last extension only', () => {
expect(getExtension('archive.tar.gz')).toBe('.gz');
});
});
describe('validateUserId (Sec M1 white-list)', () => {
it('accepts valid alnum + dash + dot + underscore', () => {
expect(validateUserId('user-123')).toBe('user-123');
expect(validateUserId('visionA-user-12345')).toBe('visionA-user-12345');
expect(validateUserId('user.name')).toBe('user.name');
expect(validateUserId('user_name')).toBe('user_name');
expect(validateUserId('A1b2C3')).toBe('A1b2C3');
});
it('rejects empty / oversize', () => {
expect(validateUserId('')).toBeNull();
expect(validateUserId('a'.repeat(129))).toBeNull();
});
it('rejects path traversal chars', () => {
expect(validateUserId('../etc')).toBeNull();
expect(validateUserId('user..name')).toBeNull();
expect(validateUserId('foo/bar')).toBeNull();
expect(validateUserId('foo\\bar')).toBeNull();
});
it('rejects colon (Redis key injection)', () => {
expect(validateUserId('user:active_job')).toBeNull();
});
it('rejects control chars / NUL', () => {
expect(validateUserId('user\x00admin')).toBeNull();
expect(validateUserId('user\nadmin')).toBeNull();
expect(validateUserId('user\tadmin')).toBeNull();
});
it('rejects leading / trailing whitespace', () => {
expect(validateUserId(' user')).toBeNull();
expect(validateUserId('user ')).toBeNull();
expect(validateUserId('user name')).toBeNull(); // inner space
});
// Sec M1新增白名單測試黑名單模式漏掉的攻擊向量
it('rejects XSS payloads', () => {
expect(validateUserId('<script>alert(1)</script>')).toBeNull();
expect(validateUserId('<img src=x onerror=alert(1)>')).toBeNull();
expect(validateUserId('user<script>')).toBeNull();
expect(validateUserId('javascript:alert(1)')).toBeNull(); // also has `:`
});
it('rejects glob / wildcard chars (Sec M1)', () => {
expect(validateUserId('*')).toBeNull();
expect(validateUserId('user*')).toBeNull();
expect(validateUserId('user?')).toBeNull();
expect(validateUserId('user[abc]')).toBeNull();
});
it('rejects shell metachars (Sec M1)', () => {
expect(validateUserId('user;rm -rf /')).toBeNull();
expect(validateUserId('user&whoami')).toBeNull();
expect(validateUserId('user|cat')).toBeNull();
expect(validateUserId('user$(id)')).toBeNull();
expect(validateUserId('user`id`')).toBeNull();
});
it('rejects CRLF (log injection)', () => {
expect(validateUserId('user\r\nadmin')).toBeNull();
expect(validateUserId('user\rOK 200')).toBeNull();
});
it('rejects unicode (whitelist mode)', () => {
expect(validateUserId('user名')).toBeNull(); // CJK
expect(validateUserId('user')).toBeNull(); // RTL override
expect(validateUserId('user🚀')).toBeNull(); // emoji
expect(validateUserId('useré')).toBeNull(); // é
});
it('rejects non-string input', () => {
expect(validateUserId(undefined)).toBeNull();
expect(validateUserId(null)).toBeNull();
expect(validateUserId(123)).toBeNull();
});
});
describe('validateTargetObjectKey', () => {
it('accepts simple paths', () => {
expect(validateTargetObjectKey('visionA/models/u/m/v/out.nef')).toBe(
'visionA/models/u/m/v/out.nef'
);
});
it('rejects "..", absolute paths, backslash, control chars', () => {
expect(validateTargetObjectKey('..')).toBeNull();
expect(validateTargetObjectKey('a/../b')).toBeNull();
expect(validateTargetObjectKey('/absolute/path')).toBeNull();
expect(validateTargetObjectKey('a\\b')).toBeNull();
expect(validateTargetObjectKey('a\x00b')).toBeNull();
});
it('rejects empty / oversize', () => {
expect(validateTargetObjectKey('')).toBeNull();
expect(validateTargetObjectKey('a'.repeat(1025))).toBeNull();
});
});

View File

@ -0,0 +1,168 @@
/**
* 字串 sanitization 工具T5
*
* 主要使用情境
* 1. POST /api/v1/jobs 收到 multipart `model.originalname` / `ref_images[i].originalname`
* 要組成 MinIO object key`jobs/{job_id}/input/{filename}`攻擊者可能送
* `../../../etc/passwd` 或包含 NUL byte / 反斜線 / 控制字元的檔名
* 2. POST /api/v1/jobs 收到 multipart field `user_id`會用在 Redis key
* `user:{user_id}:jobs`需要保證沒有冒號空白換行等可能干擾 Redis
* key parsing log injection 的字元
*
* 目標
* * 防止 path traversal`..`絕對路徑
* * 防止 NUL byte 截斷攻擊`evil.bin\0.txt`
* * 防止 log / Redis key injection換行CRTab
* * 保留人類可讀的部分盡量保留原副檔名與檔名主體便於 user 對照
*
* 不負責
* * 防止合法字元但語意敏感的內容例如 `Authorization` 本身是合法字串
* * 跨檔案重名問題呼叫端已用 jobs/{job_id}/ 前綴隔離
*/
'use strict';
const path = require('path');
/**
* multipart 提供的原始檔名 sanitize 只含字母 / 數字 / `.` / `_` / `-`
* 並保留最後一個副檔名
*
* 處理邏輯
* 1. NUL byte (`\0`) 截掉後段 null-byte injection攻擊例`evil.bin\0.png`
* 顯式用 `'\0'` 字面值之前曾誤植成空格 split難以肉眼辨識Sec M6 修正
* 2. `path.posix.basename` 去掉所有目錄前綴同時處理 `/` `\\`
* 為什麼用 `posix` 而非 `path.basename`避免 Windows dev/CI 下行為不一致
* 3. 移除前後空白與控制字元
* 4. 把不在白名單的字元換成 `_`
* 5. 收尾去掉開頭 `.` 與超長>200的部分
* 6. 若結果為空字串 'file'保證一定有合法檔名
*
* 注意本函式****強制副檔名白名單那由 validator 處理
*
* @param {unknown} raw
* @returns {string}
*/
function sanitizeFilename(raw) {
if (typeof raw !== 'string') {
return 'file';
}
// 1) 截掉 NUL byte 後的所有內容(防 null-byte truncation 攻擊:`evil.bin\0.png`
// 顯式用 `'\0'` 字面值(之前曾誤植空格 splitreadability 修正Sec M6
let cleaned = raw.split('\0')[0];
// 2) 用 path.posix.basename 去掉所有目錄前綴跨平台一致Sec m6 修正)
cleaned = path.posix.basename(cleaned.replace(/\\/g, '/'));
// 移除前後空白、控制字元
cleaned = cleaned.replace(/[\x00-\x1f\x7f]/g, '_').trim();
// 把所有非白名單字元(保留 alnum / `.` / `_` / `-`)換成 `_`
cleaned = cleaned.replace(/[^A-Za-z0-9._-]/g, '_');
// 移除開頭的 `.`(避免 `.htaccess` 或 `..` 殘留)
cleaned = cleaned.replace(/^\.+/, '');
// 截長:保留 base + ext 最多 200 字元,避免 object key 過長
if (cleaned.length > 200) {
const dot = cleaned.lastIndexOf('.');
if (dot > 0 && dot >= cleaned.length - 16) {
// ext 不長:保留 ext截 base
const ext = cleaned.slice(dot);
cleaned = cleaned.slice(0, 200 - ext.length) + ext;
} else {
cleaned = cleaned.slice(0, 200);
}
}
if (cleaned === '' || cleaned === '.' || cleaned === '..') {
return 'file';
}
return cleaned;
}
/**
* 取出 sanitized filename 的副檔名小寫 `.`無副檔名則回空字串
*
* @param {string} filename - 已經 sanitize 過的檔名
* @returns {string}
*/
function getExtension(filename) {
if (typeof filename !== 'string') return '';
const dot = filename.lastIndexOf('.');
if (dot <= 0 || dot === filename.length - 1) return '';
return filename.slice(dot).toLowerCase();
}
/**
* 嚴格白名單 regex 只允許字母 / 數字 / `.` / `_` / `-`
*
* 為什麼用白名單而非黑名單Sec M1 + m2 修正
* - 黑名單模式之前的版本逐項檢查 `/``\``..``:`、控制字元)會漏掉很多
* 攻擊向量`<script>...</script>``*`萬用字元空白unicode
* RTL overridehomograph
* - 白名單模式只保留明確安全的字元所有未列入的字元一律拒絕深度防禦
* - user_id 會被用於
* 1. Redis key`user:{userId}:active_job` / `user:{userId}:jobs`
* 2. structured log 欄位`user_id`
* 3. 對外 API response `user_id` 欄位client 可能會 echo 顯示
* 任一場景出現非預期字元都可能導致攻擊Redis key injection / log injection /
* XSS in admin UI
*/
const USER_ID_WHITELIST = /^[A-Za-z0-9._-]+$/;
/**
* 驗證 user_id 是否符合 TDD §1.4.2 的限制 + Sec M1 強化白名單
*
* 接受的字元`A-Z` / `a-z` / `0-9` / `.` / `_` / `-`
* 長度1-128 字元
*
* 拒絕的範例
* - `/``\``:` Redis key injection / path traversal
* - `..` path traversal
* - `<` `>` `;` `&` `|` `$` `*` `?` XSS / shell injection / glob pattern
* - 含空白`' '``\t``\n``\r`log injection / 對齊干擾
* - 含控制字元 / NUL byte
* - unicode除非與 ASCII alnum / `.` / `_` / `-` 等價
*
* @param {unknown} raw
* @returns {string|null} - 合法時回原值不合法回 null
*/
function validateUserId(raw) {
if (typeof raw !== 'string') return null;
if (raw.length < 1 || raw.length > 128) return null;
// 嚴格白名單檢查(單一 regex 取代之前多項黑名單檢查)
if (!USER_ID_WHITELIST.test(raw)) return null;
// 額外深度防禦:拒絕連續兩個 `.`(白名單字元中唯一可能形成 path-like 攻擊向量)
// 例:`user..name` 字面上通過白名單,但語意上仍像 path traversal明確拒絕。
if (raw.includes('..')) return null;
return raw;
}
/**
* 驗證 promote 用的 target_object_keyTDD §1.4.5
* - 不能含 `..``\\`
* - 不能空不能超過 1024 字元
*
* 留給 T7 promote T5 雖未呼叫但放在這邊集中管理
*
* @param {unknown} raw
* @returns {string|null}
*/
function validateTargetObjectKey(raw) {
if (typeof raw !== 'string') return null;
if (raw.length === 0 || raw.length > 1024) return null;
if (raw.includes('..') || raw.includes('\\')) return null;
if (/[\x00-\x1f\x7f]/.test(raw)) return null;
// 不能 leading `/` (絕對路徑)— File Access Agent 端應接相對 key
if (raw.startsWith('/')) return null;
return raw;
}
module.exports = {
sanitizeFilename,
getExtension,
validateUserId,
validateTargetObjectKey,
};

View File

@ -36,6 +36,10 @@ services:
restart: unless-stopped
# ---------- Scheduler ----------
#
# T10Phase 1 env 透傳清單。所有值都用 ${VAR} 從 .env / shell 讀取,
# 不在 docker-compose.yml hardcode避免 secret 被 commit
# 必填變數缺漏 → scheduler container 會啟動失敗fail-fast
scheduler:
build: ./apps/task-scheduler
@ -47,10 +51,19 @@ services:
volumes:
- job-data:/data/jobs
environment:
# === 應用基本 ===
- PORT=4000
- NODE_ENV=${NODE_ENV:-development}
- LOG_LEVEL=${LOG_LEVEL:-info}
# === Redis ===
- REDIS_URL=redis://redis:6379
# === Job 資料目錄 / CORS ===
- JOB_DATA_DIR=/data/jobs
- FRONTEND_URL=http://localhost:9500
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:9500}
# === Storage backend ===
- STORAGE_BACKEND=${STORAGE_BACKEND:-local}
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL:-http://192.168.0.130:9000}
- MINIO_BUCKET=${MINIO_BUCKET:-convertet-working-space}
@ -58,6 +71,46 @@ services:
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_LIFECYCLE_DAYS=${MINIO_LIFECYCLE_DAYS:-7}
# === OAuth / Member Center必填缺漏 fail-fast===
- MEMBER_CENTER_ISSUER=${MEMBER_CENTER_ISSUER}
- MEMBER_CENTER_JWKS_URL=${MEMBER_CENTER_JWKS_URL}
- MEMBER_CENTER_TOKEN_URL=${MEMBER_CENTER_TOKEN_URL}
# === Converter 身份(必填)===
- KNERON_CONVERTER_AUDIENCE=${KNERON_CONVERTER_AUDIENCE}
- KNERON_CONVERTER_CLIENT_ID=${KNERON_CONVERTER_CLIENT_ID}
- KNERON_CONVERTER_CLIENT_SECRET=${KNERON_CONVERTER_CLIENT_SECRET}
- CONVERTER_TENANT_ID=${CONVERTER_TENANT_ID:-}
# === File Access Agent必填===
- FILE_ACCESS_AGENT_BASE_URL=${FILE_ACCESS_AGENT_BASE_URL}
- FILE_ACCESS_AGENT_AUDIENCE=${FILE_ACCESS_AGENT_AUDIENCE}
# === Scope可選預設 TDD §8===
- CONVERTER_SCOPE_WRITE=${CONVERTER_SCOPE_WRITE:-converter:job.write}
- CONVERTER_SCOPE_READ=${CONVERTER_SCOPE_READ:-converter:job.read}
# === JWKS / JWT cache 行為(可選)===
- JWKS_CACHE_MAX_AGE_MS=${JWKS_CACHE_MAX_AGE_MS:-600000}
- JWKS_COOLDOWN_MS=${JWKS_COOLDOWN_MS:-30000}
- JWT_CLOCK_TOLERANCE_SEC=${JWT_CLOCK_TOLERANCE_SEC:-60}
# === OAuth Client cache可選===
- OAUTH_TOKEN_REFRESH_SKEW_MS=${OAUTH_TOKEN_REFRESH_SKEW_MS:-60000}
- OAUTH_TOKEN_TIMEOUT_MS=${OAUTH_TOKEN_TIMEOUT_MS:-10000}
# === Promote 行為(可選)===
- PROMOTE_TIMEOUT_MS=${PROMOTE_TIMEOUT_MS:-300000}
# === Multipart 上限T10 修 D5===
- MULTIPART_MODEL_MAX_BYTES=${MULTIPART_MODEL_MAX_BYTES:-524288000}
- MULTIPART_REF_IMAGE_MAX_BYTES=${MULTIPART_REF_IMAGE_MAX_BYTES:-10485760}
- MULTIPART_REF_IMAGES_MAX_COUNT=${MULTIPART_REF_IMAGES_MAX_COUNT:-100}
# === Upload concurrencyT10 修 D5===
- MAX_CONCURRENT_UPLOADS=${MAX_CONCURRENT_UPLOADS:-5}
- UPLOAD_RETRY_AFTER_SECONDS=${UPLOAD_RETRY_AFTER_SECONDS:-30}
restart: unless-stopped
# ---------- Workers (stub mode) ----------