184 lines
6.9 KiB
Go
184 lines
6.9 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
identityrepo "veyra/services/api/internal/identity/repository"
|
|
identity "veyra/services/api/internal/identity/service"
|
|
operations "veyra/services/api/internal/operations/service"
|
|
"veyra/services/api/internal/platform/apperror"
|
|
"veyra/services/api/internal/platform/database"
|
|
portfoliorepo "veyra/services/api/internal/portfolio/repository"
|
|
portfolio "veyra/services/api/internal/portfolio/service"
|
|
transport "veyra/services/api/internal/transport/http"
|
|
)
|
|
|
|
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(), 30*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 := identity.NewAuthService(identityrepo.NewPostgres(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 := portfolio.NewAccountService(portfoliorepo.NewPostgres(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", identity.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")
|
|
}
|
|
if _, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probe := func(context.Context) error { return nil }
|
|
router := transport.NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), transport.IdentityOptions{Auth: auth, 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.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")
|
|
}
|
|
}
|
|
|
|
type allowLogin struct{}
|
|
|
|
func (allowLogin) Login(context.Context, string, string) (bool, error) { return true, nil }
|