package api import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/fileaccess" "visiona-backend/internal/model" ) // ========================================================================== // fake DownloadTokenIssuer // ========================================================================== type fakeIssuer struct { token string expiresAt time.Time err error // 記錄被呼叫的參數,驗 handler 傳對 userID / objectKey。 gotUserID string gotObjectKey string calls int } func (f *fakeIssuer) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*fileaccess.IssuedDownloadToken, error) { f.calls++ f.gotUserID = userID f.gotObjectKey = objectKey if f.err != nil { return nil, f.err } return &fileaccess.IssuedDownloadToken{ Token: f.token, TokenType: "file_download", ExpiresAt: f.expiresAt, Scope: "files:download.read", }, nil } // newDownloadFixture 建一個帶 fake issuer 的 models route fixture。 // // issuer 為 nil 時模擬「FAA download 未啟用」(main.go 不 wire)。 // faaBaseURL 空時也視為未配置。 func newDownloadFixture(t *testing.T, issuer fileaccess.DownloadTokenIssuer, faaBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) { t.Helper() repo := model.NewInMemoryRepository() r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext(userID, "")) g := r.Group("/api") registerModelRoutes(g, Deps{ ModelRepo: repo, MaxUploadSizeMB: 10, FileAccessIssuer: issuer, FAABaseURL: faaBaseURL, }) return r, repo } // seedConvertedModel 直接塞一個「轉檔→promote」類 model(有 FAAObjectKey)。 func seedConvertedModel(t *testing.T, repo *model.InMemoryRepository, id, owner, faaKey string) { t.Helper() now := time.Now().UTC() require.NoError(t, repo.Save(context.Background(), &model.Model{ ID: id, OwnerUserID: owner, Name: "converted-model", StorageKey: "models/" + owner + "/" + id + ".nef", FAAObjectKey: faaKey, FileSize: 1024, Source: model.SourceConverted, UploadedAt: &now, CreatedAt: now, UpdatedAt: now, })) } // ========================================================================== // happy path // ========================================================================== func TestModelsDownload_OK(t *testing.T) { exp := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC) iss := &fakeIssuer{token: "fdt_abc123", expiresAt: exp} r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") seedConvertedModel(t, repo, "m-conv-1", "demo-user", "models/demo-user/job-1.nef") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/m-conv-1/download", nil) r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "https://faa.example.com:5081/files/models/demo-user/job-1.nef", data["download_url"]) assert.Equal(t, "fdt_abc123", data["token"]) assert.Contains(t, data["expires_at"], "2026-06-07") // issuer 必須拿到正確的 userID(OIDC sub)+ objectKey(FAAObjectKey,非 StorageKey) assert.Equal(t, "demo-user", iss.gotUserID) assert.Equal(t, "models/demo-user/job-1.nef", iss.gotObjectKey) assert.Equal(t, 1, iss.calls) } // ========================================================================== // 501 — 未配置 / 上傳類 // ========================================================================== func TestModelsDownload_NotConfiguredWhenIssuerNil(t *testing.T) { r, repo := newDownloadFixture(t, nil, "", "demo-user") seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotImplemented, w.Code) assert.Contains(t, w.Body.String(), ErrCodeNotImplemented) } func TestModelsDownload_NotConfiguredWhenFAABaseURLEmpty(t *testing.T) { iss := &fakeIssuer{token: "fdt_x"} // issuer 有,但 FAABaseURL 空 → 仍視為未配置 r, repo := newDownloadFixture(t, iss, "", "demo-user") seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotImplemented, w.Code) assert.Equal(t, 0, iss.calls, "should not call issuer when not configured") } func TestModelsDownload_UploadedModelReturns501(t *testing.T) { iss := &fakeIssuer{token: "fdt_x"} r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") // 上傳類 model:無 FAAObjectKey now := time.Now().UTC() require.NoError(t, repo.Save(context.Background(), &model.Model{ ID: "m-upload", OwnerUserID: "demo-user", Name: "uploaded", StorageKey: "models/demo-user/m-upload.nef", Source: model.SourceUploaded, UploadedAt: &now, CreatedAt: now, UpdatedAt: now, })) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotImplemented, w.Code) assert.Contains(t, w.Body.String(), "uploaded models") assert.Equal(t, 0, iss.calls, "should not issue token for uploaded model") } // ========================================================================== // 404 / 403 — ownership // ========================================================================== func TestModelsDownload_NotFound(t *testing.T) { iss := &fakeIssuer{token: "fdt_x"} r, _ := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/download", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) assert.Contains(t, w.Body.String(), ErrCodeNotFound) } func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) { iss := &fakeIssuer{token: "fdt_x"} // 登入 user = demo-user,但 model owner = other-user r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code) assert.Contains(t, w.Body.String(), ErrCodeForbidden) assert.Equal(t, 0, iss.calls, "should not issue token for non-owner") } // ========================================================================== // 502 — issuer 失敗 // ========================================================================== func TestModelsDownload_IssuerFailureReturns502(t *testing.T) { iss := &fakeIssuer{err: errors.New("mc unavailable")} r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadGateway, w.Code) assert.Contains(t, w.Body.String(), ErrCodeInternalError) // 對外 mask:不應洩漏 "mc unavailable" 內部細節 assert.NotContains(t, w.Body.String(), "mc unavailable") }