Files
GL/infrastructure/postgres/authority_integration_test.go
T
nfel 88e227f80c
CI/CD Stage / stage (push) Failing after 1m12s
feat(gl): add standalone ledger authority mode
2026-10-06 22:40:07 +03:30

244 lines
10 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
applicationledger "gl/application/ledger"
applicationsettlement "gl/application/settlement"
domain "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
grpcadapter "gl/interface/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Opt-in real PostgreSQL proof. Each run owns a new schema and removes only it.
func TestAuthorityWithPostgres(t *testing.T) {
dsn := os.Getenv("GL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set GL_TEST_DATABASE_URL to a disposable PostgreSQL database")
}
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 := fmt.Sprintf("gl_authority_test_%d", time.Now().UnixNano())
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
if _, err := admin.Exec(cleanup, "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()
database := &Pool{pool: pool}
if err := Migrate(ctx, database); err != nil {
t.Fatal(err)
}
repository := NewJournalRepository(database)
service := applicationledger.NewService(repository, nil)
settlementRepo := NewSettlementRepository(database)
settlements := applicationsettlement.NewService(settlementRepo)
handler := grpcadapter.NewHandler(nil, service, settlements).WithSettlementAdminToken("test-token")
authorized := metadata.NewIncomingContext(ctx, metadata.Pairs("x-admin-token", "test-token"))
account := func(class domain.AccountClass, owner string) domain.AccountReference {
return domain.AccountReference{Class: class, OwnerType: "user", OwnerID: owner, AssetID: 7}
}
command := func(key string, from, to domain.AccountReference, amount string) applicationledger.AppendJournalCommand {
credit, err := domain.ParseAmount(amount)
if err != nil {
t.Fatal(err)
}
return applicationledger.AppendJournalCommand{
SourceService: "internal", SourceTransactionID: key, IdempotencyKey: key, EffectKind: "transfer", EventVersion: 1,
ActorID: "operator", OccurredAt: time.Unix(100, 0).UTC(),
Entries: []applicationledger.EntryCommand{
{LineNumber: 1, Account: from, Amount: credit.Negate().String()},
{LineNumber: 2, Account: to, Amount: credit.String()},
},
}
}
alice := account(domain.AccountClassUserAvailable, "alice")
bob := account(domain.AccountClassUserAvailable, "bob")
opening := command("opening", domain.AccountReference{Class: domain.AccountClassTreasury, AssetID: 7}, alice, "100")
if _, err := service.AppendJournal(ctx, opening); err != nil {
t.Fatal(err)
}
transfer := command("transfer-1", alice, bob, "60")
if _, err := service.PostInternalJournal(ctx, transfer); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("NORMAL admitted internal execution: %v", err)
}
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("unauthorized toggle: %v", err)
}
if _, _, err := settlements.Enqueue(ctx, "wallet", "t1", "s1", "kuknos", "signed-xdr"); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by='in-flight'"); err != nil {
t.Fatal(err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 0, "operator", "outage", false); !errors.Is(err, domain.ErrSettlementInFlight) {
t.Fatalf("in-flight toggle: %v", err)
}
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by=''"); err != nil {
t.Fatal(err)
}
state, err := handler.SetOperatingMode(authorized, &ledgerv1.SetOperatingModeRequest{Mode: "GL_ONLY", ActorId: "operator", Reason: "outage"})
if err != nil || state.GetRevision() != 1 {
t.Fatalf("enable GL_ONLY: %+v %v", state, err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 0, "operator", "stale", true); !errors.Is(err, domain.ErrModeConflict) {
t.Fatalf("stale revision accepted: %v", err)
}
if claimed, err := settlementRepo.ClaimDue(ctx, "worker", 10, time.Now()); err != nil || len(claimed) != 0 {
t.Fatalf("settlements claimed in GL_ONLY: %v %v", claimed, err)
}
if _, _, err := settlements.Enqueue(ctx, "wallet", "t2", "s2", "kuknos", "signed-xdr"); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("settlement admitted: %v", err)
}
// Two simultaneous spends cannot consume the same opening balance.
var wg sync.WaitGroup
results := make(chan error, 2)
for _, key := range []string{"transfer-1", "transfer-2"} {
next := command(key, alice, bob, "60")
wg.Add(1)
go func(next applicationledger.AppendJournalCommand) {
defer wg.Done()
_, err := service.PostInternalJournal(ctx, next)
results <- err
}(next)
}
wg.Wait()
close(results)
successes, insufficient := 0, 0
for err := range results {
if err == nil {
successes++
} else if errors.Is(err, domain.ErrInsufficientFunds) {
insufficient++
} else {
t.Fatal(err)
}
}
if successes != 1 || insufficient != 1 {
t.Fatalf("concurrent spends: success=%d insufficient=%d", successes, insufficient)
}
var successfulKey string
if err := database.QueryRow(ctx, "SELECT idempotency_key FROM journals WHERE metadata->>'gl_execution'='GL_ONLY'").Scan(&successfulKey); err != nil {
t.Fatal(err)
}
transfer = command(successfulKey, alice, bob, "60")
repeated, err := service.PostInternalJournal(ctx, transfer)
if err != nil || !repeated.AlreadyExisted {
t.Fatalf("idempotent internal retry: %+v %v", repeated, err)
}
conflicting := command(successfulKey, alice, bob, "1")
if _, err := service.PostInternalJournal(ctx, conflicting); !errors.Is(err, domain.ErrIdempotencyConflict) {
t.Fatalf("conflicting retry: %v", err)
}
frozen := account(domain.AccountClassUserFrozen, "alice")
if _, err := service.PostInternalJournal(ctx, command("freeze", alice, frozen, "10")); err != nil {
t.Fatal(err)
}
if _, err := service.PostInternalJournal(ctx, command("release", frozen, alice, "4")); err != nil {
t.Fatal(err)
}
// The legacy ingestion API cannot execute new effects in GL_ONLY.
if _, err := service.AppendJournal(ctx, command("legacy-overspend", alice, bob, "100")); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("legacy bypass: %v", err)
}
response, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "alice"})
if err != nil || len(response.GetBalances()) != 1 {
t.Fatalf("user balance: %+v %v", response, err)
}
balance := response.Balances[0]
if balance.Available != "34" || balance.Frozen != "6" || balance.Total != "40" {
t.Fatalf("incorrect balance: %+v", balance)
}
var sum string
if err := database.QueryRow(ctx, `SELECT sum(e.amount)::text FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')`).Scan(&sum); err != nil {
t.Fatal(err)
}
total, err := domain.ParseAmount(sum)
if err != nil || total.String() != "100" {
t.Fatalf("conservation failed: %s %v", sum, err)
}
// Exercise the internal RPC mapping with a real commit.
posted, err := handler.PostInternalJournal(authorized, &ledgerv1.AppendJournalRequest{
SourceService: "internal", SourceTransactionId: "rpc", IdempotencyKey: "rpc", EffectKind: "transfer", EventVersion: 1,
ActorId: "operator", OccurredAt: timestamppb.New(time.Unix(100, 0)),
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "alice", AssetId: 7}, Amount: "-1"},
{LineNumber: 2, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "bob", AssetId: 7}, Amount: "1"},
},
})
if err != nil || posted.GetJournal().GetJournalId() == "" {
t.Fatalf("internal RPC: %+v %v", posted, err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", false); !errors.Is(err, domain.ErrModeConflict) {
t.Fatalf("unreconciled recovery: %v", err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", true); err != nil {
t.Fatal(err)
}
repeated, err = service.PostInternalJournal(ctx, transfer)
if err != nil || !repeated.AlreadyExisted {
t.Fatalf("retry after mode change: %+v %v", repeated, err)
}
if _, err := service.PostInternalJournal(ctx, command("after-recovery", alice, bob, "1")); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("internal post after recovery: %v", err)
}
unknownAsset := int64(99)
zero, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "unknown", AssetId: &unknownAsset})
if err != nil || len(zero.GetBalances()) != 1 || zero.Balances[0].Total != "0" {
t.Fatalf("unknown user balance: %+v %v", zero, err)
}
// New instances read the same persisted authority state.
if persisted, err := NewJournalRepository(database).OperatingMode(ctx); err != nil || persisted.Mode != domain.ModeNormal || persisted.Revision != 2 {
t.Fatalf("persisted mode: %+v %v", persisted, err)
}
if _, err := service.AppendJournal(ctx, command("negative-baseline", alice, bob, "1000")); err != nil {
t.Fatal(err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 2, "operator", "uncorrected books", false); !errors.Is(err, domain.ErrInsufficientFunds) {
t.Fatalf("negative opening balances accepted: %v", err)
}
var audits int64
if err := database.QueryRow(ctx, "SELECT count(*) FROM ledger_mode_changes").Scan(&audits); err != nil || audits != 2 {
t.Fatalf("mode audit: %d %v", audits, err)
}
if _, err := database.Exec(ctx, "DELETE FROM ledger_mode_changes"); err == nil {
t.Fatal("mode audit is mutable")
}
if _, err := database.Exec(ctx, "UPDATE journals SET actor_id='changed'"); err == nil {
t.Fatal("journals are mutable")
}
}