對齊 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>
154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
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 T4:DownloadRedirectURL 改 DownloadStream(API 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 scope(T1 review M1):t.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 寫 type;backend 改 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.Reader(compile time 透過 type assertion)
|
||
var _ io.Reader = in.Body
|
||
assert.Equal(t, "user-abc", in.UserID)
|
||
}
|