jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

251 lines
8.0 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 usersession
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// testKey 是測試用的 32 byte HMAC key。
var testKey = []byte("test-key-test-key-test-key-1234!") // 32 bytes
// ─────────────────────────────────────────────────────────
// Encode / Decode roundtrip
// ─────────────────────────────────────────────────────────
func TestEncodeDecode_Roundtrip(t *testing.T) {
sid := "abc123XYZ_-test"
encoded := EncodeCookieValue(sid, testKey)
if !strings.Contains(encoded, ".") {
t.Fatalf("encoded should contain separator '.', got %q", encoded)
}
got, err := DecodeCookieValue(encoded, testKey)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if got != sid {
t.Fatalf("roundtrip mismatch: got %q want %q", got, sid)
}
}
func TestDecode_TamperedSessionID(t *testing.T) {
encoded := EncodeCookieValue("realsid", testKey)
parts := strings.SplitN(encoded, ".", 2)
tampered := "fakesid." + parts[1]
_, err := DecodeCookieValue(tampered, testKey)
if !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("expected ErrSignatureMismatch when sessionID tampered, got %v", err)
}
}
func TestDecode_TamperedSignature(t *testing.T) {
encoded := EncodeCookieValue("realsid", testKey)
parts := strings.SplitN(encoded, ".", 2)
// 換個簽章(不同長度的 base64url 也會失敗)
tampered := parts[0] + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
_, err := DecodeCookieValue(tampered, testKey)
if !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("expected ErrSignatureMismatch, got %v", err)
}
}
func TestDecode_DifferentKeyFails(t *testing.T) {
encoded := EncodeCookieValue("sid", testKey)
other := []byte("other-key-other-key-other-key-1!")
_, err := DecodeCookieValue(encoded, other)
if !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("expected ErrSignatureMismatch with different key, got %v", err)
}
}
func TestDecode_MalformedValues(t *testing.T) {
cases := map[string]string{
"empty": "",
"no separator": "noseparator",
"only sep": ".",
"empty sid": ".sigonly",
"empty sig": "sidonly.",
}
for name, val := range cases {
t.Run(name, func(t *testing.T) {
_, err := DecodeCookieValue(val, testKey)
if !errors.Is(err, ErrInvalidCookie) {
t.Fatalf("%s: expected ErrInvalidCookie, got %v", name, err)
}
})
}
}
// ─────────────────────────────────────────────────────────
// WriteCookie / ReadCookie / ClearCookie via httptest
// ─────────────────────────────────────────────────────────
func newCookieCfg() CookieConfig {
return CookieConfig{
Name: DefaultCookieName,
Path: "/",
HTTPOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
SigningKey: testKey,
}
}
func TestWriteAndReadCookie_Roundtrip(t *testing.T) {
cfg := newCookieCfg()
sid := "session-abc-123"
w := httptest.NewRecorder()
if err := WriteCookie(w, cfg, sid); err != nil {
t.Fatalf("WriteCookie: %v", err)
}
// 從 response 取出 Set-Cookie做成 request cookie 模擬 browser 回傳
resp := w.Result()
cookies := resp.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 Set-Cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != DefaultCookieName {
t.Fatalf("cookie name: got %q want %q", c.Name, DefaultCookieName)
}
if !c.HttpOnly {
t.Fatalf("HttpOnly should be true")
}
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite should be Lax")
}
if c.MaxAge != 86400 {
t.Fatalf("MaxAge: got %d want 86400", c.MaxAge)
}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(c)
got, ok := ReadCookie(r, cfg)
if !ok {
t.Fatalf("ReadCookie: ok=false")
}
if got != sid {
t.Fatalf("ReadCookie: got %q want %q", got, sid)
}
}
func TestReadCookie_NoCookie(t *testing.T) {
cfg := newCookieCfg()
r := httptest.NewRequest(http.MethodGet, "/", nil)
if _, ok := ReadCookie(r, cfg); ok {
t.Fatalf("ReadCookie should return ok=false when no cookie present")
}
}
func TestReadCookie_TamperedValue(t *testing.T) {
cfg := newCookieCfg()
r := httptest.NewRequest(http.MethodGet, "/", nil)
// 故意放一個簽章錯的 cookie
r.AddCookie(&http.Cookie{Name: cfg.Name, Value: "tampered.sig"})
if _, ok := ReadCookie(r, cfg); ok {
t.Fatalf("ReadCookie should return ok=false for tampered cookie")
}
}
func TestClearCookie_SetsExpiration(t *testing.T) {
cfg := newCookieCfg()
w := httptest.NewRecorder()
ClearCookie(w, cfg)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 Set-Cookie, got %d", len(cookies))
}
c := cookies[0]
if c.MaxAge >= 0 {
t.Fatalf("ClearCookie MaxAge should be < 0, got %d", c.MaxAge)
}
if c.Value != "" {
t.Fatalf("ClearCookie value should be empty, got %q", c.Value)
}
if c.Name != cfg.Name || c.Path != cfg.Path {
t.Fatalf("ClearCookie must use same Name/Path; got name=%q path=%q", c.Name, c.Path)
}
}
func TestClearCookie_BrowserCannotRead(t *testing.T) {
cfg := newCookieCfg()
// 1. WriteCookie → 得到一個 cookie
w1 := httptest.NewRecorder()
if err := WriteCookie(w1, cfg, "sid-X"); err != nil {
t.Fatalf("WriteCookie: %v", err)
}
original := w1.Result().Cookies()[0]
// 2. ClearCookie → 得到一個 expiration cookie
w2 := httptest.NewRecorder()
ClearCookie(w2, cfg)
expirationCookie := w2.Result().Cookies()[0]
// 3. 模擬 browser 在收到 expiration cookie 後立刻發 request — 此時應該沒有 cookie
// (這裡無法直接模擬 browser 的 cookie jar 邏輯,但能驗證 expiration cookie 內容是空的、
// 若 browser 真的把它存下來,後續 ReadCookie 會失敗)。
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: expirationCookie.Name, Value: expirationCookie.Value})
if _, ok := ReadCookie(r, cfg); ok {
t.Fatalf("after ClearCookie, ReadCookie of cleared value must fail")
}
// sanity check原本的 cookie 仍然能讀(驗證 ReadCookie 本身沒壞)
r2 := httptest.NewRequest(http.MethodGet, "/", nil)
r2.AddCookie(original)
if _, ok := ReadCookie(r2, cfg); !ok {
t.Fatalf("baseline: original cookie should still read OK")
}
}
// ─────────────────────────────────────────────────────────
// CookieConfig validation
// ─────────────────────────────────────────────────────────
func TestWriteCookie_MissingSigningKey(t *testing.T) {
cfg := CookieConfig{} // SigningKey 為 nil
w := httptest.NewRecorder()
err := WriteCookie(w, cfg, "sid")
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
}
func TestWriteCookie_EmptySessionID(t *testing.T) {
cfg := newCookieCfg()
w := httptest.NewRecorder()
err := WriteCookie(w, cfg, "")
if !errors.Is(err, ErrInvalidCookie) {
t.Fatalf("expected ErrInvalidCookie, got %v", err)
}
}
func TestReadCookie_MissingSigningKey(t *testing.T) {
cfg := CookieConfig{} // SigningKey 為 nil
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "anything.thing"})
if _, ok := ReadCookie(r, cfg); ok {
t.Fatalf("ReadCookie should fail when SigningKey missing")
}
}
func TestCookieConfig_Defaults(t *testing.T) {
cfg := CookieConfig{SigningKey: testKey} // 其他欄位都用預設
if cfg.resolvedName() != DefaultCookieName {
t.Fatalf("resolvedName default mismatch")
}
if cfg.resolvedPath() != "/" {
t.Fatalf("resolvedPath default should be '/'")
}
if cfg.resolvedSameSite() != http.SameSiteLaxMode {
t.Fatalf("resolvedSameSite default should be Lax")
}
}