# 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>
341 lines
12 KiB
Go
341 lines
12 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/storage"
|
||
)
|
||
|
||
// 建一個 in-memory fixture(storage + model repo)給 models_test 用。
|
||
func newModelsFixture(t *testing.T) (*gin.Engine, *model.InMemoryRepository, *storage.LocalFSStore) {
|
||
t.Helper()
|
||
|
||
dir := t.TempDir()
|
||
st, err := storage.NewLocalFSStore(dir, "http://api/storage", "test-secret")
|
||
require.NoError(t, err)
|
||
|
||
repo := model.NewInMemoryRepository()
|
||
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
// Phase 0.7 security fix C1:injectStaticUserContext 顯式注入 UserContext。
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
registerModelRoutes(g, Deps{
|
||
ModelRepo: repo,
|
||
Storage: st,
|
||
MaxUploadSizeMB: 10,
|
||
})
|
||
return r, repo, st
|
||
}
|
||
|
||
// TestModelsInit_OK 驗證 init 能成功:建立 pending 紀錄並回 upload_url。
|
||
func TestModelsInit_OK(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
body := strings.NewReader(`{"name":"m1","file_size":1024}`)
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/models/init", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
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)
|
||
|
||
modelID, _ := data["model_id"].(string)
|
||
require.NotEmpty(t, modelID)
|
||
assert.Contains(t, data["upload_url"].(string), "signature=")
|
||
|
||
// Repo 中應已有 pending 紀錄(UploadedAt == nil)
|
||
m, err := repo.Get(context.Background(), modelID)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, m.UploadedAt)
|
||
assert.Equal(t, int64(1024), m.FileSize)
|
||
}
|
||
|
||
// TestModelsInit_NameMissing 驗證沒 name 回 400。
|
||
func TestModelsInit_NameMissing(t *testing.T) {
|
||
r, _, _ := newModelsFixture(t)
|
||
|
||
body := strings.NewReader(`{"file_size":1024}`)
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/models/init", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
|
||
}
|
||
|
||
// TestModelsInit_TooLarge 驗證超過限制回 413。
|
||
func TestModelsInit_TooLarge(t *testing.T) {
|
||
r, _, _ := newModelsFixture(t)
|
||
// MaxUploadSizeMB=10,送 11MB
|
||
body := strings.NewReader(`{"name":"big","file_size":11534336}`) // 11 MB
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/models/init", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodePayloadTooLarge)
|
||
}
|
||
|
||
// TestModelsFinalize_FileNotUploaded 驗證 finalize 在沒實際 PUT 前回 400。
|
||
func TestModelsFinalize_FileNotUploaded(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
// 先塞一筆 pending model(沒實際檔案)
|
||
now := time.Now().UTC()
|
||
m := &model.Model{
|
||
ID: "mdl-1",
|
||
OwnerUserID: "demo-user",
|
||
Name: "x",
|
||
FileSize: 100,
|
||
StorageKey: "models/demo-user/mdl-1.nef",
|
||
Source: model.SourceUploaded,
|
||
CreatedAt: now,
|
||
}
|
||
require.NoError(t, repo.Save(context.Background(), m))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/models/mdl-1/finalize", nil))
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), "file not uploaded")
|
||
}
|
||
|
||
// TestModelsFinalize_SizeMismatch 驗證實際檔案大小對不上 file_size 回 400。
|
||
func TestModelsFinalize_SizeMismatch(t *testing.T) {
|
||
r, repo, st := newModelsFixture(t)
|
||
|
||
// 塞 pending model(宣稱 100 bytes)
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-2",
|
||
OwnerUserID: "demo-user",
|
||
Name: "x",
|
||
FileSize: 100,
|
||
StorageKey: "models/demo-user/mdl-2.nef",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
// 實際檔案寫 10 bytes(Size 不符)
|
||
require.NoError(t, st.Put(context.Background(), "models/demo-user/mdl-2.nef",
|
||
strings.NewReader("0123456789"), 10, nil))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/models/mdl-2/finalize", nil))
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), "size mismatch")
|
||
}
|
||
|
||
// TestModelsFinalize_OK 驗證 happy path:檔案已存在、size 對得上,標 ready。
|
||
func TestModelsFinalize_OK(t *testing.T) {
|
||
r, repo, st := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-3",
|
||
OwnerUserID: "demo-user",
|
||
Name: "x",
|
||
FileSize: 5,
|
||
StorageKey: "models/demo-user/mdl-3.nef",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
require.NoError(t, st.Put(context.Background(), "models/demo-user/mdl-3.nef",
|
||
strings.NewReader("hello"), 5, nil))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/models/mdl-3/finalize", nil))
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
// Repo 中應已 UploadedAt 被設
|
||
m, err := repo.Get(context.Background(), "mdl-3")
|
||
require.NoError(t, err)
|
||
assert.NotNil(t, m.UploadedAt)
|
||
}
|
||
|
||
// TestModelsDelete_NotOwner 驗證非 owner 不能刪。
|
||
func TestModelsDelete_NotOwner(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
// 塞一個「別人」的 model
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-other",
|
||
OwnerUserID: "other-user",
|
||
Name: "x",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/mdl-other", nil))
|
||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||
}
|
||
|
||
// TestModelsGet_HTTPResponseCarriesMetadata 驗證 B4 metadata 鏈路的最後序列化點:
|
||
// GET /api/models/:id 的 HTTP JSON response 真的含 input_shape / classes / framework。
|
||
//
|
||
// 這是先前測試的盲區——舊測試只測到 model.Model 落地(dbtest)為止、沒測最外層 HTTP
|
||
// JSON。此測試直接 call handler → 解析 response JSON → 斷言三欄都在裡面且值正確。
|
||
func TestModelsGet_HTTPResponseCarriesMetadata(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
now := time.Now().UTC()
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-meta",
|
||
OwnerUserID: "demo-user",
|
||
Name: "metamodel",
|
||
Source: model.SourceConverted,
|
||
InputShape: []int{1, 3, 224, 224},
|
||
Classes: []string{"cat", "dog"},
|
||
Framework: "onnx",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-meta", 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, ok := sb.Data.(map[string]any)
|
||
require.True(t, ok, "data should be an object")
|
||
|
||
// input_shape 必須在最外層 HTTP JSON 裡(snake_case,前端 normalize 讀得到)。
|
||
rawShape, present := data["input_shape"]
|
||
require.True(t, present, "input_shape must be present in HTTP JSON response; body=%s", w.Body.String())
|
||
shape, ok := rawShape.([]any)
|
||
require.True(t, ok)
|
||
require.Len(t, shape, 4)
|
||
assert.EqualValues(t, 1, shape[0])
|
||
assert.EqualValues(t, 3, shape[1])
|
||
assert.EqualValues(t, 224, shape[2])
|
||
assert.EqualValues(t, 224, shape[3])
|
||
|
||
// classes / framework 同樣要帶到。
|
||
classes, ok := data["classes"].([]any)
|
||
require.True(t, ok, "classes must be present; body=%s", w.Body.String())
|
||
require.Len(t, classes, 2)
|
||
assert.Equal(t, "cat", classes[0])
|
||
assert.Equal(t, "dog", classes[1])
|
||
|
||
assert.Equal(t, "onnx", data["framework"])
|
||
}
|
||
|
||
// TestModelsGet_OmitsMetadataWhenAbsent 驗證 omitempty:無 metadata 的 model(如上傳類 /
|
||
// 舊 model)回應不含 input_shape / classes / framework,不破壞既有回應結構。
|
||
func TestModelsGet_OmitsMetadataWhenAbsent(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-nometa",
|
||
OwnerUserID: "demo-user",
|
||
Name: "plain",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-nometa", 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, ok := sb.Data.(map[string]any)
|
||
require.True(t, ok)
|
||
|
||
_, hasShape := data["input_shape"]
|
||
_, hasClasses := data["classes"]
|
||
_, hasFramework := data["framework"]
|
||
assert.False(t, hasShape, "input_shape should be omitted when empty")
|
||
assert.False(t, hasClasses, "classes should be omitted when empty")
|
||
assert.False(t, hasFramework, "framework should be omitted when empty")
|
||
}
|
||
|
||
// TestModelsList_HTTPResponseCarriesMetadata 驗證 list endpoint(GET /api/models)的
|
||
// HTTP JSON 也帶 input_shape(列表頁雖非 B4 主顯示處,仍確認鏈路一致)。
|
||
func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-list-meta",
|
||
OwnerUserID: "demo-user",
|
||
Name: "listed",
|
||
Source: model.SourceConverted,
|
||
InputShape: []int{1, 28, 28},
|
||
Framework: "tflite",
|
||
}))
|
||
|
||
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)
|
||
// B8:list = 7 preset + 1 user model = 8 筆。找出 user model 那筆(不假設位置)。
|
||
require.Len(t, arr, 8)
|
||
var first map[string]any
|
||
for _, item := range arr {
|
||
m := item.(map[string]any)
|
||
if m["id"] == "mdl-list-meta" {
|
||
first = m
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, first, "user model must be in list; body=%s", w.Body.String())
|
||
|
||
rawShape, present := first["input_shape"]
|
||
require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String())
|
||
shape, ok := rawShape.([]any)
|
||
require.True(t, ok)
|
||
require.Len(t, shape, 3)
|
||
assert.EqualValues(t, 1, shape[0])
|
||
assert.EqualValues(t, 28, shape[1])
|
||
assert.EqualValues(t, 28, shape[2])
|
||
assert.Equal(t, "tflite", first["framework"])
|
||
}
|
||
|
||
// TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。
|
||
func TestModelsList_FiltersByOwner(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "my", OwnerUserID: "demo-user", Name: "mine", Source: model.SourceUploaded,
|
||
}))
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "other", OwnerUserID: "other-user", Name: "theirs", Source: model.SourceUploaded,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
arr, ok := sb.Data.([]any)
|
||
require.True(t, ok)
|
||
// B8:list = 7 preset(公用)+ 只有自己的那 1 筆 user model = 8 筆。
|
||
// 不應看到 other-user 的 model;preset 對所有人可見。
|
||
require.Len(t, arr, 8, "7 preset + 1 own model")
|
||
ids := map[string]bool{}
|
||
for _, item := range arr {
|
||
ids[item.(map[string]any)["id"].(string)] = true
|
||
}
|
||
assert.True(t, ids["my"], "應看到自己的 model")
|
||
assert.False(t, ids["other"], "不應看到別人的 model")
|
||
assert.True(t, ids["kl520-yolov5-detection"], "preset 應對所有人可見")
|
||
}
|