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 }