jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

154 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package conversion
import (
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// noopService 是一個 compile-time 驗證 — 用來確認 Service interface 的方法集合穩定。
// 真實實作Flow會在 T6 補。這裡只測 interface 簽名沒有打錯(避免 T6 才發現要改 interface
type noopService struct{}
func (noopService) InitJob(ctx context.Context, in InitJobInput) (*Job, error) {
return nil, nil
}
func (noopService) GetJob(ctx context.Context, userID, jobID string) (*Job, error) {
return nil, nil
}
func (noopService) PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error) {
return nil, nil
}
// Phase 0.8b T4DownloadRedirectURL 改 DownloadStreamAPI key 模式下沒有 delegated token
// 改 server-side stream proxy對應 ADR-015 §7 / conversion.md §4.1
func (noopService) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, nil
}
func (noopService) ActiveJob(ctx context.Context, userID string) (*Job, error) {
return nil, nil
}
// File-scope compile-time check — 若 Service interface 改變,
// noopService 就不再實作此 interface編譯失敗。
// 移到 file scopeT1 review M1t.Run 內的 var declaration 只在執行該 test 時驗,
// 而我們希望「package 編譯成功」就保證 interface 穩定。
var _ Service = noopService{}
// TestService_InterfaceSatisfied 在 test 中再 assert 一次,作為文件性說明。
func TestService_InterfaceSatisfied(t *testing.T) {
t.Parallel()
var _ Service = noopService{}
}
// TestJob_JSONShape 驗證 Job struct 的 JSON tag 與 api-conversion.md §1-2 response 對齊。
//
// 這是契約測試frontend 依 api-conversion.md 寫 typebackend 改 json tag 一定要回頭看這個 test。
func TestJob_JSONShape(t *testing.T) {
t.Parallel()
createdAt, _ := time.Parse(time.RFC3339, "2026-04-30T12:00:00Z")
expiresAt := createdAt.Add(7 * 24 * time.Hour)
job := Job{
JobID: "550e8400-e29b-41d4-a716-446655440000",
Status: "running",
Stage: "bie",
Progress: 45,
StageProgress: 60,
CreatedAt: createdAt,
UpdatedAt: createdAt.Add(5 * time.Minute),
ExpiresAt: expiresAt,
SourceFilename: "yolov5s.onnx",
TargetChip: "720",
}
raw, err := json.Marshal(job)
require.NoError(t, err)
// 必要欄位都在
assert.Contains(t, string(raw), `"job_id":"550e8400-e29b-41d4-a716-446655440000"`)
assert.Contains(t, string(raw), `"status":"running"`)
assert.Contains(t, string(raw), `"stage":"bie"`)
assert.Contains(t, string(raw), `"progress":45`)
assert.Contains(t, string(raw), `"stage_progress":60`)
assert.Contains(t, string(raw), `"created_at":"2026-04-30T12:00:00Z"`)
assert.Contains(t, string(raw), `"expires_at":"2026-05-07T12:00:00Z"`)
assert.Contains(t, string(raw), `"source_filename":"yolov5s.onnx"`)
assert.Contains(t, string(raw), `"target_chip":"720"`)
// error 欄位 zero value 時應被 omitempty 隱藏
assert.NotContains(t, string(raw), `"error_code"`)
assert.NotContains(t, string(raw), `"error_message"`)
}
// TestJob_FailedShape 驗證 failed job 的 error 欄位序列化。
func TestJob_FailedShape(t *testing.T) {
t.Parallel()
job := Job{
JobID: "job-failed",
Status: "failed",
ErrorCode: "QUANTIZATION_FAILED",
ErrorMessage: "model has unsupported operator",
}
raw, err := json.Marshal(job)
require.NoError(t, err)
assert.Contains(t, string(raw), `"error_code":"QUANTIZATION_FAILED"`)
assert.Contains(t, string(raw), `"error_message":"model has unsupported operator"`)
}
// TestPromoteResult_JSONShape 對齊 api-conversion.md §3 response。
func TestPromoteResult_JSONShape(t *testing.T) {
t.Parallel()
createdAt, _ := time.Parse(time.RFC3339, "2026-04-30T12:30:00Z")
pr := PromoteResult{
ModelID: "abc-123",
Source: "converted",
SourceJobID: "550e8400-...",
Name: "YOLOv5 Face KL520",
TargetChip: "kl520",
FileSize: 12345678,
Status: "ready",
CreatedAt: createdAt,
}
raw, err := json.Marshal(pr)
require.NoError(t, err)
assert.Contains(t, string(raw), `"model_id":"abc-123"`)
assert.Contains(t, string(raw), `"source":"converted"`)
assert.Contains(t, string(raw), `"source_job_id":"550e8400-..."`)
assert.Contains(t, string(raw), `"file_size":12345678`)
assert.Contains(t, string(raw), `"status":"ready"`)
assert.Contains(t, string(raw), `"target_chip":"kl520"`)
}
// TestInitJobInput_AcceptsReader 驗證 InitJobInput.Body 接受 io.Reader即 streaming 不收 buffer
//
// 關鍵:若有人不小心把欄位改成 []byte這個測試編譯會壞。
func TestInitJobInput_AcceptsReader(t *testing.T) {
t.Parallel()
in := InitJobInput{
UserID: "user-abc",
ContentType: "multipart/form-data; boundary=xyz",
Body: strings.NewReader("--xyz--"),
ContentLength: 7,
}
// 確認 Body 是 io.Readercompile time 透過 type assertion
var _ io.Reader = in.Body
assert.Equal(t, "user-abc", in.UserID)
}