對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。 砍除: - internal/conversion/faa_client.go(整檔) - internal/conversion/faa_client_test.go(整檔) - errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping) - flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc - flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa - internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed - cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field 保留: - ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要) - hashObjectKey helper 搬到 util.go(ownership 仍用) - e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護 新增(T3 必補,T1/T2 reviewer 累積): - s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳) - s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護) - s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted) 發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename T3 review M-1 修補(commit 內含): - internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」 - 對齊 ADR-016 visionA 端不再有 FAA 直連設計 驗證: - B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩): * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0 * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項 - go build ./... exit 0 - go test -race -count=3 ./... 17 packages 全綠 - Reviewer 5 軸(v0.6-t3-review)⚠️→ ✅ 通過(M-1 已修) a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。 下一步: - T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併 - T5 main.go startup log 整理 + e2e regression 防護 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1369 lines
51 KiB
Go
1369 lines
51 KiB
Go
// Converter Client 單元測試。
|
||
//
|
||
// 測試策略:
|
||
// - 用 httptest.Server mock task-scheduler 的 4 個 endpoint
|
||
// - **Phase 0.8b**:直接用 string fake API key(不再注入 stub MCTokenClient)— 與 ADR-015
|
||
// pre-shared key 模式一致;驗 server 端確實收到 `Authorization: Bearer <fakeAPIKey>`
|
||
// - 用 atomic counter 驗 retry 行為(attempts 數對齊 conversion.md §9.1)
|
||
// - 大 body streaming 用 io.LimitReader(不真的寫 100MB 進 RAM)
|
||
//
|
||
// 對應 task 規範必含 case:
|
||
// - InitJob:Success / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthFailed401 / AuthFailed403
|
||
// - GetJob:Success / NotFound / 5xx_RetryThenSuccess / AuthFailed401_NoRetry
|
||
// - Promote:Success / BadGateway / AuthFailed401_NoRetry
|
||
// - List:Success / Empty / 5xxRetry / AuthFailed401_NoRetry
|
||
// - Constructor:Panics_When_APIKey_Empty
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1)
|
||
// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3)
|
||
package conversion
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// Phase 0.8b:fake API key fixtures
|
||
// ==========================================================================
|
||
//
|
||
// 取明顯 fake 字串、含 `do-not-use-in-prod` marker(grepable,避免被誤當真 key)。
|
||
// 長度 64 hex chars 對齊 ADR-015 §3.4 production key 規格(`openssl rand -hex 32`)— 即使
|
||
// 未來加 length validation 也不會 break 這個 fixture。
|
||
const (
|
||
fakeConverterAPIKey = "fake-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaaaaaaaa"
|
||
fakeFAAAPIKey = "fake-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// converter mock server helpers
|
||
// ==========================================================================
|
||
|
||
// newConverterClientForTest 建立指向 mock server 的 ConverterClient。
|
||
//
|
||
// Phase 0.8b:直接傳 fakeConverterAPIKey;不再需要 MCTokenClient 注入。
|
||
//
|
||
// 使用較短的 init/http timeout 加速 test;retry 退避保持原本(converterRetryBackoff 0.5s 起跳
|
||
// 對 retry test 有點久但仍可接受 — 5xx retry test 的 max 2 retries = 0.5s + 1s = 1.5s)。
|
||
func newConverterClientForTest(t *testing.T, baseURL string) ConverterClient {
|
||
t.Helper()
|
||
return NewConverterClient(ConverterClientOpts{
|
||
BaseURL: baseURL,
|
||
APIKey: fakeConverterAPIKey,
|
||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||
InitHTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// InitJob tests
|
||
// ==========================================================================
|
||
|
||
// TestInitJob_Success:mock 接受 multipart,回 201 + job spec。
|
||
//
|
||
// Phase 0.8b:驗 server 端確實收到 `Authorization: Bearer <fakeConverterAPIKey>`(pre-shared
|
||
// API key 直接 set header;不再透過 MCTokenClient 取 token)。
|
||
func TestInitJob_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var serverContentType string
|
||
var serverAuth string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodPost, r.Method)
|
||
serverAuth = r.Header.Get("Authorization")
|
||
serverContentType = r.Header.Get("Content-Type")
|
||
|
||
// drain body 確認 streaming 完成
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"status": "created",
|
||
"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",
|
||
"user_id": "alice"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
job, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
UserID: "alice",
|
||
Platform: "520",
|
||
SourceFilename: "model.onnx",
|
||
Body: strings.NewReader("--xyz\r\nContent-Disposition: form-data; name=\"user_id\"\r\n\r\nalice\r\n--xyz--\r\n"),
|
||
BodyContentType: "multipart/form-data; boundary=xyz",
|
||
})
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", job.JobID)
|
||
assert.Equal(t, "created", job.Status)
|
||
assert.Equal(t, "onnx", job.Stage)
|
||
assert.Equal(t, "multipart/form-data; boundary=xyz", serverContentType,
|
||
"InitJob 必須完整透傳 Content-Type 含 boundary(converter multer 解析依賴此)")
|
||
assert.Equal(t, "Bearer "+fakeConverterAPIKey, serverAuth,
|
||
"Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)")
|
||
}
|
||
|
||
// TestInitJob_StreamingBody:driver 寫 100MB 假資料給 io.Reader,confirm streaming(不全 buffer RAM)。
|
||
//
|
||
// 用 io.LimitReader 包一個無限 reader,server side 也用 io.Discard 不存。
|
||
// 觀察:peakReadBytes 不應接近 100MB(確認 net/http 真的是 streaming)— 但 peak 偵測在 Go 層級不易,
|
||
// 改驗:reader 的 ReadCalls 數應遠大於 1(如果 buffer 全進 RAM,net/http 會一次全讀)。
|
||
func TestInitJob_StreamingBody(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var serverBytesRead int64
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
// 不一次 ReadAll;用 Copy 到 io.Discard 強制 streaming
|
||
n, _ := io.Copy(io.Discard, r.Body)
|
||
atomic.AddInt64(&serverBytesRead, n)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "stream-test", "status": "created", "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"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
const totalSize = int64(10 * 1024 * 1024) // 10MB(測試成本與 streaming 驗證的平衡)
|
||
reader := &countingReader{
|
||
R: io.LimitReader(zerosReader{}, totalSize),
|
||
}
|
||
|
||
// 對 streaming test 加長 timeout
|
||
cc := NewConverterClient(ConverterClientOpts{
|
||
BaseURL: srv.URL,
|
||
APIKey: fakeConverterAPIKey,
|
||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||
InitHTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
|
||
job, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
UserID: "alice",
|
||
Body: reader,
|
||
BodyContentType: "multipart/form-data; boundary=stream",
|
||
})
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "stream-test", job.JobID)
|
||
assert.Equal(t, totalSize, atomic.LoadInt64(&serverBytesRead),
|
||
"server 應該收到完整 body(streaming proxy 不掉資料)")
|
||
|
||
// streaming 證據:reader 應被多次呼叫 Read(如果是 buffer 全 RAM 模式,會一次大讀)
|
||
calls := atomic.LoadInt64(&reader.calls)
|
||
assert.Greater(t, calls, int64(1), "streaming 必須多次 Read(不能一次性 buffer 全 RAM)")
|
||
}
|
||
|
||
// TestInitJob_ContentTypeHeader:multipart boundary 必須完整透傳。
|
||
func TestInitJob_ContentTypeHeader(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var receivedCT string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
receivedCT = r.Header.Get("Content-Type")
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "ct-test", "status": "created", "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"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
const customCT = "multipart/form-data; boundary=---xxx-very-specific-boundary-yyy---"
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("body content"),
|
||
BodyContentType: customCT,
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Equal(t, customCT, receivedCT, "boundary 必須一字不差透傳(含特殊字元)")
|
||
}
|
||
|
||
// TestInitJob_Conflict409_ActiveJobError:mock 回 409 user_has_active_job → return *ActiveJobError。
|
||
func TestInitJob_Conflict409_ActiveJobError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{
|
||
"error": {
|
||
"code": "user_has_active_job",
|
||
"message": "使用者目前已有進行中的轉檔任務",
|
||
"details": {
|
||
"active_job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"active_job_status": "running",
|
||
"active_job_stage": "bie",
|
||
"active_job_progress": 45,
|
||
"active_job_created_at": "2026-04-25T12:00:00Z"
|
||
},
|
||
"request_id": "req-123"
|
||
}
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrActiveJobExists), "必須能透過 errors.Is 比對 sentinel")
|
||
|
||
var ae *ActiveJobError
|
||
require.True(t, errors.As(err, &ae), "必須能透過 errors.As 取出 ActiveJobError struct")
|
||
require.NotNil(t, ae.Job)
|
||
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", ae.Job.JobID)
|
||
assert.Equal(t, "running", ae.Job.Status)
|
||
assert.Equal(t, "bie", ae.Job.Stage)
|
||
assert.Equal(t, 45, ae.Job.Progress)
|
||
}
|
||
|
||
// TestInitJob_Validation400:mock 回 400 + fields → return *ConverterValidationError,
|
||
// fields 對齊 openapi.yaml shape([]ValidationFieldError)。
|
||
func TestInitJob_Validation400(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = w.Write([]byte(`{
|
||
"error": {
|
||
"code": "validation_error",
|
||
"message": "欄位驗證失敗",
|
||
"details": {
|
||
"fields": [
|
||
{"field": "model_id", "message": "model_id 範圍必須在 1 ~ 65535"},
|
||
{"field": "platform", "message": "platform 必須是 520 / 720 / 530 / 630 / 730"}
|
||
]
|
||
},
|
||
"request_id": "req-validation"
|
||
}
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrValidationFailed))
|
||
|
||
var ve *ConverterValidationError
|
||
require.True(t, errors.As(err, &ve))
|
||
require.Len(t, ve.Fields, 2, "fields 必須對齊 converter openapi.yaml 的 array shape")
|
||
assert.Equal(t, "model_id", ve.Fields[0].Field)
|
||
assert.Equal(t, "model_id 範圍必須在 1 ~ 65535", ve.Fields[0].Message)
|
||
assert.Equal(t, "platform", ve.Fields[1].Field)
|
||
assert.Contains(t, ve.Message, "驗證失敗", "Message 應透傳 converter 原文供 log 用")
|
||
}
|
||
|
||
// TestInitJob_5xx_NoRetry:mock 連續 500 → InitJob 不 retry,立即 return。
|
||
//
|
||
// 設計理由:multipart body 是 streaming(io.Reader 一次性),retry 會傳到一半的爛資料。
|
||
func TestInitJob_5xx_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
counter.Add(1)
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"misconfiguration","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterUnavailable))
|
||
assert.Equal(t, int32(1), counter.Load(),
|
||
"InitJob 不可 retry 5xx(streaming body 不可 replay)")
|
||
}
|
||
|
||
// TestInitJob_AuthFailed401:mock 回 401 → ErrConverterAuthFailed(Phase 0.8b 新 sentinel;
|
||
// 對外 mask 成 converter_unavailable / 502,避免洩漏「API key 不對齊」內部運維狀態)。
|
||
func TestInitJob_AuthFailed401(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"Phase 0.8b:401 必須 mapping 到新 sentinel ErrConverterAuthFailed")
|
||
// Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除,
|
||
// 改由 ErrConverterAuthFailed 接管 401/403 mapping。
|
||
assert.Equal(t, int32(1), attempts.Load(),
|
||
"401 不應 retry(API key 不對 retry 也是 401)")
|
||
// 對外 ErrorCode mask 成 converter_unavailable(不洩漏「API key 不對」)
|
||
assert.Equal(t, "converter_unavailable", ErrorCode(err))
|
||
assert.Equal(t, 502, HTTPStatus(err))
|
||
}
|
||
|
||
// TestInitJob_AuthFailed403:對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry。
|
||
func TestInitJob_AuthFailed403(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.WriteHeader(http.StatusForbidden)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed))
|
||
assert.Equal(t, int32(1), attempts.Load())
|
||
}
|
||
|
||
// TestInitJob_RequiredFieldsValidation:本地參數驗證(不打網路)。
|
||
func TestInitJob_RequiredFieldsValidation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cc := newConverterClientForTest(t, "http://unused")
|
||
|
||
// 缺 body
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
BodyContentType: "multipart/form-data; boundary=x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "body is required")
|
||
|
||
// 缺 content type
|
||
_, err = cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "content type is required")
|
||
}
|
||
|
||
// TestNewConverterClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b
|
||
// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。
|
||
//
|
||
// 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
|
||
func TestNewConverterClient_Panics_When_APIKey_Empty(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
defer func() {
|
||
r := recover()
|
||
require.NotNil(t, r, "APIKey 為空時必須 panic(fail-fast)")
|
||
err, ok := r.(error)
|
||
require.True(t, ok, "panic value 應為 error 型別")
|
||
assert.True(t, errors.Is(err, ErrConverterAPIKeyNotConfigured),
|
||
"panic 應為 ErrConverterAPIKeyNotConfigured sentinel")
|
||
}()
|
||
|
||
_ = NewConverterClient(ConverterClientOpts{
|
||
BaseURL: "http://example.com",
|
||
APIKey: "", // empty — 必須觸發 panic
|
||
Logger: silentLogger(),
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// GetJob tests
|
||
// ==========================================================================
|
||
|
||
// TestGetJob_Success:標準 happy path(含完整 Job shape 解析)。
|
||
func TestGetJob_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodGet, r.Method)
|
||
require.Equal(t, "Bearer "+fakeConverterAPIKey, r.Header.Get("Authorization"),
|
||
"Phase 0.8b:每個 GET 也要直接帶 pre-shared API key")
|
||
// path: /api/v1/jobs/{id}
|
||
assert.Contains(t, r.URL.Path, "550e8400")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"user_id": "alice",
|
||
"status": "running",
|
||
"stage": "bie",
|
||
"progress": 45,
|
||
"stage_progress": 60,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"expires_at": "2026-05-02T12:00:00Z",
|
||
"input": {"filename": "model.onnx", "size_bytes": 100, "ref_images_count": 0},
|
||
"parameters": {"model_id": 1001, "version": "v1.0.0", "platform": "520"},
|
||
"error": null
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
job, err := cc.GetJob(context.Background(), "550e8400-e29b-41d4-a716-446655440000")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "running", job.Status)
|
||
assert.Equal(t, "bie", job.Stage)
|
||
require.NotNil(t, job.Progress)
|
||
assert.Equal(t, 45, *job.Progress)
|
||
require.NotNil(t, job.StageProgress)
|
||
assert.Equal(t, 60, *job.StageProgress)
|
||
assert.Equal(t, "model.onnx", job.SourceFilename)
|
||
assert.Equal(t, "520", job.Platform)
|
||
assert.False(t, job.ExpiresAt.IsZero())
|
||
}
|
||
|
||
// TestGetJob_NotFound:404 → ErrJobNotFound。
|
||
func TestGetJob_NotFound(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"job_not_found","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.GetJob(context.Background(), "missing-job")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotFound))
|
||
}
|
||
|
||
// TestGetJob_5xx_RetryThenSuccess:500/500/200 → atomic counter 驗 retry 3 次。
|
||
func TestGetJob_5xx_RetryThenSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
idx := counter.Add(1)
|
||
if idx <= 2 {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"...","request_id":"r"}}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "j1", "status": "completed", "stage": null, "progress": 100,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:08:30Z",
|
||
"expires_at": "2026-05-02T12:00:00Z"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
job, err := cc.GetJob(context.Background(), "j1")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "completed", job.Status)
|
||
assert.Equal(t, int32(3), counter.Load(), "GetJob 應 retry max 2 次(共 3 attempts)")
|
||
}
|
||
|
||
// TestGetJob_5xx_Exhausted:連續 5xx 用完 retry 仍失敗 → ErrConverterUnavailable。
|
||
func TestGetJob_5xx_Exhausted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
counter.Add(1)
|
||
w.WriteHeader(http.StatusBadGateway)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.GetJob(context.Background(), "j1")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterUnavailable))
|
||
assert.Equal(t, int32(3), counter.Load(), "用完 retry 仍 5xx 應該打 3 次")
|
||
}
|
||
|
||
// TestGetJob_ContextCancel_NoRetry:ctx 在 retry 等待中被 cancel → 立即 return。
|
||
func TestGetJob_ContextCancel_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
counter.Add(1)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
// 第一次 attempt 完後 cancel;第二次 retry 等待時應立即 return
|
||
go func() {
|
||
time.Sleep(50 * time.Millisecond)
|
||
cancel()
|
||
}()
|
||
|
||
_, err := cc.GetJob(ctx, "j1")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, context.Canceled))
|
||
// 至多 1 次(cancel 在退避時觸發)
|
||
assert.LessOrEqual(t, counter.Load(), int32(1),
|
||
"ctx cancel 應在第 1 次 attempt 後立即 return,不再打 server")
|
||
}
|
||
|
||
// TestGetJob_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry(API key 不對齊)。
|
||
func TestGetJob_AuthFailed401_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.GetJob(context.Background(), "j1")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"Phase 0.8b:GetJob 401 必須 mapping 到 ErrConverterAuthFailed")
|
||
assert.Equal(t, int32(1), attempts.Load(),
|
||
"401 不應 retry — API key 不對 retry 100 次也不會自己變對")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Promote tests
|
||
// ==========================================================================
|
||
|
||
// TestPromote_Success:promote response 含 target_object_key。
|
||
func TestPromote_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var receivedBody string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodPost, r.Method)
|
||
assert.Contains(t, r.URL.Path, "/promote")
|
||
body, _ := io.ReadAll(r.Body)
|
||
receivedBody = string(body)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "j1",
|
||
"promoted": [
|
||
{
|
||
"source": "nef",
|
||
"target_object_key": "visionA/models/alice/m-1001/v1.0.0/out.nef",
|
||
"size_bytes": 10485760,
|
||
"file_access_agent_etag": "abc123",
|
||
"promoted_at": "2026-04-25T12:30:00Z"
|
||
}
|
||
]
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
result, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
Source: "nef",
|
||
TargetObjectKey: "visionA/models/alice/m-1001/v1.0.0/out.nef",
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, result)
|
||
assert.Equal(t, "visionA/models/alice/m-1001/v1.0.0/out.nef", result.TargetObjectKey)
|
||
assert.Equal(t, int64(10485760), result.Size)
|
||
assert.Equal(t, "abc123", result.Checksum)
|
||
assert.Contains(t, receivedBody, `"user_id":"alice"`,
|
||
"promote body 應含 user_id metadata(trust boundary 重申)")
|
||
assert.Contains(t, receivedBody, `"target_object_key":"visionA/models/alice/m-1001/v1.0.0/out.nef"`)
|
||
}
|
||
|
||
// TestPromote_DefaultSource:未傳 Source 時預設 nef。
|
||
func TestPromote_DefaultSource(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var receivedBody string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
receivedBody = string(body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "j1",
|
||
"promoted": [{"source":"nef","target_object_key":"x","size_bytes":1,"file_access_agent_etag":"","promoted_at":"2026-04-25T00:00:00Z"}]
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Contains(t, receivedBody, `"source":"nef"`, "未傳 Source 時應預設 nef")
|
||
}
|
||
|
||
// TestPromote_BadGateway:FAA 不可達 → 502 → ErrFAAUnavailable。
|
||
func TestPromote_BadGateway(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusBadGateway)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"file_gateway_unavailable","message":"FAA 不可達","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
||
"converter 502 file_gateway_unavailable 必須對應到 ErrFAAUnavailable")
|
||
}
|
||
|
||
// TestPromote_NotCompleted409:job_not_ready_for_promote → ErrJobNotCompleted。
|
||
func TestPromote_NotCompleted409(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"job_not_ready_for_promote","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotCompleted))
|
||
}
|
||
|
||
// TestPromote_NotFound404:404 → ErrJobNotFound。
|
||
func TestPromote_NotFound404(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"job_not_found","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotFound))
|
||
}
|
||
|
||
// TestPromote_RequiredFieldsValidation:本地參數驗證。
|
||
func TestPromote_RequiredFieldsValidation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cc := newConverterClientForTest(t, "http://unused")
|
||
|
||
_, err := cc.Promote(context.Background(), "", PromoteReq{TargetObjectKey: "x"})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "jobID is required")
|
||
|
||
_, err = cc.Promote(context.Background(), "j1", PromoteReq{})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "target_object_key is required")
|
||
}
|
||
|
||
// TestPromote_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry。
|
||
func TestPromote_AuthFailed401_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"Phase 0.8b:Promote 401 必須 mapping 到 ErrConverterAuthFailed")
|
||
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// ListInProgressJobs tests
|
||
// ==========================================================================
|
||
|
||
// TestListInProgressJobs_Success:query string 含 user_id + status=in_progress。
|
||
func TestListInProgressJobs_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var receivedQuery string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
// path 在 mux pattern 沒結尾 / 時 ServeMux 會匹配精確路徑(list 端點)
|
||
require.Equal(t, http.MethodGet, r.Method)
|
||
receivedQuery = r.URL.RawQuery
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"jobs": [
|
||
{
|
||
"job_id": "j-active",
|
||
"user_id": "alice",
|
||
"status": "running",
|
||
"stage": "bie",
|
||
"progress": 45,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"expires_at": "2026-05-02T12:00:00Z",
|
||
"input": {"filename": "model.onnx", "size_bytes": 1, "ref_images_count": 0},
|
||
"parameters": {"model_id": 1, "version": "v1.0.0", "platform": "720"},
|
||
"error": null
|
||
}
|
||
],
|
||
"total": 1,
|
||
"next_cursor": null
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.NoError(t, err)
|
||
require.Len(t, jobs, 1)
|
||
assert.Equal(t, "j-active", jobs[0].JobID)
|
||
assert.Equal(t, "running", jobs[0].Status)
|
||
assert.Equal(t, "bie", jobs[0].Stage)
|
||
assert.Equal(t, "720", jobs[0].Platform)
|
||
assert.Contains(t, receivedQuery, "user_id=alice")
|
||
assert.Contains(t, receivedQuery, "status=in_progress",
|
||
"必須帶 status=in_progress 給 lazy rebuild ownership 用")
|
||
}
|
||
|
||
// TestListInProgressJobs_Empty:[] response → 空 slice。
|
||
func TestListInProgressJobs_Empty(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{"jobs":[],"total":0,"next_cursor":null}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.NoError(t, err)
|
||
assert.Len(t, jobs, 0, "empty result 應回空 slice,不是 nil 也不是 error")
|
||
assert.NotNil(t, jobs, "應回非 nil 空 slice 給 caller 安全 range")
|
||
}
|
||
|
||
// TestListInProgressJobs_5xxRetry:5xx 後成功;驗 retry 1 次(共 2 attempts)。
|
||
func TestListInProgressJobs_5xxRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
idx := counter.Add(1)
|
||
if idx == 1 {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{"jobs":[],"total":0,"next_cursor":null}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.NoError(t, err)
|
||
assert.Len(t, jobs, 0)
|
||
assert.Equal(t, int32(2), counter.Load(), "List 應 retry 1 次(共 2 attempts)")
|
||
}
|
||
|
||
// TestListInProgressJobs_RequiredUserID:本地參數驗證。
|
||
func TestListInProgressJobs_RequiredUserID(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cc := newConverterClientForTest(t, "http://unused")
|
||
|
||
_, err := cc.ListInProgressJobs(context.Background(), "")
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "userID is required")
|
||
}
|
||
|
||
// TestListInProgressJobs_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry。
|
||
func TestListInProgressJobs_AuthFailed401_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"Phase 0.8b:List 401 必須 mapping 到 ErrConverterAuthFailed")
|
||
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// GetResult tests(Phase 0.8b v0.6,ADR-016 §1)
|
||
// ==========================================================================
|
||
|
||
// TestGetResult_Success:mock 接受 GET /api/v1/jobs/{id}/result,回 200 + binary body
|
||
// + Content-Length / Content-Type / Content-Disposition。
|
||
//
|
||
// Phase 0.8b v0.6:驗 visionA → converter 端送 `Authorization: Bearer <fakeKey>` +
|
||
// metadata 正確解出 + body 透傳成功(不被 ReadAll 進 RAM)。
|
||
func TestGetResult_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const wantBody = "FAKE_NEF_BINARY_CONTENT_FOR_TEST"
|
||
var serverAuth string
|
||
var serverPath string
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-xyz/result", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodGet, r.Method)
|
||
serverAuth = r.Header.Get("Authorization")
|
||
serverPath = r.URL.Path
|
||
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "32")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="yolov5s_kl720.nef"`)
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(wantBody))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-xyz")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, stream)
|
||
require.NotNil(t, meta)
|
||
defer stream.Close()
|
||
|
||
assert.Equal(t, "Bearer "+fakeConverterAPIKey, serverAuth,
|
||
"Phase 0.8b:server 端應收到 pre-shared API key(visionA → converter API key 路徑)")
|
||
assert.Equal(t, "/api/v1/jobs/job-xyz/result", serverPath)
|
||
assert.Equal(t, int64(32), meta.ContentLength)
|
||
assert.Equal(t, "application/octet-stream", meta.ContentType)
|
||
assert.Equal(t, "yolov5s_kl720.nef", meta.Filename)
|
||
|
||
body, err := io.ReadAll(stream)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, wantBody, string(body))
|
||
}
|
||
|
||
// TestGetResult_StreamingBody_NotBuffered:用 8MB body,驗 client 拿到 stream
|
||
// 而不是 ReadAll 進 RAM。
|
||
//
|
||
// 策略:用 countingReader 包 8MB 假 body,count 一次回應裡 reader 被 Read 的次數;
|
||
// 然後在 caller 端只讀前 1KB 就 close,驗 client 端對 body 是 lazy read(剩下的不會被讀完)。
|
||
func TestGetResult_StreamingBody_NotBuffered(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const totalBytes = 8 * 1024 * 1024 // 8MB
|
||
var serverReadCalls int64
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-big/result", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "8388608")
|
||
w.WriteHeader(http.StatusOK)
|
||
|
||
// 用 countingReader 包來源(驗 server 端 io.Copy 是 streaming)
|
||
src := &countingReader{R: io.LimitReader(zerosReader{}, totalBytes)}
|
||
_, _ = io.Copy(w, src)
|
||
atomic.AddInt64(&serverReadCalls, src.calls)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-big")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, stream)
|
||
require.NotNil(t, meta)
|
||
|
||
// caller 只讀前 1KB 就 close — 驗 client 確實是 streaming
|
||
// (非 ReadAll,否則 client 收到 8MB 完整 body 後才回給 caller)
|
||
buf := make([]byte, 1024)
|
||
n, _ := io.ReadFull(stream, buf)
|
||
assert.Equal(t, 1024, n, "caller 應能在 client 還在 streaming 時就拿到部分 byte")
|
||
require.NoError(t, stream.Close())
|
||
|
||
assert.Equal(t, int64(8*1024*1024), meta.ContentLength,
|
||
"meta.ContentLength 應正確反映 server 端 Content-Length(不是 -1)")
|
||
}
|
||
|
||
// TestGetResult_AuthFailed401:mock 回 401 → ErrConverterAuthFailed,不 retry,
|
||
// 且對外 mask 為 converter_unavailable / 502。
|
||
//
|
||
// 對齊 T1 Reviewer Minor #M-3:補 mask 行為驗證(ErrorCode / HTTPStatus);
|
||
// 結構與 TestInitJob_AuthFailed401 對稱。
|
||
func TestGetResult_AuthFailed401(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-401/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-401")
|
||
|
||
require.Error(t, err)
|
||
assert.Nil(t, stream)
|
||
assert.Nil(t, meta)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"401 必須 mapping 到 ErrConverterAuthFailed(API key 不對齊運維事件;對外 mask 成 converter_unavailable)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
|
||
// 對外 ErrorCode mask 成 converter_unavailable(不洩漏「API key 不對」內部運維狀態)
|
||
assert.Equal(t, "converter_unavailable", ErrorCode(err),
|
||
"ErrorCode 應 mask 成 converter_unavailable(與 TestInitJob_AuthFailed401 對稱)")
|
||
assert.Equal(t, 502, HTTPStatus(err),
|
||
"HTTPStatus 應為 502 — auth_failed 與『服務不可達』對外同層")
|
||
}
|
||
|
||
// TestGetResult_AuthFailed403:對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry,
|
||
// 對外仍 mask 為 converter_unavailable / 502。
|
||
//
|
||
// mapGetResultError 將 401 / 403 mapping 到同一個 sentinel(API key 不對齊運維事件),
|
||
// 此 test 對稱補上 403 case,比照 TestInitJob_AuthFailed403 結構。
|
||
func TestGetResult_AuthFailed403(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-403/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusForbidden)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-403")
|
||
|
||
require.Error(t, err)
|
||
assert.Nil(t, stream)
|
||
assert.Nil(t, meta)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"403 必須與 401 共用 ErrConverterAuthFailed sentinel")
|
||
assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry")
|
||
assert.Equal(t, "converter_unavailable", ErrorCode(err),
|
||
"ErrorCode 對 403 也 mask 成 converter_unavailable")
|
||
assert.Equal(t, 502, HTTPStatus(err))
|
||
}
|
||
|
||
// TestGetResult_NotFound404:mock 回 404 → ErrJobNotFound,不 retry。
|
||
//
|
||
// 對應 ADR-016 §1.3:job_id 不存在 / 已被 GC(converter 端 7d expires_at)。
|
||
// 對外 visionA 端用既有 ErrJobNotFound(i18n key `conversion.error.not_found`,
|
||
// 與 ownership 找不到共用文字「任務不存在」)。
|
||
func TestGetResult_NotFound404(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-404/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":"job_not_found"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-404")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotFound),
|
||
"404 必須 mapping 到 ErrJobNotFound(job_id 不存在 / 已被 GC)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "404 不應 retry")
|
||
}
|
||
|
||
// TestGetResult_NotCompleted409:mock 回 409 → ErrJobNotCompleted,不 retry。
|
||
//
|
||
// 對應 ADR-016 §1.3:job 尚未 completed(理論上 visionA flow.go ensurePromoted 已先
|
||
// 確認 completed 才打 GetResult,不應發生;保留 mapping 防 converter 端 race)。
|
||
func TestGetResult_NotCompleted409(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-409/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{"error":"job_not_completed","status":"running"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-409")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotCompleted),
|
||
"409 必須 mapping 到 ErrJobNotCompleted(job 尚未 completed)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "409 不應 retry")
|
||
}
|
||
|
||
// TestGetResult_ResultExpired410:mock 回 410 → ErrResultExpired(v0.6 新增 sentinel),不 retry。
|
||
//
|
||
// 對應 ADR-016 §1.3:job completed 但 converter MinIO 內 NEF 已過 7d expires_at 被 GC。
|
||
// 對外 HTTP 410 / code `result_expired`,frontend 顯示「轉檔結果已過期,請重新轉檔」CTA。
|
||
func TestGetResult_ResultExpired410(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-410/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusGone)
|
||
_, _ = w.Write([]byte(`{"error":"result_expired"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-410")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrResultExpired),
|
||
"410 必須 mapping 到 ErrResultExpired(NEF 已被 converter MinIO GC)")
|
||
assert.Equal(t, "result_expired", ErrorCode(err),
|
||
"ErrorCode 應回 result_expired(與 converter_unavailable 區分,給 frontend 顯示精確過期訊息)")
|
||
assert.Equal(t, 410, HTTPStatus(err),
|
||
"HTTPStatus 應回 410 Gone(語意:曾經有、現在永遠沒了)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "410 不應 retry(過期不會自己變回來)")
|
||
}
|
||
|
||
// TestGetResult_5xx_RetryThenSuccess:mock 前 1 次回 503、第 2 次 200。
|
||
// 驗 Phase A retry 機制:5xx 可 retry。
|
||
func TestGetResult_5xx_RetryThenSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
const wantBody = "RECOVERED_NEF"
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-503-then-200/result", func(w http.ResponseWriter, r *http.Request) {
|
||
n := attempts.Add(1)
|
||
if n == 1 {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"error":"storage_unavailable"}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "13")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(wantBody))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
// 用較短 retry base 加速 test(注入 fake stream client 不適用,因為 transport
|
||
// 行為與 retry base 不同 — 改用 short-circuit ctx 或直接接受 1s 等候)。
|
||
// resultRetryBackoff(1) = 1s;test 接受。
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-503-then-200")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, stream)
|
||
defer stream.Close()
|
||
|
||
body, err := io.ReadAll(stream)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, wantBody, string(body))
|
||
assert.Equal(t, int64(13), meta.ContentLength)
|
||
assert.Equal(t, int32(2), attempts.Load(), "5xx 應 retry 1 次後成功(共 2 attempts)")
|
||
}
|
||
|
||
// TestGetResult_5xx_Exhausted:mock 一直 503 → 用完 retry 額度後回 ErrConverterUnavailable。
|
||
func TestGetResult_5xx_Exhausted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-always-503/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"error":"storage_unavailable"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-always-503")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterUnavailable),
|
||
"5xx exhausted 必須 mapping 到 ErrConverterUnavailable")
|
||
assert.Equal(t, int32(converterMaxRetriesResult+1), attempts.Load(),
|
||
"應該打滿 max retry 額度(1 + converterMaxRetriesResult = 3 attempts)")
|
||
}
|
||
|
||
// TestGetResult_ContextCancel:開 ctx 然後 cancel → 收 ctx.Err(不 retry)。
|
||
//
|
||
// 場景:server 一開始就 503,但在第 1 次 retry 退避(1s)期間 caller cancel ctx
|
||
// → 應該立即 return ctx.Err(),不再進下一輪 attempt。
|
||
func TestGetResult_ContextCancel(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-cancel/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
// 100ms 後 cancel — 在第 1 次 503 後、第 1 次 retry 退避(1s)期間觸發
|
||
time.AfterFunc(100*time.Millisecond, cancel)
|
||
|
||
_, _, err := cc.GetResult(ctx, "job-cancel")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, context.Canceled),
|
||
"ctx cancel 應立即透傳,不繼續 retry")
|
||
// attempts 可能是 1(第 1 次 503 後在 backoff sleep 收到 cancel)
|
||
assert.LessOrEqual(t, attempts.Load(), int32(2),
|
||
"ctx cancel 後不應繼續 retry 到打滿額度")
|
||
}
|
||
|
||
// TestGetResult_EmptyJobID:jobID 空字串 → 立即 return error,不打網路。
|
||
func TestGetResult_EmptyJobID(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cc := newConverterClientForTest(t, "http://this-should-never-be-called")
|
||
_, _, err := cc.GetResult(context.Background(), "")
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "jobID is required")
|
||
}
|
||
|
||
// TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。
|
||
//
|
||
// v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case):
|
||
// - RFC 5987 encoded form(`filename*=UTF-8''...`)— 驗 Go stdlib `mime.ParseMediaType`
|
||
// 對 charset-encoded `filename*` 參數的 transparent 解碼行為
|
||
// - Hostile-input:CRLF injection / path traversal / null byte / extreme length
|
||
//
|
||
// **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8''...` 形式
|
||
// **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。
|
||
// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded
|
||
// (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。
|
||
// 此行為對 visionA 無影響:
|
||
// - flow.DownloadStream 對 filename 不依賴此 parser;用 defaultDownloadFilename(cj) 覆寫
|
||
// - converter 端 ADR-016 §1.2 給的 filename 是 ASCII(`<stem>_<chip>.nef`)、不會走到 RFC 5987 path
|
||
//
|
||
// Hostile-input 防護:mime.ParseMediaType 對 malformed input(CRLF)回 error → parser 回
|
||
// 空字串、不 panic、不洩漏 raw input;visionA-backend handler (api/conversion.go) 對
|
||
// filename 另有 sanitize 層(CRLF / quote 移除),不依賴此 parser 做 sanitize。
|
||
func TestParseFilenameFromContentDisposition(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tests := []struct {
|
||
name string
|
||
cd string
|
||
want string
|
||
}{
|
||
{"happy_path", `attachment; filename="yolov5s_kl720.nef"`, "yolov5s_kl720.nef"},
|
||
{"no_quotes", `attachment; filename=yolov5s_kl720.nef`, "yolov5s_kl720.nef"},
|
||
{"empty_header", ``, ""},
|
||
{"malformed_no_attachment", `;;;`, ""},
|
||
{"missing_filename_param", `attachment`, ""},
|
||
{"inline_disposition", `inline; filename="foo.bin"`, "foo.bin"},
|
||
// === v0.6 T3 s-5 補強 ===
|
||
// RFC 5987 encoded form — Go stdlib mime.ParseMediaType 自動 percent-decode 為 UTF-8
|
||
// 並寫進 params["filename"](透明行為);對 ASCII-only 值結果就是原字串
|
||
{"rfc5987_utf8_ascii_only", `attachment; filename*=UTF-8''yolov5s_kl720.nef`, "yolov5s_kl720.nef"},
|
||
// RFC 5987 含 lang tag(`UTF-8'en'foo.nef`)— 同上、結果為解碼後 ASCII
|
||
{"rfc5987_with_lang", `attachment; filename*=UTF-8'en'foo.nef`, "foo.nef"},
|
||
// RFC 5987 含 percent-encoded UTF-8(✓ 字元)— stdlib 解碼後回 UTF-8 字串
|
||
// 且 RFC 5987 form **優先於** ASCII fallback(當兩者並存時,stdlib 取 `filename*` 值)
|
||
{"rfc5987_utf8_with_unicode", `attachment; filename="foo.nef"; filename*=UTF-8''foo_%E2%9C%93.nef`, "foo_✓.nef"},
|
||
// Hostile-input 1:CRLF injection(HTTP response splitting 攻擊向量)
|
||
// `mime.ParseMediaType` 對含 CR/LF 的 header 回 error → parser 回空字串
|
||
{"hostile_crlf_injection", "attachment; filename=\"foo.nef\r\nSet-Cookie: evil=1\"", ""},
|
||
// Hostile-input 2:path traversal — parser 端不做 sanitize(responsibility 在 handler);
|
||
// 此處只驗 parser 不 panic、不丟錯,把 raw `../../etc/passwd` 字串原樣傳出(caller 端
|
||
// 後續的 defaultDownloadFilename / handler sanitize 會處理)
|
||
{"hostile_path_traversal", `attachment; filename="../../etc/passwd"`, "../../etc/passwd"},
|
||
// Hostile-input 3:null byte injection — `mime.ParseMediaType` 容忍 null byte
|
||
// (規範未明禁),parser 把 raw 字串傳出(同上、由後續 layer sanitize)
|
||
{"hostile_null_byte", "attachment; filename=\"foo\x00.nef\"", "foo\x00.nef"},
|
||
// Hostile-input 4:extreme length(4KB filename)— parser 不限制長度、回 raw 字串
|
||
// 對 visionA 影響:0(response header 4KB 在 Content-Disposition 內早就被 net/http
|
||
// 拒絕;此 case 純驗 parser 不 OOM / 不 panic)
|
||
{"hostile_extreme_length", `attachment; filename="` + strings.Repeat("A", 4096) + `"`, strings.Repeat("A", 4096)},
|
||
// Hostile-input 5:empty quoted string — 合法格式、回空字串
|
||
{"empty_quoted_filename", `attachment; filename=""`, ""},
|
||
}
|
||
for _, tc := range tests {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got := parseFilenameFromContentDisposition(tc.cd)
|
||
assert.Equal(t, tc.want, got)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 共用:interface 契約 + helpers
|
||
// ==========================================================================
|
||
|
||
// 確保 converterClient 滿足 ConverterClient interface(compile-time check)。
|
||
var _ ConverterClient = (*converterClient)(nil)
|
||
|
||
// zerosReader 是無限產生 0 byte 的 reader(測 streaming 用)。
|
||
type zerosReader struct{}
|
||
|
||
func (zerosReader) Read(p []byte) (int, error) {
|
||
for i := range p {
|
||
p[i] = 0
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
// countingReader 包一個 reader 並計數 Read 呼叫次數(給 streaming 驗證用)。
|
||
type countingReader struct {
|
||
R io.Reader
|
||
calls int64 // atomic
|
||
}
|
||
|
||
func (c *countingReader) Read(p []byte) (int, error) {
|
||
atomic.AddInt64(&c.calls, 1)
|
||
return c.R.Read(p)
|
||
}
|