從 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>
117 lines
3.5 KiB
Go
117 lines
3.5 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"
|
||
)
|
||
|
||
// newDevicesFixture 建立 router 並塞好必要依賴(InMemory repo + fakeSessionStore)。
|
||
//
|
||
// Phase 0.7 security fix C1:移除 Deps.StaticUserID(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)。
|
||
// 改由 injectStaticUserContext 顯式注入 UserContext,handler 強制要求 UserContext 非空。
|
||
func newDevicesFixture(t *testing.T, sessions []any) *gin.Engine {
|
||
t.Helper()
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
_ = sessions // 暫用,下方 helper 內建
|
||
registerDeviceRoutes(g, Deps{
|
||
DeviceRepo: device.NewInMemoryRepository(),
|
||
SessionStore: &fakeSessionStore{}, // 無 session
|
||
})
|
||
return r
|
||
}
|
||
|
||
// TestDevicesList_Empty 驗證沒 device 時回空陣列。
|
||
func TestDevicesList_Empty(t *testing.T) {
|
||
r := newDevicesFixture(t, nil)
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices", 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.Empty(t, arr)
|
||
}
|
||
|
||
// TestDevicesList_ReturnsOwnDevicesOnly 驗證只回當前 user 的 device。
|
||
func TestDevicesList_ReturnsOwnDevicesOnly(t *testing.T) {
|
||
repo := device.NewInMemoryRepository()
|
||
ctx := context.Background()
|
||
now := time.Now().UTC()
|
||
require.NoError(t, repo.Save(ctx, &device.Device{
|
||
ID: "mine", OwnerUserID: "demo-user", Name: "A", DeviceType: "kl520",
|
||
RemoteStatus: device.RemoteStatusOnline, Status: device.USBStatusOnline,
|
||
CreatedAt: now,
|
||
}))
|
||
require.NoError(t, repo.Save(ctx, &device.Device{
|
||
ID: "theirs", OwnerUserID: "other", Name: "B", DeviceType: "kl520",
|
||
CreatedAt: now,
|
||
}))
|
||
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
registerDeviceRoutes(g, Deps{
|
||
DeviceRepo: repo,
|
||
SessionStore: &fakeSessionStore{},
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
arr := sb.Data.([]any)
|
||
require.Len(t, arr, 1, "只應看到自己的 device")
|
||
first := arr[0].(map[string]any)
|
||
assert.Equal(t, "mine", first["id"])
|
||
assert.Equal(t, false, first["tunnel_online"], "沒 session → tunnel_online=false")
|
||
}
|
||
|
||
// TestDevicesGet_NotOwner 驗證非 owner 被擋 403。
|
||
func TestDevicesGet_NotOwner(t *testing.T) {
|
||
repo := device.NewInMemoryRepository()
|
||
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||
ID: "x", OwnerUserID: "other", Name: "a", DeviceType: "kl520",
|
||
}))
|
||
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
registerDeviceRoutes(g, Deps{
|
||
DeviceRepo: repo,
|
||
SessionStore: &fakeSessionStore{},
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/x", nil))
|
||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||
}
|
||
|
||
// TestDevicesGet_NotFound 驗證不存在回 404。
|
||
func TestDevicesGet_NotFound(t *testing.T) {
|
||
r := newDevicesFixture(t, nil)
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/ghost", nil))
|
||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||
}
|