對齊 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>
607 lines
22 KiB
Go
607 lines
22 KiB
Go
// FAA Client 單元測試。
|
||
//
|
||
// 測試策略:
|
||
// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點
|
||
// - **Phase 0.8b**:直接用 string fake API key(fakeFAAAPIKey;定義在 converter_client_test.go),
|
||
// 不再注入 stub MCTokenClient
|
||
// - 用 atomic counter 驗 retry 行為(Phase A retry:max 3 attempts = 1 + 2 retries)
|
||
// - streaming 驗證用較大但合理大小(10MB)— 真 100MB 會拖慢 test runner 太多
|
||
//
|
||
// 測試範疇對應 conversion.md §9.1(FAA GET /files retry max 2 次, 1s/2s)+ ADR-015 §3 認證:
|
||
// - GetFile_Success / GetFile_Streaming / GetFile_AuthHeader
|
||
// - GetFile_404_NoRetry / GetFile_AuthFailed401 / GetFile_AuthFailed403
|
||
// - GetFile_5xx_RetryThenSuccess / GetFile_5xx_Exhausted
|
||
// - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted
|
||
// - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry
|
||
// - GetFile_EmptyObjectKey / GetFile_400_GenericError / HashObjectKey_StableAndLength
|
||
// - NewFAAClient_Panics_When_APIKey_Empty
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 + §9.1)
|
||
// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3)
|
||
package conversion
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// FAA mock server helpers
|
||
// ==========================================================================
|
||
|
||
// newFAAClientForTest 建立指向 mock server 的 FAAClient。
|
||
//
|
||
// Phase 0.8b:直接傳 fakeFAAAPIKey(定義在 converter_client_test.go),不再透過 MCTokenClient。
|
||
// 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout,所以另外覆寫。
|
||
func newFAAClientForTest(t *testing.T, baseURL string) FAAClient {
|
||
t.Helper()
|
||
return NewFAAClient(FAAClientOpts{
|
||
BaseURL: baseURL,
|
||
APIKey: fakeFAAAPIKey,
|
||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 成功路徑
|
||
// ==========================================================================
|
||
|
||
// TestGetFile_Success:mock 回 200 + binary stream,驗 ContentLength / ETag / ContentType 解析。
|
||
func TestGetFile_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
payload := []byte("binary payload here")
|
||
var receivedAuth string
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
receivedAuth = r.Header.Get("Authorization")
|
||
require.Equal(t, http.MethodGet, r.Method)
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("ETag", "\"etag-abc-123\"")
|
||
w.Header().Set("Content-Length", "19")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(payload)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef")
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, file)
|
||
require.NotNil(t, file.Body)
|
||
t.Cleanup(func() { _ = file.Body.Close() })
|
||
|
||
assert.Equal(t, "application/octet-stream", file.ContentType)
|
||
assert.Equal(t, "\"etag-abc-123\"", file.ETag)
|
||
assert.Equal(t, int64(19), file.ContentLength)
|
||
|
||
// caller 確實能 streaming 讀到完整 body
|
||
body, readErr := io.ReadAll(file.Body)
|
||
require.NoError(t, readErr)
|
||
assert.Equal(t, payload, body)
|
||
|
||
assert.Equal(t, "Bearer "+fakeFAAAPIKey, receivedAuth,
|
||
"Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)")
|
||
}
|
||
|
||
// TestGetFile_Streaming:mock 回 10MB body,confirm caller 能 streaming 讀(不 buffer 全 RAM)。
|
||
//
|
||
// 與 InitJob streaming test 對稱:用 io.LimitReader + zerosReader,確認 reader 被多次 Read
|
||
// (而非一次性全讀)。但 net/http 端 download 的 streaming 由 res.Body 提供,這裡的關鍵是:
|
||
// - faa_client 必須**不 io.ReadAll** 把 body 提前讀完
|
||
// - caller 用 io.Copy 慢慢讀時,server 端不需要先把全部 buffer 完成
|
||
func TestGetFile_Streaming(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const totalSize = int64(10 * 1024 * 1024) // 10MB
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "10485760")
|
||
w.WriteHeader(http.StatusOK)
|
||
// streaming write — 用 io.Copy from zerosReader(避免一次配 10MB buffer)
|
||
_, _ = io.CopyN(w, zerosReader{}, totalSize)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
// streaming download 不能用 http.Client.Timeout(會中斷 body streaming)
|
||
fc := NewFAAClient(FAAClientOpts{
|
||
BaseURL: srv.URL,
|
||
APIKey: fakeFAAAPIKey,
|
||
// 這裡用無 timeout 的 client(test 自己控)
|
||
HTTPClient: &http.Client{},
|
||
Logger: silentLogger(),
|
||
})
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
file, err := fc.GetFile(ctx, "big.nef")
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, file)
|
||
t.Cleanup(func() { _ = file.Body.Close() })
|
||
|
||
assert.Equal(t, totalSize, file.ContentLength)
|
||
|
||
// 用 countingReader 包 file.Body — 但 countingReader 是 io.Reader,
|
||
// 這裡換成 wrap 一下:直接 io.Copy 到 io.Discard,confirm 全 download 完成。
|
||
written, copyErr := io.Copy(io.Discard, file.Body)
|
||
require.NoError(t, copyErr)
|
||
assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body")
|
||
}
|
||
|
||
// TestGetFile_AuthHeader:Phase 0.8b — 驗 pre-shared API key 直接帶在 Authorization header。
|
||
//
|
||
// 用客製 APIKey(與 fakeFAAAPIKey 不同的字串),確認 client 真的透傳「建構時拿到的 key」、
|
||
// 而不是 hardcode 某個常數。
|
||
func TestGetFile_AuthHeader(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const customKey = "custom-faa-key-do-not-use-in-prod-ccccccccccccccccccccccccccccc"
|
||
|
||
var receivedAuth string
|
||
var receivedAccept string
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
receivedAuth = r.Header.Get("Authorization")
|
||
receivedAccept = r.Header.Get("Accept")
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("ok"))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := NewFAAClient(FAAClientOpts{
|
||
BaseURL: srv.URL,
|
||
APIKey: customKey,
|
||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
file, err := fc.GetFile(context.Background(), "key")
|
||
require.NoError(t, err)
|
||
defer file.Body.Close()
|
||
_, _ = io.ReadAll(file.Body)
|
||
|
||
assert.Equal(t, "Bearer "+customKey, receivedAuth,
|
||
"必須透傳建構時拿到的 API key,不可 hardcode 或從別處取")
|
||
assert.Equal(t, "application/octet-stream", receivedAccept)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 失敗映射(不 retry 類)
|
||
// ==========================================================================
|
||
|
||
// TestGetFile_404_NoRetry:mock 回 404 → 立即 return ErrFAAFileNotFound,不 retry。
|
||
func TestGetFile_404_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"file_not_found","message":"File not found."}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
file, err := fc.GetFile(context.Background(), "missing.nef")
|
||
|
||
require.Error(t, err)
|
||
require.Nil(t, file, "失敗時不應回 FAAFile(避免 caller 誤用 nil body)")
|
||
assert.True(t, errors.Is(err, ErrFAAFileNotFound),
|
||
"404 → ErrFAAFileNotFound(caller 可精細處理)")
|
||
assert.Equal(t, int32(1), attempts.Load(),
|
||
"404 不應 retry(object 不存在 retry 也沒用)")
|
||
// 對外仍應 mask 成 faa_unavailable(避免揭露 object_key 不存在)
|
||
assert.Equal(t, "faa_unavailable", ErrorCode(err))
|
||
assert.Equal(t, 502, HTTPStatus(err))
|
||
}
|
||
|
||
// TestGetFile_AuthFailed401:Phase 0.8b — mock 回 401 → 不 retry,return ErrFAAAuthFailed。
|
||
//
|
||
// 觸發情境:VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不對齊(rotate 未同步 / env 設錯)。
|
||
// 對外仍 mask 成 faa_unavailable / 502,避免洩漏「API key 不對」內部運維狀態。
|
||
func TestGetFile_AuthFailed401(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
file, err := fc.GetFile(context.Background(), "k")
|
||
|
||
require.Error(t, err)
|
||
require.Nil(t, file)
|
||
assert.True(t, errors.Is(err, ErrFAAAuthFailed),
|
||
"Phase 0.8b:401 必須 mapping 到新 sentinel ErrFAAAuthFailed")
|
||
// Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除,
|
||
// 改由 ErrFAAAuthFailed 接管 401/403 mapping。
|
||
assert.Equal(t, int32(1), attempts.Load(),
|
||
"401 不應 retry(API key 不對 retry 也是 401)")
|
||
// 對外仍 mask 成 faa_unavailable
|
||
assert.Equal(t, "faa_unavailable", ErrorCode(err))
|
||
assert.Equal(t, 502, HTTPStatus(err))
|
||
}
|
||
|
||
// TestGetFile_AuthFailed403:對稱 — FAA 端 403 同樣 ErrFAAAuthFailed、不 retry。
|
||
func TestGetFile_AuthFailed403(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusForbidden)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
_, err := fc.GetFile(context.Background(), "k")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrFAAAuthFailed))
|
||
assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry")
|
||
}
|
||
|
||
// TestGetFile_400_GenericError:FAA 400(如 invalid_object_key)→ ErrFAAUnavailable,不 retry。
|
||
func TestGetFile_400_GenericError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"invalid_object_key"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
_, err := fc.GetFile(context.Background(), "invalid//key")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
||
"400(非 401/403/404)→ ErrFAAUnavailable")
|
||
// 應該不會被 mis-classified 成 ErrFAAFileNotFound
|
||
assert.False(t, errors.Is(err, ErrFAAFileNotFound))
|
||
assert.Equal(t, int32(1), attempts.Load(), "400 不應 retry(visionA 端的 bug)")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Phase A retry 驗證(5xx / network)
|
||
// ==========================================================================
|
||
|
||
// TestGetFile_5xx_RetryThenSuccess:mock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。
|
||
//
|
||
// 對齊 §9.1:max 2 retries(1s, 2s)— 1 + 2 = 3 attempts;第 3 次成功就 return。
|
||
// 注意:test 用真實 backoff(1s + 2s = 3s)— 為了驗 §9.1 退避時序,可接受。
|
||
func TestGetFile_5xx_RetryThenSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
payload := []byte("recovered after retry")
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
n := attempts.Add(1)
|
||
if n < 3 {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "21")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(payload)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
|
||
start := time.Now()
|
||
file, err := fc.GetFile(context.Background(), "k")
|
||
duration := time.Since(start)
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, file)
|
||
t.Cleanup(func() { _ = file.Body.Close() })
|
||
|
||
got, _ := io.ReadAll(file.Body)
|
||
assert.Equal(t, payload, got, "第 3 次成功的 body 應正確透傳")
|
||
assert.Equal(t, int32(3), attempts.Load(),
|
||
"5xx 應 retry:max 2 retries → 3 attempts")
|
||
// 驗時序:兩次 retry 退避 1s + 2s,至少花 3s(容忍輕微誤差用 ≥2.5s)
|
||
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
|
||
"§9.1 退避序列 1s + 2s 應至少耗 2.5s")
|
||
}
|
||
|
||
// TestGetFile_5xx_Exhausted:mock 持續 500 → 用完 max retry 後 return ErrFAAUnavailable。
|
||
func TestGetFile_5xx_Exhausted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
_, err := fc.GetFile(context.Background(), "k")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
||
"5xx exhausted → ErrFAAUnavailable")
|
||
assert.Equal(t, int32(faaMaxRetries+1), attempts.Load(),
|
||
"5xx 應跑滿 max retries:1 + 2 = 3 attempts")
|
||
}
|
||
|
||
// TestGetFile_Network_RetryThenSuccess:前 2 次 connection refused,第 3 次成功。
|
||
//
|
||
// 用 dynamic listener swap 實作:先用一個 free port 不開 listener(dial fail),
|
||
// 第 3 次 attempt 之前才 swap 到真的 mock server。實作上比較複雜 — 改用
|
||
// proxy handler 在 mock server 內部對前 N 次「立刻 hijack 後 close」模擬 dial fail
|
||
// 不行(連線已建好);改用「server 端 force-close connection 不送任何 byte」
|
||
// 來模擬 transport 層失敗。
|
||
//
|
||
// 簡化版:用一個 proxy server,前 2 次直接 hijack + close 連線(client 看到 EOF),
|
||
// 第 3 次正常回 200。
|
||
func TestGetFile_Network_RetryThenSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
payload := []byte("recovered from net error")
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
n := attempts.Add(1)
|
||
if n < 3 {
|
||
// hijack + close 模擬 connection 中斷(client 端會看到 unexpected EOF / read error)
|
||
hj, ok := w.(http.Hijacker)
|
||
if !ok {
|
||
t.Fatal("server does not support hijacking")
|
||
}
|
||
conn, _, err := hj.Hijack()
|
||
if err != nil {
|
||
t.Fatalf("hijack failed: %v", err)
|
||
}
|
||
_ = conn.Close()
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(payload)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
file, err := fc.GetFile(context.Background(), "k")
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, file)
|
||
t.Cleanup(func() { _ = file.Body.Close() })
|
||
|
||
got, _ := io.ReadAll(file.Body)
|
||
assert.Equal(t, payload, got)
|
||
assert.Equal(t, int32(3), attempts.Load(),
|
||
"network error 應 retry:max 2 retries → 3 attempts 後成功")
|
||
}
|
||
|
||
// TestGetFile_Network_Exhausted:dial 失敗持續發生 → 用完 max retry 後 ErrFAAUnavailable。
|
||
//
|
||
// 用一個 listen 後立刻 close 的 socket 製造 connection refused(每次 attempt 都失敗)。
|
||
func TestGetFile_Network_Exhausted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// 拿一個 free port 立刻關掉(dial 必失敗)
|
||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||
require.NoError(t, err)
|
||
addr := ln.Addr().String()
|
||
require.NoError(t, ln.Close())
|
||
|
||
fc := NewFAAClient(FAAClientOpts{
|
||
BaseURL: "http://" + addr,
|
||
APIKey: fakeFAAAPIKey,
|
||
// 用較短 timeout,但仍要大於 retry 退避總和(1s + 2s = 3s)— 設 10s 安全
|
||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
|
||
start := time.Now()
|
||
_, err = fc.GetFile(context.Background(), "k")
|
||
duration := time.Since(start)
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
||
"network exhausted → ErrFAAUnavailable")
|
||
// retry:1 + 2 retries = 3 attempts,2 次退避 = 1s + 2s = 3s 起跳
|
||
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
|
||
"network retry 應走完 §9.1 退避序列")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Context cancel
|
||
// ==========================================================================
|
||
|
||
// TestGetFile_ContextCancel:caller cancel ctx → 立即 return ctx.Err()(不包成 sentinel)。
|
||
func TestGetFile_ContextCancel(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
|
||
// mock server:handler 故意 sleep(讓 ctx cancel 在 server response 前發生)
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
select {
|
||
case <-r.Context().Done():
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
go func() {
|
||
time.Sleep(50 * time.Millisecond)
|
||
cancel()
|
||
}()
|
||
|
||
_, err := fc.GetFile(ctx, "k")
|
||
require.Error(t, err)
|
||
// ctx cancel → 透傳 ctx.Err()(不包成 ErrFAAUnavailable)
|
||
assert.True(t,
|
||
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
|
||
"ctx cancel 應透傳,不應包成 ErrFAAUnavailable")
|
||
}
|
||
|
||
// TestGetFile_ContextCancel_DuringRetry:ctx cancel 發生在 retry sleep 中 → 立即中斷。
|
||
//
|
||
// 流程:
|
||
// - mock server 持續 500(觸發 retry)
|
||
// - 在第 1 次 retry 退避(1s)的中間(500ms)cancel ctx
|
||
// - 期望:GetFile 立即 return ctx.Err(),不等完 1s 退避也不繼續第 2 次 retry
|
||
func TestGetFile_ContextCancel_DuringRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
fc := newFAAClientForTest(t, srv.URL)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
go func() {
|
||
// 等第 1 次 attempt 跑完 + 進 retry sleep 後再 cancel
|
||
// 第 1 次 attempt 約 < 100ms;第 1 次 retry 退避 1s,在 500ms cancel
|
||
time.Sleep(500 * time.Millisecond)
|
||
cancel()
|
||
}()
|
||
|
||
start := time.Now()
|
||
_, err := fc.GetFile(ctx, "k")
|
||
duration := time.Since(start)
|
||
|
||
require.Error(t, err)
|
||
assert.True(t,
|
||
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
|
||
"retry sleep 中 cancel → 透傳 ctx.Err()")
|
||
// 應在 cancel 後立即中斷(< 1s 整體時間)— 不該等完 1s 退避或進入第 2 次 retry
|
||
assert.Less(t, duration, 900*time.Millisecond,
|
||
"ctx cancel 應立即中斷 retry sleep(不等完退避)")
|
||
// attempts 應為 1(第 1 次 attempt 後進 retry sleep 就被 cancel)
|
||
assert.Equal(t, int32(1), attempts.Load(),
|
||
"cancel 後不應再嘗試第 2 次 attempt")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Constructor fail-fast
|
||
// ==========================================================================
|
||
|
||
// TestNewFAAClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b
|
||
// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。
|
||
//
|
||
// 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
|
||
//
|
||
// (Phase 0.8b 之前的 `TestGetFile_ServiceTokenFailure_Propagated` 已移除:
|
||
// API key 改造後 ServiceToken 不再被呼叫,「token 取不到」這個失敗路徑結構性消失,
|
||
// 原測試的前提不存在;對應的失敗模式變成「建構時 fail-fast」由本測試覆蓋。)
|
||
func TestNewFAAClient_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, ErrFAAAPIKeyNotConfigured),
|
||
"panic 應為 ErrFAAAPIKeyNotConfigured sentinel")
|
||
}()
|
||
|
||
_ = NewFAAClient(FAAClientOpts{
|
||
BaseURL: "http://example.com",
|
||
APIKey: "", // empty — 必須觸發 panic
|
||
Logger: silentLogger(),
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 額外:empty object_key validation
|
||
// ==========================================================================
|
||
|
||
// TestGetFile_EmptyObjectKey:保護性 validation — 空字串 object_key 應立即 fail。
|
||
func TestGetFile_EmptyObjectKey(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
fc := NewFAAClient(FAAClientOpts{
|
||
BaseURL: "http://invalid",
|
||
APIKey: fakeFAAAPIKey,
|
||
Logger: silentLogger(),
|
||
})
|
||
|
||
_, err := fc.GetFile(context.Background(), "")
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "object_key is required",
|
||
"empty object_key 應立即 fail(不打網路)")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// hashObjectKey unit test(log 用 hash 函式的穩定性)
|
||
// ==========================================================================
|
||
|
||
// TestHashObjectKey_StableAndLength:同 input 應產生同 output;長度固定 16。
|
||
func TestHashObjectKey_StableAndLength(t *testing.T) {
|
||
t.Parallel()
|
||
h1 := hashObjectKey("tenant/jobs/abc/output.nef")
|
||
h2 := hashObjectKey("tenant/jobs/abc/output.nef")
|
||
h3 := hashObjectKey("tenant/jobs/xyz/output.nef")
|
||
|
||
assert.Equal(t, h1, h2, "同 object_key 應產生同 hash(log 可追蹤同一 request)")
|
||
assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同")
|
||
assert.Len(t, h1, objectKeyHashLen, "hash 長度固定")
|
||
}
|