jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

163 lines
5.8 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.

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-1visionA 自身基礎設施失敗用獨立 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-1visionA 自身基礎設施失敗 → 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))
}