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

175 lines
4.5 KiB
Go

package storage
import (
"bytes"
"context"
"io"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestStore(t *testing.T) *LocalFSStore {
t.Helper()
s, err := NewLocalFSStore(t.TempDir(), "http://localhost:3001/storage", "test-secret")
require.NoError(t, err)
return s
}
func TestLocalFSStore_PutGetStat(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
payload := []byte("hello-visiona")
key := "models/user-1/m1.nef"
require.NoError(t, s.Put(ctx, key, bytes.NewReader(payload), int64(len(payload)), nil))
// Stat
info, err := s.Stat(ctx, key)
require.NoError(t, err)
assert.Equal(t, int64(len(payload)), info.Size)
assert.Equal(t, key, info.Key)
// Get
rc, obj, err := s.Get(ctx, key)
require.NoError(t, err)
defer rc.Close()
got, err := io.ReadAll(rc)
require.NoError(t, err)
assert.Equal(t, payload, got)
assert.Equal(t, int64(len(payload)), obj.Size)
}
func TestLocalFSStore_Exists(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
ok, err := s.Exists(ctx, "nope.txt")
require.NoError(t, err)
assert.False(t, ok, "不存在應回 (false, nil)")
require.NoError(t, s.Put(ctx, "a.txt", strings.NewReader("x"), 1, nil))
ok, err = s.Exists(ctx, "a.txt")
require.NoError(t, err)
assert.True(t, ok)
}
func TestLocalFSStore_Get_NotFound(t *testing.T) {
s := newTestStore(t)
_, _, err := s.Get(context.Background(), "missing.txt")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLocalFSStore_Stat_NotFound(t *testing.T) {
s := newTestStore(t)
_, err := s.Stat(context.Background(), "missing.txt")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLocalFSStore_Delete(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
require.NoError(t, s.Put(ctx, "tmp.txt", strings.NewReader("x"), 1, nil))
require.NoError(t, s.Delete(ctx, "tmp.txt"))
ok, _ := s.Exists(ctx, "tmp.txt")
assert.False(t, ok)
// 刪除不存在的 key 不應回錯
assert.NoError(t, s.Delete(ctx, "never.txt"))
}
func TestLocalFSStore_List(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
require.NoError(t, s.Put(ctx, "models/u1/a.nef", strings.NewReader("A"), 1, nil))
require.NoError(t, s.Put(ctx, "models/u1/b.nef", strings.NewReader("B"), 1, nil))
require.NoError(t, s.Put(ctx, "models/u2/c.nef", strings.NewReader("C"), 1, nil))
listU1, err := s.List(ctx, "models/u1")
require.NoError(t, err)
assert.Len(t, listU1, 2)
listAll, err := s.List(ctx, "")
require.NoError(t, err)
assert.Len(t, listAll, 3)
listEmpty, err := s.List(ctx, "not-exist-prefix")
require.NoError(t, err)
assert.Empty(t, listEmpty)
}
func TestLocalFSStore_PathTraversal_Rejected(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// 嘗試逃出 root
err := s.Put(ctx, "../../etc/passwd", strings.NewReader("pwned"), 5, nil)
assert.ErrorIs(t, err, ErrInvalidKey)
_, _, err = s.Get(ctx, "../secret.txt")
assert.ErrorIs(t, err, ErrInvalidKey)
_, err = s.Stat(ctx, "../secret.txt")
assert.ErrorIs(t, err, ErrInvalidKey)
// 空 key
err = s.Put(ctx, "", strings.NewReader("x"), 1, nil)
assert.ErrorIs(t, err, ErrInvalidKey)
}
func TestLocalFSStore_PresignedGetURL_AndVerify(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
key := "models/u1/m.nef"
require.NoError(t, s.Put(ctx, key, strings.NewReader("X"), 1, nil))
u, err := s.PresignedGetURL(ctx, key, 5*time.Minute)
require.NoError(t, err)
assert.Contains(t, u, "http://localhost:3001/storage/")
assert.Contains(t, u, "expires=")
assert.Contains(t, u, "signature=")
// 解析並驗證
parsed, err := url.Parse(u)
require.NoError(t, err)
expires, err := strconv.ParseInt(parsed.Query().Get("expires"), 10, 64)
require.NoError(t, err)
sig := parsed.Query().Get("signature")
assert.NoError(t, s.VerifySignature("GET", key, expires, sig))
// 簽名錯誤
assert.ErrorIs(t, s.VerifySignature("GET", key, expires, "tampered"), ErrInvalidSignature)
// 已過期
assert.ErrorIs(t,
s.VerifySignature("GET", key, time.Now().Add(-1*time.Hour).Unix(), sig),
ErrInvalidSignature)
// method 不符
assert.ErrorIs(t, s.VerifySignature("PUT", key, expires, sig), ErrInvalidSignature)
}
func TestLocalFSStore_PresignedPutURL(t *testing.T) {
s := newTestStore(t)
u, err := s.PresignedPutURL(context.Background(), "models/u1/new.nef", 10*time.Minute)
require.NoError(t, err)
assert.Contains(t, u, "mode=put")
}
func TestNewLocalFSStore_EmptyRoot(t *testing.T) {
_, err := NewLocalFSStore("", "", "")
assert.Error(t, err)
}