visionA/visionA-backend/internal/conversion/mc_token_client_test.go
jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:56:07 +08:00

865 lines
28 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.

// MC Token Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock MCaccept counter / atomic 驗 retry / cache 行為
// - 用 fake clock 控制時間(測 cache 過期)
// - 用 silent logger 避免 test 輸出污染assert 過程仍可 inspect
//
// 對應 task 規範必含 11 個 case本檔每個都有對應 test func。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.4 / §5)
package conversion
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// silentLogger 是 test 用的 no-op logger避免 test 輸出污染。
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// fakeClock 提供可控的時間源;用 atomic 操作 nano 確保 race-free。
type fakeClock struct {
nano atomic.Int64 // unix nano
}
func newFakeClock(t time.Time) *fakeClock {
c := &fakeClock{}
c.nano.Store(t.UnixNano())
return c
}
func (c *fakeClock) now() time.Time {
return time.Unix(0, c.nano.Load())
}
func (c *fakeClock) advance(d time.Duration) {
c.nano.Add(int64(d))
}
// ==========================================================================
// mock helpers — 模擬 MC oauth/token + file-access/download-tokens 兩個 endpoint
// ==========================================================================
// tokenServerOpts 控制 mock server 行為。
type tokenServerOpts struct {
// expiresIn 是回給 caller 的 expires_in預設 3600
expiresIn int
// statusFn 控制每次 request 的 HTTP status預設 200
statusFn func(callIdx int) int
// tokenFn 控制每次 request 的 access_token 內容;預設 "tok-{idx}"
tokenFn func(callIdx int) string
// delay 是 server 回應前的等待(測 timeout / cancel 用)
delay time.Duration
// invalidJSON 為 true 時回非 JSON body測 parse error
invalidJSON bool
// emptyToken 為 true 時回 access_token=""(測 invalid shape
emptyToken bool
}
// newTokenServer 建立一個 mock MC server提供 /oauth/token endpoint。
//
// 回傳server URL、call counteratomic可用來驗 fetch 次數)、收到的 last form values。
func newTokenServer(t *testing.T, opts tokenServerOpts) (*httptest.Server, *atomic.Int32, *sync.Map) {
t.Helper()
var counter atomic.Int32
lastForm := &sync.Map{} // map[int]url.Valueskey 是 call idx
if opts.expiresIn == 0 {
opts.expiresIn = 3600
}
if opts.statusFn == nil {
opts.statusFn = func(int) int { return 200 }
}
if opts.tokenFn == nil {
opts.tokenFn = func(idx int) string { return fmt.Sprintf("tok-%d", idx) }
}
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
idx := int(counter.Add(1)) - 1
// 驗 Basic auth + Content-Type 都對
if _, _, ok := r.BasicAuth(); !ok {
t.Errorf("oauth/token expected Basic auth header, got none")
}
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
t.Errorf("oauth/token expected form content-type, got %q", r.Header.Get("Content-Type"))
}
// 解 body 存起來給 test 檢查
_ = r.ParseForm()
// 拷一份 r.Form 進 sync.Mapr.Form 之後可能被 server 覆寫)
form := url.Values{}
for k, v := range r.Form {
form[k] = append([]string(nil), v...)
}
lastForm.Store(idx, form)
if opts.delay > 0 {
select {
case <-time.After(opts.delay):
case <-r.Context().Done():
return
}
}
status := opts.statusFn(idx)
if status != 200 {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":"server_error"}`))
return
}
if opts.invalidJSON {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`<not json>`))
return
}
token := opts.tokenFn(idx)
if opts.emptyToken {
token = ""
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":%d}`,
token, opts.expiresIn)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &counter, lastForm
}
// downloadServerOpts 控制 download-tokens mock 行為。
type downloadServerOpts struct {
tokenStatusFn func(callIdx int) int // /oauth/token 端的 status預設 200
downloadStatusFn func(callIdx int) int // /file-access/download-tokens 的 status預設 200
respBody string // /file-access/download-tokens 的回應 body預設 happy path
}
// newDownloadServer 同時 mock /oauth/token + /file-access/download-tokens。
//
// 回傳server URL、download endpoint call counter、收到的 last download body解 JSON 後)。
func newDownloadServer(t *testing.T, opts downloadServerOpts) (
srv *httptest.Server,
tokenCounter, downloadCounter *atomic.Int32,
lastDownloadBody *string,
) {
t.Helper()
var tCounter, dCounter atomic.Int32
var bodyMu sync.Mutex
var lastBody string
if opts.tokenStatusFn == nil {
opts.tokenStatusFn = func(int) int { return 200 }
}
if opts.downloadStatusFn == nil {
opts.downloadStatusFn = func(int) int { return 200 }
}
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
idx := int(tCounter.Add(1)) - 1
status := opts.tokenStatusFn(idx)
if status != 200 {
w.WriteHeader(status)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`))
})
mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) {
idx := int(dCounter.Add(1)) - 1
// 把收到的 body 存起來給 test 驗 shape
body, _ := io.ReadAll(r.Body)
bodyMu.Lock()
lastBody = string(body)
bodyMu.Unlock()
// 驗 Bearer token 有送
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("download endpoint expected Bearer auth, got %q", auth)
}
status := opts.downloadStatusFn(idx)
if status != 200 {
w.WriteHeader(status)
return
}
body2 := opts.respBody
if body2 == "" {
// happy path: 回一個 future expires_at
body2 = fmt.Sprintf(`{"token":"opaque-tok-%d","expires_at":"%s"}`,
idx, time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body2))
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &tCounter, &dCounter, func() *string {
bodyMu.Lock()
defer bodyMu.Unlock()
s := lastBody
return &s
}()
}
// newClient 建一個測試用的 mcTokenClient注入 fake clock 與 silent logger。
func newClient(srv *httptest.Server, clock *fakeClock) MCTokenClient {
opts := MCTokenClientOpts{
Issuer: srv.URL,
ClientID: "visiona-svc-id",
ClientSecret: "visiona-svc-secret",
HTTPClient: srv.Client(),
Logger: silentLogger(),
}
if clock != nil {
opts.Now = clock.now
}
return NewMCTokenClient(opts)
}
// ==========================================================================
// ServiceToken — cache / fetch / retry 系列
// ==========================================================================
func TestServiceToken_FirstCall_Fetches(t *testing.T) {
t.Parallel()
srv, counter, lastForm := newTokenServer(t, tokenServerOpts{})
c := newClient(srv, nil)
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
require.NoError(t, err)
assert.Equal(t, "tok-0", tok)
assert.Equal(t, int32(1), counter.Load(), "第一次呼叫應該真的打 MC")
// 驗 form values 對齊 RFC 6749 §4.4
if v, ok := lastForm.Load(0); ok {
form := v.(url.Values)
assert.Equal(t, "client_credentials", form.Get("grant_type"))
assert.Equal(t, "converter:job.write", form.Get("scope"))
} else {
t.Fatal("server did not record form")
}
}
func TestServiceToken_CacheHit(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 3600})
c := newClient(srv, nil)
scope := "converter:job.write"
tok1, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
tok2, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
tok3, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
assert.Equal(t, tok1, tok2)
assert.Equal(t, tok2, tok3)
assert.Equal(t, int32(1), counter.Load(), "後續呼叫應走 cache不打 MC")
}
func TestServiceToken_Expired_Refetch(t *testing.T) {
t.Parallel()
clock := newFakeClock(time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC))
srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 60}) // 60s TTL
c := newClient(srv, clock)
scope := "converter:job.write"
tok1, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
assert.Equal(t, int32(1), counter.Load())
// 推進到 exp - skew 之後60s - 15s = 45s應視為過期
clock.advance(46 * time.Second)
tok2, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
assert.NotEqual(t, tok1, tok2, "過期後應拿到新 token")
assert.Equal(t, int32(2), counter.Load(), "過期後應重 fetch")
}
func TestServiceToken_DifferentScope_DifferentCache(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 3600})
c := newClient(srv, nil)
tokA1, err := c.ServiceToken(context.Background(), "scope-a")
require.NoError(t, err)
tokB1, err := c.ServiceToken(context.Background(), "scope-b")
require.NoError(t, err)
tokA2, err := c.ServiceToken(context.Background(), "scope-a")
require.NoError(t, err)
tokB2, err := c.ServiceToken(context.Background(), "scope-b")
require.NoError(t, err)
assert.Equal(t, tokA1, tokA2, "同 scope 應走 cache")
assert.Equal(t, tokB1, tokB2)
assert.NotEqual(t, tokA1, tokB1, "不同 scope 應有不同 token")
assert.Equal(t, int32(2), counter.Load(), "兩個 scope 各 fetch 一次")
}
// TestServiceToken_Concurrent_OnlyOneFetch — 100 個 goroutine 同時要 tokenDCL 確保只 fetch 一次。
//
// 實作細節mock server 回應有 50ms delay確保第一個 fetch 還沒回前所有 caller 都已進來;
// DCL 應讓他們全部 block 在 mu.Lock(),第一個 fetch 完寫 cache 後,後續 caller 走 fast path。
func TestServiceToken_Concurrent_OnlyOneFetch(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
expiresIn: 3600,
delay: 50 * time.Millisecond,
})
c := newClient(srv, nil)
const N = 100
var wg sync.WaitGroup
wg.Add(N)
tokens := make([]string, N)
errs := make([]error, N)
start := make(chan struct{})
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
<-start
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
tokens[idx] = tok
errs[idx] = err
}(i)
}
close(start)
wg.Wait()
for _, e := range errs {
require.NoError(t, e)
}
for i := 1; i < N; i++ {
assert.Equal(t, tokens[0], tokens[i], "所有 goroutine 應拿到同一個 token")
}
assert.Equal(t, int32(1), counter.Load(), "DCL 應確保 100 個 caller 只打一次 MC")
}
func TestServiceToken_Server4xx_NoRetry(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 401 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"401 應 mapping 到 ErrServiceClientUnauthorized, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"401 不應同時掛 ErrMCTokenUnavailable")
assert.Equal(t, int32(1), counter.Load(), "401 不應 retry")
}
func TestServiceToken_Server403_NoRetry(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 403 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
assert.Equal(t, int32(1), counter.Load(), "403 不應 retry")
}
func TestServiceToken_Server400_NoRetry(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 400 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6MC token endpoint 4xx (非 401/403) → idp_misconfigured / 500
assert.True(t, errors.Is(err, ErrIDPMisconfigured),
"service_token 4xx 應 mapping 到 ErrIDPMisconfigured§6, got %v", err)
assert.False(t, errors.Is(err, ErrServiceClientUnauthorized),
"400 不應掛 ErrServiceClientUnauthorized限 401/403")
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"service_token 4xx 不應掛 ErrMCTokenUnavailable§6 該 sentinel 限 delegated 5xx 用)")
assert.Equal(t, int32(1), counter.Load(), "400 不應 retry")
}
func TestServiceToken_Server5xx_Retry(t *testing.T) {
t.Parallel()
// 前兩次 500、第三次 200
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(idx int) int {
if idx < 2 {
return 500
}
return 200
},
})
// 把 retryBaseDelay 暫時縮短,避免 test 等太久(用環境變數無法 — 改用 dial-down opts
// 這裡選擇接受真實 1s + 2s = 3s 的等待test 內可接受)
c := newClient(srv, nil)
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
require.NoError(t, err)
assert.Equal(t, "tok-2", tok, "第三次成功的 token")
assert.Equal(t, int32(3), counter.Load(), "5xx 應 retry 兩次後第三次成功")
}
func TestServiceToken_Server5xx_Exhausted(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 500 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6MC token endpoint 5xx / network 持續失敗 → idp_unavailable / 503
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"service_token 連續 5xx 應 mapping 到 ErrIDPUnavailable§6, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"service_token 5xx 不應掛 ErrMCTokenUnavailable§6 該 sentinel 限 delegated 5xx 用)")
// 第一次 + 2 次 retry = 3 次 attempt
assert.Equal(t, int32(3), counter.Load(), "5xx 應 attempt 3 次")
}
func TestServiceToken_ContextCancel_NoRetry(t *testing.T) {
t.Parallel()
// server 回應有 500ms delay給我們時間 cancel
srv, counter, _ := newTokenServer(t, tokenServerOpts{
delay: 500 * time.Millisecond,
})
c := newClient(srv, nil)
ctx, cancel := context.WithCancel(context.Background())
// 50ms 後 cancel在 server response 之前)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := c.ServiceToken(ctx, "converter:job.write")
require.Error(t, err)
// ctx cancel 在 service_token endpoint
// - http.Client 端攔到 ctx cancel → 透傳 context.Canceled不包 sentinel
// - 透過 fmt.Errorf("%w") 包過 → ErrIDPUnavailable§6 service_token network 失敗映射)
// 兩者擇一即為合法
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, ErrIDPUnavailable),
"ctx cancel 應立即 returncontext.Canceled 或 ErrIDPUnavailable wrapgot %v", err)
// counter 可能是 1server 收到了但 client 在等回應時 cancel不應該 retry
assert.LessOrEqual(t, counter.Load(), int32(1),
"ctx cancel 不應 retrycounter <= 1")
}
func TestServiceToken_InvalidJSON_TreatedAsError(t *testing.T) {
t.Parallel()
srv, _, _ := newTokenServer(t, tokenServerOpts{invalidJSON: true})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6service_token endpoint 回 200 但 body 不合法 — 視為 IDP 暫時不可用503/idp_unavailable
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"service_token JSON parse error 應 mapping 到 ErrIDPUnavailable§6, got %v", err)
}
func TestServiceToken_EmptyTokenInResponse_TreatedAsError(t *testing.T) {
t.Parallel()
srv, _, _ := newTokenServer(t, tokenServerOpts{emptyToken: true})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6service_token endpoint shape 不對 — 同 IdP 失常503/idp_unavailable
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"空 access_token 應 mapping 到 ErrIDPUnavailable§6, got %v", err)
}
func TestServiceToken_FailureNotCached(t *testing.T) {
t.Parallel()
// 第一次 500 (+2 retry 都 500),第四次(即第二次 ServiceToken 呼叫的第一個 attempt成功
var phase atomic.Int32
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(idx int) int {
if phase.Load() == 0 {
return 500
}
return 200
},
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err, "第一次預期失敗")
assert.Equal(t, int32(3), counter.Load())
// 切換到 success phase
phase.Store(1)
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
require.NoError(t, err, "第二次應成功(之前的失敗不應 cache")
assert.NotEmpty(t, tok)
assert.Equal(t, int32(4), counter.Load(), "第二次 ServiceToken 應重新打 MC")
}
// ==========================================================================
// IssueDelegatedDownload 系列
// ==========================================================================
func TestIssueDelegatedDownload_Success(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{})
c := newClient(srv, nil)
dl, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "tenant-x",
UserID: "user-y",
ObjectKey: "promoted/job-1.nef",
ExpiresInSeconds: 600,
})
require.NoError(t, err)
require.NotNil(t, dl)
assert.Contains(t, dl.Token, "opaque-tok-")
assert.True(t, dl.ExpiresAt.After(time.Now()), "expires_at 應在未來")
assert.Equal(t, int32(1), dCounter.Load())
}
// TestIssueDelegatedDownload_RequestBodyShape 驗 POST /file-access/download-tokens 的 body shape
// 對齊 conversion.md §1 + §2.4。
func TestIssueDelegatedDownload_RequestBodyShape(t *testing.T) {
t.Parallel()
// 自訂 server 收 body 後驗 shape
var lastBody string
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`))
})
mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
lastBody = string(body)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Bearer svc-tok"),
"應帶 service token 為 Bearer auth")
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"token":"opaque","expires_at":"%s"}`,
time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339))
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: srv.URL,
ClientID: "id",
ClientSecret: "sec",
HTTPClient: srv.Client(),
Logger: silentLogger(),
})
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "tenant-z",
UserID: "user-a",
ObjectKey: "a/b/c.nef",
ExpiresInSeconds: 300,
})
require.NoError(t, err)
// 驗 body shape — JSON 含必要欄位
assert.Contains(t, lastBody, `"tenant_id":"tenant-z"`)
assert.Contains(t, lastBody, `"user_id":"user-a"`)
assert.Contains(t, lastBody, `"object_key":"a/b/c.nef"`)
assert.Contains(t, lastBody, `"method":"GET"`)
assert.Contains(t, lastBody, `"expires_in_seconds":300`)
}
func TestIssueDelegatedDownload_DefaultTTL(t *testing.T) {
t.Parallel()
var lastBody string
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`))
})
mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
lastBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"token":"opaque","expires_at":"%s"}`,
time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339))
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: srv.URL,
ClientID: "id",
ClientSecret: "sec",
HTTPClient: srv.Client(),
Logger: silentLogger(),
})
// 不傳 ExpiresInSeconds=0應自動套 default 300
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.NoError(t, err)
assert.Contains(t, lastBody, `"expires_in_seconds":300`,
"ExpiresInSeconds 為 0 時應 fallback 到 default 300")
}
func TestIssueDelegatedDownload_Server4xx_PropagateError(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{
downloadStatusFn: func(int) int { return 400 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
// §6MC delegated download 4xx → download_token_failed / 502
assert.True(t, errors.Is(err, ErrDownloadTokenFailed),
"delegated 4xx 應 mapping 到 ErrDownloadTokenFailed§6, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"delegated 4xx 不應掛 ErrMCTokenUnavailable§6 該 sentinel 限 5xx 用)")
assert.Equal(t, int32(1), dCounter.Load(), "4xx 不應 retry")
}
func TestIssueDelegatedDownload_Server5xx_RetryThenFail(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{
downloadStatusFn: func(int) int { return 500 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
// §6MC delegated download 5xx / network 持續失敗 → mc_token_unavailable / 502不變
assert.True(t, errors.Is(err, ErrMCTokenUnavailable),
"delegated 5xx 應 mapping 到 ErrMCTokenUnavailable§6, got %v", err)
assert.False(t, errors.Is(err, ErrDownloadTokenFailed),
"delegated 5xx 不應掛 ErrDownloadTokenFailed§6 該 sentinel 限 4xx 用)")
assert.Equal(t, int32(3), dCounter.Load(), "5xx 應 attempt 3 次")
}
func TestIssueDelegatedDownload_Server401_PropagateUnauthorized(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{
downloadStatusFn: func(int) int { return 401 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"download 401 應 mapping 到 ErrServiceClientUnauthorized, got %v", err)
assert.Equal(t, int32(1), dCounter.Load(), "401 不應 retry")
}
func TestIssueDelegatedDownload_ServiceTokenFailure_Propagated(t *testing.T) {
t.Parallel()
srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{
tokenStatusFn: func(int) int { return 500 }, // service token 完全取不到
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
// §6失敗源頭是 service_token endpoint 5xx → ErrIDPUnavailable
// IssueDelegatedDownload 用 fmt.Errorf("%w") 透傳,不會升級成 ErrMCTokenUnavailable
// 確保前端 i18n 能正確顯示「認證服務暫時無法使用」而非「無法取得下載授權」。
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"service token 5xx 透傳 → ErrIDPUnavailable§6, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"不應被升級成 ErrMCTokenUnavailable否則 i18n 訊息會錯")
assert.Equal(t, int32(3), tCounter.Load(), "service token 5xx 應 attempt 3 次")
assert.Equal(t, int32(0), dCounter.Load(), "service token 失敗時不應打 download endpoint")
}
// TestIssueDelegatedDownload_ServiceTokenAuthFailure_Propagated — service_token 401/403 透傳。
//
// §6 mapping401/403 用 ErrServiceClientUnauthorized對外仍 mask 成 idp_misconfigured/500
// 確認 IssueDelegatedDownload 用 fmt.Errorf("%w") 透傳後errors.Is 仍能命中。
func TestIssueDelegatedDownload_ServiceTokenAuthFailure_Propagated(t *testing.T) {
t.Parallel()
srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{
tokenStatusFn: func(int) int { return 401 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"service token 401 透傳 → ErrServiceClientUnauthorized§5.2, got %v", err)
assert.Equal(t, int32(1), tCounter.Load(), "401 不應 retry")
assert.Equal(t, int32(0), dCounter.Load(), "service token 401 時不應打 download endpoint")
}
// TestIssueDelegatedDownload_ServiceToken4xxNonAuth_Propagated — service_token 400 透傳成 IDP 設定錯誤。
//
// §6 mappingservice_token 4xx (非 401/403) → ErrIDPMisconfigured500/idp_misconfigured
// 這是「IDP grant 設定錯」而非「下載授權失敗」— 區分 i18n 訊息。
func TestIssueDelegatedDownload_ServiceToken4xxNonAuth_Propagated(t *testing.T) {
t.Parallel()
srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{
tokenStatusFn: func(int) int { return 400 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrIDPMisconfigured),
"service token 400 透傳 → ErrIDPMisconfigured§6, got %v", err)
assert.False(t, errors.Is(err, ErrDownloadTokenFailed),
"不應掛 ErrDownloadTokenFailed那是 delegated endpoint 4xx 的錯誤碼)")
assert.Equal(t, int32(1), tCounter.Load(), "400 不應 retry")
assert.Equal(t, int32(0), dCounter.Load(), "service token 4xx 時不應打 download endpoint")
}
func TestIssueDelegatedDownload_RequiredFieldsValidation(t *testing.T) {
t.Parallel()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: "http://localhost:9999", // 不會真的打到
ClientID: "id",
ClientSecret: "sec",
Logger: silentLogger(),
})
cases := []struct {
name string
in IssueDownloadReq
}{
{"empty_tenant", IssueDownloadReq{UserID: "u", ObjectKey: "k"}},
{"empty_user", IssueDownloadReq{TenantID: "t", ObjectKey: "k"}},
{"empty_object_key", IssueDownloadReq{TenantID: "t", UserID: "u"}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := c.IssueDelegatedDownload(context.Background(), tc.in)
require.Error(t, err, "缺必填欄位應 fail-fast")
})
}
}
// ==========================================================================
// Constructor / 邊界
// ==========================================================================
func TestNewMCTokenClient_NilOptsDefaults(t *testing.T) {
t.Parallel()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: "http://example.com/",
ClientID: "id",
ClientSecret: "sec",
})
require.NotNil(t, c)
// 透過 type assertion 檢查預設值有套用(這是內部檢查;
// 平常 caller 不該 assert 內部 struct但 test 可以)
impl, ok := c.(*mcTokenClient)
require.True(t, ok)
assert.NotNil(t, impl.http, "HTTPClient nil 時應有預設")
assert.NotNil(t, impl.now, "Now nil 時應有預設")
assert.NotNil(t, impl.logger, "Logger nil 時應有預設")
assert.Equal(t, "http://example.com", impl.issuer, "issuer 結尾斜線應被移除")
}
func TestServiceToken_EmptyScope_ReturnsError(t *testing.T) {
t.Parallel()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: "http://localhost:9999",
ClientID: "id",
ClientSecret: "sec",
Logger: silentLogger(),
})
_, err := c.ServiceToken(context.Background(), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "scope is required")
}