對齊 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>
163 lines
5.8 KiB
Go
163 lines
5.8 KiB
Go
package conversion
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestErrorCode 確保所有 sentinel error 都對應到一個明確的 visionA error code,
|
||
// 且未匹配的 error 走 internal_error fallback(對齊 api-conversion.md §錯誤碼總覽)。
|
||
func TestErrorCode(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
want string
|
||
}{
|
||
{"forbidden", ErrForbidden, "forbidden"},
|
||
{"not_found", ErrJobNotFound, "not_found"},
|
||
{"job_not_completed", ErrJobNotCompleted, "job_not_completed"},
|
||
{"active_job_exists", ErrActiveJobExists, "active_job_exists"},
|
||
{"validation_failed", ErrValidationFailed, "validation_failed"},
|
||
{"payload_too_large", ErrPayloadTooLarge, "payload_too_large"},
|
||
{"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"},
|
||
{"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"},
|
||
{"service_busy", ErrServiceBusy, "service_busy"},
|
||
// Phase 0.8b T3:以下 sentinel 已移除,不再對外暴露對應 error code
|
||
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
|
||
// ErrIDPUnavailable / ErrServiceClientUnauthorized
|
||
// 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed(下方 wrapped)
|
||
{"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"},
|
||
{"faa_auth_failed_masked_as_faa_unavailable", ErrFAAAuthFailed, "faa_unavailable"},
|
||
// Reviewer M-1:visionA 自身基礎設施失敗用獨立 code(與 FAA / converter 區分)
|
||
{"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"},
|
||
{"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"},
|
||
{"unknown_falls_back_to_internal_error", errors.New("某個未預期錯誤"), "internal_error"},
|
||
{"nil_falls_back_to_internal_error", nil, "internal_error"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
assert.Equal(t, tc.want, ErrorCode(tc.err))
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestHTTPStatus 確保所有 sentinel error 對應到正確的 HTTP status,
|
||
// 且未匹配的 error 走 500 fallback(對齊 conversion.md §6 mapping)。
|
||
func TestHTTPStatus(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
want int
|
||
}{
|
||
{"forbidden_403", ErrForbidden, 403},
|
||
{"not_found_404", ErrJobNotFound, 404},
|
||
{"job_not_completed_409", ErrJobNotCompleted, 409},
|
||
{"active_job_exists_409", ErrActiveJobExists, 409},
|
||
{"validation_400", ErrValidationFailed, 400},
|
||
{"payload_too_large_413", ErrPayloadTooLarge, 413},
|
||
{"converter_unavailable_502", ErrConverterUnavailable, 502},
|
||
{"faa_unavailable_502", ErrFAAUnavailable, 502},
|
||
{"service_busy_503", ErrServiceBusy, 503},
|
||
// Phase 0.8b T3:以下 sentinel 已移除,對外不再 mapping HTTP status
|
||
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
|
||
// ErrIDPUnavailable / ErrServiceClientUnauthorized
|
||
// 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed (HTTP 502)
|
||
{"converter_auth_failed_502", ErrConverterAuthFailed, 502},
|
||
{"faa_auth_failed_502", ErrFAAAuthFailed, 502},
|
||
// Reviewer M-1:visionA 自身基礎設施失敗 → 500(不是 502 gateway)
|
||
{"storage_unavailable_500", ErrStorageUnavailable, 500},
|
||
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500},
|
||
{"unknown_500", errors.New("未知錯誤"), 500},
|
||
{"nil_500", nil, 500},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
assert.Equal(t, tc.want, HTTPStatus(tc.err))
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestActiveJobError 驗證 wrapped form 既能被 errors.Is 比對,又能用 errors.As 取出 Job。
|
||
//
|
||
// 這是 frontend 顯示「你已有進行中任務」+ 跳轉到該 job 進度頁的關鍵:handler 用 errors.As
|
||
// 取出 Job 帶到 response details。
|
||
func TestActiveJobError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
job := &Job{JobID: "job-abc", Status: "running"}
|
||
err := &ActiveJobError{Job: job}
|
||
|
||
// errors.Is 應命中 sentinel
|
||
assert.True(t, errors.Is(err, ErrActiveJobExists))
|
||
|
||
// errors.As 應拿到 wrapped 結構
|
||
var ae *ActiveJobError
|
||
assert.True(t, errors.As(err, &ae))
|
||
assert.NotNil(t, ae.Job)
|
||
assert.Equal(t, "job-abc", ae.Job.JobID)
|
||
|
||
// ErrorCode 應仍透過 sentinel 對應到 active_job_exists
|
||
assert.Equal(t, "active_job_exists", ErrorCode(err))
|
||
assert.Equal(t, 409, HTTPStatus(err))
|
||
}
|
||
|
||
// TestConverterValidationError 驗證 wrapped validation error 同樣行為。
|
||
func TestConverterValidationError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
verr := &ConverterValidationError{
|
||
Fields: []ValidationFieldError{
|
||
{Field: "platform", Message: "must be 520 or 720"},
|
||
},
|
||
Message: "platform invalid",
|
||
}
|
||
|
||
assert.True(t, errors.Is(verr, ErrValidationFailed))
|
||
|
||
var ve *ConverterValidationError
|
||
assert.True(t, errors.As(verr, &ve))
|
||
require.Len(t, ve.Fields, 1)
|
||
assert.Equal(t, "platform", ve.Fields[0].Field)
|
||
assert.Equal(t, "must be 520 or 720", ve.Fields[0].Message)
|
||
|
||
assert.Equal(t, "validation_failed", ErrorCode(verr))
|
||
assert.Equal(t, 400, HTTPStatus(verr))
|
||
|
||
// Error() 應包含 Message(給 log 用)
|
||
assert.Contains(t, verr.Error(), "platform invalid")
|
||
|
||
// Message 為空時退化到 sentinel 訊息
|
||
verr2 := &ConverterValidationError{}
|
||
assert.Equal(t, ErrValidationFailed.Error(), verr2.Error())
|
||
}
|
||
|
||
// TestErrorWrapping 驗證 fmt.Errorf("%w") wrapping 後仍能被 ErrorCode 抓對。
|
||
//
|
||
// 這個測試模擬 flow.go 預期的 wrap pattern:
|
||
//
|
||
// if err := convClient.GetJob(...); err != nil {
|
||
// return fmt.Errorf("flow: get job from converter: %w", err)
|
||
// }
|
||
func TestErrorWrapping(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
wrapped := fmt.Errorf("flow: get job: %w", ErrJobNotFound)
|
||
assert.True(t, errors.Is(wrapped, ErrJobNotFound))
|
||
assert.Equal(t, "not_found", ErrorCode(wrapped))
|
||
assert.Equal(t, 404, HTTPStatus(wrapped))
|
||
}
|