從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
6.6 KiB
Go
209 lines
6.6 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)
|
||
}
|
||
|
||
// 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)
|
||
assert.Len(t, arr, 1, "只應看到自己的 model")
|
||
first := arr[0].(map[string]any)
|
||
assert.Equal(t, "my", first["id"])
|
||
}
|