// MC Token Client 單元測試。 // // 測試策略: // - 用 httptest.Server mock MC,accept 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 counter(atomic,可用來驗 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.Values,key 是 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.Map(r.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(``)) 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 同時要 token,DCL 確保只 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) // §6:MC 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) // §6:MC 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 應立即 return(context.Canceled 或 ErrIDPUnavailable wrap),got %v", err) // counter 可能是 1(server 收到了但 client 在等回應時 cancel);不應該 retry assert.LessOrEqual(t, counter.Load(), int32(1), "ctx cancel 不應 retry,counter <= 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) // §6:service_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) // §6:service_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) // §6:MC 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) // §6:MC 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 mapping:401/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 mapping:service_token 4xx (非 401/403) → ErrIDPMisconfigured(500/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") }