從 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>
125 lines
3.8 KiB
Go
125 lines
3.8 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/session"
|
|
)
|
|
|
|
// fakeSessionStore 是測試用 Store 實作,只回 List 結果;其他方法 panic 表示
|
|
// 不應被呼叫(以利早期偵錯)。
|
|
type fakeSessionStore struct {
|
|
sessions []*session.Summary
|
|
listErr error
|
|
}
|
|
|
|
func (f *fakeSessionStore) Register(context.Context, string, session.Handle) error {
|
|
panic("Register should not be called")
|
|
}
|
|
func (f *fakeSessionStore) Unregister(context.Context, string) error {
|
|
panic("Unregister should not be called")
|
|
}
|
|
func (f *fakeSessionStore) Lookup(context.Context, string) (session.Handle, error) {
|
|
panic("Lookup should not be called")
|
|
}
|
|
func (f *fakeSessionStore) Exists(context.Context, string) (bool, error) {
|
|
panic("Exists should not be called")
|
|
}
|
|
func (f *fakeSessionStore) List(context.Context) ([]*session.Summary, error) {
|
|
return f.sessions, f.listErr
|
|
}
|
|
func (f *fakeSessionStore) Heartbeat(context.Context, string) error {
|
|
panic("Heartbeat should not be called")
|
|
}
|
|
func (f *fakeSessionStore) CleanupExpired(context.Context, time.Duration) (int, error) {
|
|
panic("CleanupExpired should not be called")
|
|
}
|
|
|
|
// TestHealthzHandler 驗證 /healthz 回 200 + status:ok。
|
|
func TestHealthzHandler(t *testing.T) {
|
|
r := gin.New()
|
|
r.GET("/healthz", HealthzHandler())
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Body.String(), `"status":"ok"`)
|
|
}
|
|
|
|
// TestSystemHealth_TunnelDisconnected 驗證沒 session 時回 connected=false。
|
|
func TestSystemHealth_TunnelDisconnected(t *testing.T) {
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
g := r.Group("/api")
|
|
registerSystemRoutes(g, Deps{
|
|
SessionStore: &fakeSessionStore{sessions: nil},
|
|
Logger: nil,
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/health", nil))
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var body SuccessBody
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.True(t, body.Success)
|
|
data, _ := body.Data.(map[string]any)
|
|
require.NotNil(t, data)
|
|
assert.Equal(t, "ok", data["api_server"])
|
|
assert.Equal(t, false, data["tunnel_connected"])
|
|
}
|
|
|
|
// TestSystemHealth_TunnelConnected 驗證有 session 時回 connected=true 並帶 last_seen_at。
|
|
func TestSystemHealth_TunnelConnected(t *testing.T) {
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
g := r.Group("/api")
|
|
registerSystemRoutes(g, Deps{
|
|
SessionStore: &fakeSessionStore{
|
|
sessions: []*session.Summary{
|
|
{Token: "vAc_a", LastHeartbeat: now.Add(-5 * time.Second)},
|
|
{Token: "vAc_b", LastHeartbeat: now}, // 最新
|
|
},
|
|
},
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/health", nil))
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var body SuccessBody
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
data := body.Data.(map[string]any)
|
|
assert.Equal(t, true, data["tunnel_connected"])
|
|
assert.EqualValues(t, 2, data["agent_session_count"])
|
|
assert.NotEmpty(t, data["agent_last_seen_at"])
|
|
}
|
|
|
|
// TestSystemInfo 驗證 GET /api/system/info 回基本欄位。
|
|
func TestSystemInfo(t *testing.T) {
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
g := r.Group("/api")
|
|
registerSystemRoutes(g, Deps{})
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil))
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var body SuccessBody
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
data := body.Data.(map[string]any)
|
|
assert.Equal(t, "visiona-api-server", data["service"])
|
|
assert.Equal(t, "phase-0-prototype", data["phase"])
|
|
}
|