visionA/visionA-backend/internal/api/models_preset_test.go
jim800121chen 9ab1a11ea4 feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token)
模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。
補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json):
- metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複
- 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY)
- download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權):
  download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501
- 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition
  attachment 用 mime.FormatMediaType、path-traversal 防禦)
- list/get 含 preset(對所有人可見)

# B5 詳細頁下載按鈕 + preset 可下載
- model-detail-client.tsx 補下載按鈕(行為對齊列表卡片)
- isModelDownloadable 加 preset(source==="preset" → true)
- normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520
  在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫)

# 測試
backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠
frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS
Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題)

# backlog(未做的優化)
preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:38:25 +08:00

301 lines
12 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 api
import (
"context"
"encoding/json"
"mime"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/model"
)
// newPresetFixture 建一個帶 preset 配置的 models route fixture。
//
// presetDir / presetBaseURL 空時模擬「preset download 未配置」。
// 同時設 FileAccessIssuer=nil 確保 preset 分支完全獨立於 FAA。
func newPresetFixture(t *testing.T, presetDir, presetBaseURL, 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,
PresetModelsDir: presetDir,
PresetBaseURL: presetBaseURL,
// FileAccessIssuer 刻意留 nilpreset download 不該依賴 FAA。
})
return r, repo
}
// ==========================================================================
// list — preset 對所有人可見
// ==========================================================================
func TestModelsList_IncludesPresetsForEveryone(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
require.Len(t, arr, 7, "無 user model 時應只回 7 個 preset")
ids := map[string]bool{}
for _, item := range arr {
ids[item.(map[string]any)["id"].(string)] = true
}
for _, id := range []string{
"kl520-yolov5-detection", "kl520-fcos-detection", "kl520-ssd-face-detection",
"kl520-tiny-yolov3", "kl720-yolov5-detection", "kl720-resnet18-classification",
"kl720-fcos-detection",
} {
assert.True(t, ids[id], "preset %s 應在 list 中", id)
}
}
// TestModelsList_PresetVisibleWhenRepoNil 驗證即使無 ModelRepo最小骨架preset 仍可見。
func TestModelsList_PresetVisibleWhenRepoNil(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
g := r.Group("/api")
registerModelRoutes(g, Deps{MaxUploadSizeMB: 10}) // ModelRepo nil
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
assert.Len(t, arr, 7)
}
// ==========================================================================
// get — preset 查得到(無 ownership 限制)
// ==========================================================================
func TestModelsGet_PresetFound(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "any-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl720-resnet18-classification", nil))
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, "kl720-resnet18-classification", data["id"])
assert.Equal(t, "ImageNet Classification ResNet18 (KL720)", data["name"])
assert.Equal(t, "preset", data["source"])
assert.Equal(t, "KL720", data["target_chip"])
assert.Equal(t, "ready", data["status"])
}
// ==========================================================================
// download — 三分支preset / converted / uploaded
// ==========================================================================
// TestModelsDownload_PresetReturnsVisionAURLpreset 回 visionA 自己的 URL非 FAA、非 501
func TestModelsDownload_PresetReturnsVisionAURL(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-yolov5-detection/download", nil))
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,
"http://localhost:3721/preset-models/kl520-yolov5-detection.nef",
data["download_url"])
// preset 不簽 tokentoken 欄位應為空omitempty 後可能不存在或為空字串)。
assert.Empty(t, data["token"])
}
// TestModelsDownload_PresetWorksWithoutFAAConfigpreset download 完全不依賴 FAA 配置。
// fixture 的 FileAccessIssuer 為 nil未配置 FAApreset 仍應成功。
func TestModelsDownload_PresetWorksWithoutFAAConfig(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl720-fcos-detection/download", nil))
assert.Equal(t, http.StatusOK, w.Code, "preset 不需 FAA 也能下載; body=%s", w.Body.String())
}
// TestModelsDownload_PresetNotConfiguredReturns501preset 目錄 / baseURL 未配置 → 501。
func TestModelsDownload_PresetNotConfiguredReturns501(t *testing.T) {
r, _ := newPresetFixture(t, "", "", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-tiny-yolov3/download", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code, "body=%s", w.Body.String())
}
// TestModelsDownload_ConvertedStillUsesFAAconverted 類仍走 FAA沒被 preset 分支破壞)。
func TestModelsDownload_ConvertedStillUsesFAA(t *testing.T) {
exp := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
iss := &fakeIssuer{token: "fdt_abc", expiresAt: exp}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-conv", "demo-user", "models/demo-user/job.nef")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m-conv/download", nil))
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.nef?access_token=fdt_abc",
data["download_url"])
assert.Equal(t, 1, iss.calls, "converted 類仍應簽 FAA token")
}
// TestModelsDownload_UploadedStillReturns501uploaded 類(無 FAAObjectKey、非 preset仍 501。
func TestModelsDownload_UploadedStillReturns501(t *testing.T) {
iss := &fakeIssuer{token: "fdt_x"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
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()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code)
assert.Equal(t, 0, iss.calls)
}
// ==========================================================================
// delete — preset 不在 DBdelete 應回 404不炸
// ==========================================================================
func TestModelsDelete_PresetReturns404(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/kl520-fcos-detection", nil))
// preset 不在 DBModelRepo.Get 回 ErrNotFound → 404合理錯誤不 panic
assert.Equal(t, http.StatusNotFound, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
}
// ==========================================================================
// preset 靜態下載 handlerGET /preset-models/*
// ==========================================================================
// newPresetServeFixture 建一個含實際 .nef 檔的 preset serve fixture。
func newPresetServeFixture(t *testing.T) (*gin.Engine, string) {
t.Helper()
dir := t.TempDir()
content := []byte("fake-nef-bytes-kl520-yolov5")
require.NoError(t, os.WriteFile(filepath.Join(dir, "kl520-yolov5-detection.nef"), content, 0o644))
r := gin.New()
r.Use(RequestIDMiddleware())
// 註冊條件對稱Minor-1須同時帶 PresetModelsDir + PresetBaseURL 才註冊路由。
registerPresetModelRoutes(r, Deps{PresetModelsDir: dir, PresetBaseURL: "http://localhost:3721"})
return r, dir
}
func TestPresetServe_OK(t *testing.T) {
r, _ := newPresetServeFixture(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/kl520-yolov5-detection.nef", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
assert.Equal(t, "fake-nef-bytes-kl520-yolov5", w.Body.String())
// Minor-2Content-Disposition 經 mime.FormatMediaType 產生(安全檔名不會被 corrupt
// 安全字元檔名 mime 會輸出 unquotedspecial char 才加引號 escape防 header injection
cd := w.Header().Get("Content-Disposition")
assert.True(t, strings.HasPrefix(cd, "attachment"), "應為 attachment disposition: %q", cd)
assert.Contains(t, cd, "kl520-yolov5-detection.nef")
// 可被標準 parser 正確還原 → 證明 header 結構合法(無破壞)。
mediaType, params, err := mime.ParseMediaType(cd)
require.NoError(t, err, "Content-Disposition 應可被標準 parser 解析: %q", cd)
assert.Equal(t, "attachment", mediaType)
assert.Equal(t, "kl520-yolov5-detection.nef", params["filename"])
}
func TestPresetServe_NotFound(t *testing.T) {
r, _ := newPresetServeFixture(t)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/does-not-exist.nef", nil))
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestPresetServe_RejectsTraversal 驗證 path-traversal 被擋(不會讀到 root 外的檔)。
func TestPresetServe_RejectsTraversal(t *testing.T) {
r, _ := newPresetServeFixture(t)
// gin 會先 normalize URL path故直接打 handler 用的 raw param 較難觸發;
// 這裡驗證 handler 對含 ".." 的 filepath 一律 400防禦深度
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/preset-models/..%2f..%2fetc%2fpasswd", nil)
r.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code, "traversal 不應成功讀檔")
}
// TestPresetServe_NotRegisteredWhenDirEmpty 驗證 PresetModelsDir 空時不註冊路由。
func TestPresetServe_NotRegisteredWhenDirEmpty(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
registerPresetModelRoutes(r, Deps{PresetModelsDir: ""})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/anything.nef", nil))
assert.Equal(t, http.StatusNotFound, w.Code, "路由未註冊 → gin 預設 404")
}
// TestPresetServe_NotRegisteredWhenBaseURLEmpty 驗證 Minor-1 對稱條件:
// PresetModelsDir 有設但 PresetBaseURL 空 → route 也不註冊(與 download handler 501 對稱),
// 避免「download 501 但 serve route 仍活著」的狀態不一致。
func TestPresetServe_NotRegisteredWhenBaseURLEmpty(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
registerPresetModelRoutes(r, Deps{PresetModelsDir: t.TempDir(), PresetBaseURL: ""})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/anything.nef", nil))
assert.Equal(t, http.StatusNotFound, w.Code, "PresetBaseURL 空 → route 不註冊 → 404")
}
// TestModelsDownload_PresetMissingBaseURLReturns501 驗證 download 端對稱條件:
// PresetModelsDir 有設但 PresetBaseURL 空 → download preset 回 501與 route 不註冊對稱)。
func TestModelsDownload_PresetMissingBaseURLReturns501(t *testing.T) {
r, _ := newPresetFixture(t, t.TempDir(), "", "demo-user")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-tiny-yolov3/download", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code, "body=%s", w.Body.String())
}