visionA/visionA-backend/internal/api/device_register_test.go
jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
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>
2026-08-02 16:29:50 +08:00

251 lines
8.9 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"
"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 建 routerInMemory repo + 無 sessionuser 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 解析成功回應的 dataDeviceListItem 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 FORBIDDENIDOR 主防線)。
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 → 500auth 沒配好不可 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=nulldevice 仍在 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 應冪等回 200body=%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)
}