B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
8.9 KiB
Go
251 lines
8.9 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"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/device"
|
||
)
|
||
|
||
// newRegisterFixture 建 router(InMemory repo + 無 session),user context = demo-user。
|
||
// 回傳 router + repo 供測試直接塞 device / 驗 registered_at。
|
||
func newRegisterFixture(t *testing.T) (*gin.Engine, *device.InMemoryRepository) {
|
||
t.Helper()
|
||
repo := device.NewInMemoryRepository()
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
registerDeviceRoutes(g, Deps{
|
||
DeviceRepo: repo,
|
||
SessionStore: &fakeSessionStore{},
|
||
})
|
||
return r, repo
|
||
}
|
||
|
||
func postRegister(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
|
||
return w
|
||
}
|
||
|
||
// errCodeOf 解析錯誤回應的 error.code。
|
||
func errCodeOf(t *testing.T, w *httptest.ResponseRecorder) string {
|
||
t.Helper()
|
||
var eb ErrorBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb), "body=%s", w.Body.String())
|
||
require.NotNil(t, eb.Error)
|
||
return eb.Error.Code
|
||
}
|
||
|
||
// dataItemOf 解析成功回應的 data(DeviceListItem map)。
|
||
func dataItemOf(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||
t.Helper()
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String())
|
||
item, ok := sb.Data.(map[string]any)
|
||
require.True(t, ok, "data should be object, body=%s", w.Body.String())
|
||
return item
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// register
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestRegister_Success 未註冊 → register → 200 且 registered_at 非 null。
|
||
func TestRegister_Success(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "demo-user", Name: "usb", DeviceType: "kl520",
|
||
SerialNumber: "0xAAAA", // 未註冊:RegisteredAt 留 nil
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/register")
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
item := dataItemOf(t, w)
|
||
assert.Equal(t, "d1", item["id"])
|
||
assert.NotNil(t, item["registered_at"], "register 後 registered_at 應非 null")
|
||
assert.NotEmpty(t, item["registered_at"])
|
||
|
||
// repo 端也確認翻轉。
|
||
got, err := repo.Get(context.Background(), "d1")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got.RegisteredAt)
|
||
}
|
||
|
||
// TestRegister_AlreadyRegistered 已註冊再 register → 409 ALREADY_REGISTERED。
|
||
func TestRegister_AlreadyRegistered(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
past := time.Now().UTC().Add(-time.Hour)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||
RegisteredAt: &past,
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/register")
|
||
require.Equal(t, http.StatusConflict, w.Code)
|
||
assert.Equal(t, ErrCodeAlreadyRegistered, errCodeOf(t, w))
|
||
}
|
||
|
||
// TestRegister_NotOwner 非 owner → 403 FORBIDDEN(IDOR 主防線)。
|
||
func TestRegister_NotOwner(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA",
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/register")
|
||
require.Equal(t, http.StatusForbidden, w.Code)
|
||
assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w))
|
||
}
|
||
|
||
// TestRegister_NotFound device 不存在 → 404。
|
||
func TestRegister_NotFound(t *testing.T) {
|
||
r, _ := newRegisterFixture(t)
|
||
w := postRegister(t, r, "/api/devices/ghost/register")
|
||
require.Equal(t, http.StatusNotFound, w.Code)
|
||
assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w))
|
||
}
|
||
|
||
// TestRegister_Representative representative device → 409 REPRESENTATIVE_DEVICE。
|
||
func TestRegister_Representative(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true,
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/rep/register")
|
||
require.Equal(t, http.StatusConflict, w.Code)
|
||
assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w),
|
||
"representative 用 REPRESENTATIVE_DEVICE 碼區分於 ALREADY_REGISTERED")
|
||
}
|
||
|
||
// TestRegister_MissingUserContext 缺 UserContext → 500(auth 沒配好不可 fallthrough)。
|
||
func TestRegister_MissingUserContext(t *testing.T) {
|
||
repo := device.NewInMemoryRepository()
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||
}))
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
// 刻意不注入 UserContext。
|
||
g := r.Group("/api")
|
||
registerDeviceRoutes(g, Deps{DeviceRepo: repo, SessionStore: &fakeSessionStore{}})
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/register")
|
||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||
assert.Equal(t, ErrCodeInternalError, errCodeOf(t, w))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// unregister
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestUnregister_Success 已註冊 → unregister → 200 且 registered_at=null,device 仍在 List。
|
||
func TestUnregister_Success(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
past := time.Now().UTC().Add(-time.Hour)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||
RegisteredAt: &past,
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
item := dataItemOf(t, w)
|
||
assert.Nil(t, item["registered_at"], "unregister 後 registered_at 應為 null")
|
||
|
||
// device 仍存在(未軟刪、保留列)。
|
||
got, err := repo.Get(context.Background(), "d1")
|
||
require.NoError(t, err, "unregister 不軟刪、device 應仍在")
|
||
assert.Nil(t, got.RegisteredAt)
|
||
|
||
// 仍列在 List。
|
||
list, err := repo.List(context.Background(), "demo-user")
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 1, "unregister 後 device 仍在清單(與 unpair 不同)")
|
||
}
|
||
|
||
// TestUnregister_IdempotentWhenUnregistered 未註冊 → unregister → 200 冪等 no-op。
|
||
func TestUnregister_IdempotentWhenUnregistered(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||
// RegisteredAt nil = 未註冊
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||
require.Equal(t, http.StatusOK, w.Code, "未註冊 unregister 應冪等回 200,body=%s", w.Body.String())
|
||
item := dataItemOf(t, w)
|
||
assert.Nil(t, item["registered_at"])
|
||
}
|
||
|
||
// TestUnregister_NotOwner 非 owner → 403。
|
||
func TestUnregister_NotOwner(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
past := time.Now().UTC()
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA",
|
||
RegisteredAt: &past,
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||
require.Equal(t, http.StatusForbidden, w.Code)
|
||
assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w))
|
||
}
|
||
|
||
// TestUnregister_Representative representative → 409 REPRESENTATIVE_DEVICE。
|
||
func TestUnregister_Representative(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true,
|
||
}))
|
||
|
||
w := postRegister(t, r, "/api/devices/rep/unregister")
|
||
require.Equal(t, http.StatusConflict, w.Code)
|
||
assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w))
|
||
}
|
||
|
||
// TestUnregister_NotFound device 不存在 → 404。
|
||
func TestUnregister_NotFound(t *testing.T) {
|
||
r, _ := newRegisterFixture(t)
|
||
w := postRegister(t, r, "/api/devices/ghost/unregister")
|
||
require.Equal(t, http.StatusNotFound, w.Code)
|
||
assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w))
|
||
}
|
||
|
||
// TestRegisterUnregister_RoundTrip register → 綠,unregister → 退回,device 全程保留。
|
||
func TestRegisterUnregister_RoundTrip(t *testing.T) {
|
||
r, repo := newRegisterFixture(t)
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||
}))
|
||
|
||
// register
|
||
w := postRegister(t, r, "/api/devices/d1/register")
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
got, _ := repo.Get(context.Background(), "d1")
|
||
require.NotNil(t, got.RegisteredAt)
|
||
|
||
// unregister
|
||
w = postRegister(t, r, "/api/devices/d1/unregister")
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
got, _ = repo.Get(context.Background(), "d1")
|
||
require.Nil(t, got.RegisteredAt)
|
||
|
||
// device 全程未消失。
|
||
list, _ := repo.List(context.Background(), "demo-user")
|
||
require.Len(t, list, 1)
|
||
}
|