Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
207 lines
8.2 KiB
Go
207 lines
8.2 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"go.uber.org/zap"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/database"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
|
)
|
|
|
|
func TestIdentityAccountLifecycle(t *testing.T) {
|
|
dsn := os.Getenv("VEYRA_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
admin, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer admin.Close()
|
|
schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
|
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
cfg, err := pgxpool.ParseConfig(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.ConnConfig.RuntimeParams["search_path"] = schema
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := database.Migrate(ctx, pool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u1, err := auth.ProvisionUser(ctx, "alice", "a-test-only-password", "Alice", "USER")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u2, err := auth.ProvisionUser(ctx, "bob", "another-test-password", "Bob", "USER")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := auth.ProvisionUser(ctx, "alice", "a-test-only-password", "Duplicate", "USER"); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatalf("duplicate identity: %v", err)
|
|
}
|
|
var count int
|
|
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_user").Scan(&count); err != nil || count != 2 {
|
|
t.Fatal("credential conflict left an orphan user")
|
|
}
|
|
login, err := auth.Login(ctx, "alice", "a-test-only-password", "app")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := auth.Authenticate(ctx, login.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
|
t.Fatal("app session escaped audience")
|
|
}
|
|
if _, err := auth.Authenticate(ctx, login.Token, "app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := auth.Logout(ctx, login.Token, "app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
|
t.Fatal("revoked session remained valid")
|
|
}
|
|
accounts := service.NewAccountService(repository.NewAccountRepository(pool))
|
|
cash := "100000.01"
|
|
a, err := accounts.Create(ctx, u1.ID, "simulation", "Simulation", &cash)
|
|
if err != nil || a.CashBalance == nil || *a.CashBalance != cash {
|
|
t.Fatalf("simulation balance: %v", err)
|
|
}
|
|
if _, err := accounts.Get(ctx, u2.ID, a.ID); !errors.Is(err, apperror.ErrNotFound) {
|
|
t.Fatal("cross-user account disclosed")
|
|
}
|
|
if _, err := accounts.Create(ctx, u1.ID, "simulation", "Duplicate", &cash); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("duplicate simulation account accepted")
|
|
}
|
|
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_cash_entry").Scan(&count); err != nil || count != 1 {
|
|
t.Fatal("duplicate creation left a cash entry")
|
|
}
|
|
real, err := accounts.Create(ctx, u1.ID, "manual_real", "Manual", nil)
|
|
if err != nil || real.CashBalance != nil || real.LedgerStatus != "INCOMPLETE" {
|
|
t.Fatalf("manual account fabricated cash: %v", err)
|
|
}
|
|
if _, err := accounts.Create(ctx, u2.ID, "manual_real", "Manual", &cash); !errors.Is(err, apperror.ErrInvalidInput) {
|
|
t.Fatal("manual account accepted simulated funding")
|
|
}
|
|
list, err := accounts.List(ctx, u2.ID)
|
|
if err != nil || len(list) != 0 {
|
|
t.Fatal("account list escaped owner scope")
|
|
}
|
|
login, err = auth.Login(ctx, "alice", "a-test-only-password", "app")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := pool.Exec(ctx, "UPDATE veyra_session SET created_at=now()-interval '1 day',expires_at=now()-interval '1 hour' WHERE token_hash=$1", service.TokenHash(login.Token)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
|
t.Fatal("expired session remained valid")
|
|
}
|
|
adminUser, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testProviderLifecycle(t, ctx, pool, adminUser, u1)
|
|
vault, _ := secret.NewVault("")
|
|
providers := service.NewProviderService(repository.NewProviderRepository(pool), vault)
|
|
probe := func(context.Context) error { return nil }
|
|
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Providers: providers, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
|
|
call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if origin != "" {
|
|
req.Header.Set("Origin", origin)
|
|
}
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
response := call(http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"an-admin-test-password"}`, "http://example.com", nil)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("admin login failed: %d", response.Code)
|
|
}
|
|
cookies := response.Result().Cookies()
|
|
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode || cookies[0].Path != "/api/admin" {
|
|
t.Fatal("admin cookie constraints missing")
|
|
}
|
|
cookie := cookies[0]
|
|
if strings.Contains(response.Body.String(), cookie.Value) {
|
|
t.Fatal("admin token leaked into response body")
|
|
}
|
|
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
|
|
t.Fatal("cookie authentication failed")
|
|
}
|
|
|
|
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", nil); response.Code != http.StatusUnauthorized {
|
|
t.Fatal("unauthenticated configuration read allowed")
|
|
}
|
|
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", cookie); response.Code != http.StatusOK {
|
|
t.Fatal("admin configuration read failed")
|
|
}
|
|
body := `{"name":"","base_url":"","model_name":"","enabled":false,"timeout_seconds":15,"clear_api_key":false,"expected_version":0}`
|
|
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
|
t.Fatal("cross-origin configuration write allowed")
|
|
}
|
|
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusOK {
|
|
t.Fatal("empty market configuration write failed")
|
|
}
|
|
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusConflict {
|
|
t.Fatal("HTTP stale version accepted")
|
|
}
|
|
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
|
t.Fatal("cross-origin logout allowed")
|
|
}
|
|
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
|
|
t.Fatal("CSRF attempt revoked a session")
|
|
}
|
|
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://example.com", cookie); response.Code != http.StatusOK {
|
|
t.Fatal("logout failed")
|
|
}
|
|
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized {
|
|
t.Fatal("revoked admin cookie remained valid")
|
|
}
|
|
testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1)
|
|
testStrategyBacktest(t, ctx, pool, adminUser, u1)
|
|
testTradeLedger(t, ctx, pool, auth)
|
|
testAdminCredentialChange(t, ctx, pool, auth)
|
|
}
|
|
|
|
type allowLogin struct{}
|
|
|
|
func (allowLogin) Login(context.Context, string, string) (bool, error) { return true, nil }
|