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

607 lines
22 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.

// FAA Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點
// - **Phase 0.8b**:直接用 string fake API keyfakeFAAAPIKey定義在 converter_client_test.go
// 不再注入 stub MCTokenClient
// - 用 atomic counter 驗 retry 行為Phase A retrymax 3 attempts = 1 + 2 retries
// - streaming 驗證用較大但合理大小10MB— 真 100MB 會拖慢 test runner 太多
//
// 測試範疇對應 conversion.md §9.1FAA 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_Successmock 回 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_Streamingmock 回 10MB bodyconfirm 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 的 clienttest 自己控)
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.Discardconfirm 全 download 完成。
written, copyErr := io.Copy(io.Discard, file.Body)
require.NoError(t, copyErr)
assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body")
}
// TestGetFile_AuthHeaderPhase 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_NoRetrymock 回 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 → ErrFAAFileNotFoundcaller 可精細處理)")
assert.Equal(t, int32(1), attempts.Load(),
"404 不應 retryobject 不存在 retry 也沒用)")
// 對外仍應 mask 成 faa_unavailable避免揭露 object_key 不存在)
assert.Equal(t, "faa_unavailable", ErrorCode(err))
assert.Equal(t, 502, HTTPStatus(err))
}
// TestGetFile_AuthFailed401Phase 0.8b — mock 回 401 → 不 retryreturn 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.8b401 必須 mapping 到新 sentinel ErrFAAAuthFailed")
// Phase 0.8b T3舊 sentinel ErrServiceClientUnauthorized 已移除,
// 改由 ErrFAAAuthFailed 接管 401/403 mapping。
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retryAPI 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_GenericErrorFAA 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 不應 retryvisionA 端的 bug")
}
// ==========================================================================
// Phase A retry 驗證5xx / network
// ==========================================================================
// TestGetFile_5xx_RetryThenSuccessmock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。
//
// 對齊 §9.1max 2 retries1s, 2s— 1 + 2 = 3 attempts第 3 次成功就 return。
// 注意test 用真實 backoff1s + 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 應 retrymax 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_Exhaustedmock 持續 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 retries1 + 2 = 3 attempts")
}
// TestGetFile_Network_RetryThenSuccess前 2 次 connection refused第 3 次成功。
//
// 用 dynamic listener swap 實作:先用一個 free port 不開 listenerdial 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 應 retrymax 2 retries → 3 attempts 後成功")
}
// TestGetFile_Network_Exhausteddial 失敗持續發生 → 用完 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")
// retry1 + 2 retries = 3 attempts2 次退避 = 1s + 2s = 3s 起跳
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
"network retry 應走完 §9.1 退避序列")
}
// ==========================================================================
// Context cancel
// ==========================================================================
// TestGetFile_ContextCancelcaller cancel ctx → 立即 return ctx.Err()(不包成 sentinel
func TestGetFile_ContextCancel(t *testing.T) {
t.Parallel()
// mock serverhandler 故意 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_DuringRetryctx cancel 發生在 retry sleep 中 → 立即中斷。
//
// 流程:
// - mock server 持續 500觸發 retry
// - 在第 1 次 retry 退避1s的中間500mscancel 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_Emptyfail-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 為空時必須 panicfail-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 testlog 用 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 應產生同 hashlog 可追蹤同一 request")
assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同")
assert.Len(t, h1, objectKeyHashLen, "hash 長度固定")
}