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>
865 lines
28 KiB
Go
865 lines
28 KiB
Go
// 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(`<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 同時要 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")
|
||
}
|