@@ -0,0 +1,181 @@
|
||||
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"
|
||||
|
||||
"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/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(), 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 := 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")
|
||||
}
|
||||
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 := handler.NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), handler.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 }
|
||||
Reference in New Issue
Block a user