# 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>
301 lines
12 KiB
Go
301 lines
12 KiB
Go
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 刻意留 nil:preset 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_PresetReturnsVisionAURL:preset 回 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 不簽 token:token 欄位應為空(omitempty 後可能不存在或為空字串)。
|
||
assert.Empty(t, data["token"])
|
||
}
|
||
|
||
// TestModelsDownload_PresetWorksWithoutFAAConfig:preset download 完全不依賴 FAA 配置。
|
||
// fixture 的 FileAccessIssuer 為 nil(未配置 FAA),preset 仍應成功。
|
||
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_PresetNotConfiguredReturns501:preset 目錄 / 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_ConvertedStillUsesFAA:converted 類仍走 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_UploadedStillReturns501:uploaded 類(無 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 不在 DB,delete 應回 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 不在 DB,ModelRepo.Get 回 ErrNotFound → 404(合理錯誤,不 panic)。
|
||
assert.Equal(t, http.StatusNotFound, w.Code, "body=%s", w.Body.String())
|
||
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// preset 靜態下載 handler(GET /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-2:Content-Disposition 經 mime.FormatMediaType 產生(安全檔名不會被 corrupt)。
|
||
// 安全字元檔名 mime 會輸出 unquoted;special 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())
|
||
}
|