This commit is contained in:
@@ -15,6 +15,10 @@ type SettlementMonitor interface {
|
||||
Stats(context.Context) (ledger.SettlementStats, error)
|
||||
}
|
||||
|
||||
type SettlementAuthority interface {
|
||||
SettlementAllowed(context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Serving bool
|
||||
DatabaseReady bool
|
||||
@@ -50,6 +54,10 @@ func (s *Service) Check(ctx context.Context) Status {
|
||||
if err == nil {
|
||||
status.SettlementStats = stats
|
||||
status.SettlementReady = true
|
||||
if authority, ok := s.settlement.(SettlementAuthority); ok {
|
||||
allowed, authorityErr := authority.SettlementAllowed(ctx)
|
||||
status.SettlementReady = authorityErr == nil && allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
return status
|
||||
|
||||
@@ -61,3 +61,15 @@ func TestCheckReportsSettlementQueryFailure(t *testing.T) {
|
||||
t.Fatalf("unexpected status: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type pausedSettlementStub struct{ settlementStub }
|
||||
|
||||
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
|
||||
|
||||
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
|
||||
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
|
||||
got := NewService(databaseStub{}, monitor).Check(context.Background())
|
||||
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
|
||||
t.Fatalf("unexpected GL-only readiness: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
domain "gl/domain/ledger"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
type AuthorityRepository interface {
|
||||
OperatingMode(context.Context) (domain.OperatingState, error)
|
||||
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
|
||||
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
|
||||
}
|
||||
|
||||
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return domain.OperatingState{}, domain.ErrModeRequired
|
||||
}
|
||||
return repository.OperatingMode(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
|
||||
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
|
||||
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
|
||||
}
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return domain.OperatingState{}, domain.ErrModeRequired
|
||||
}
|
||||
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
|
||||
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, "mode change failed")
|
||||
return domain.OperatingState{}, err
|
||||
}
|
||||
if state.Revision != revision {
|
||||
span.AddEvent("ledger.operating_mode_changed")
|
||||
}
|
||||
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
|
||||
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
|
||||
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
|
||||
}
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
|
||||
}
|
||||
|
||||
// PostInternalJournal executes exact balanced entries without network settlement.
|
||||
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
|
||||
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
if strings.TrimSpace(command.ActorID) == "" {
|
||||
return AppendJournalResult{}, invalid("actor id is required for internal execution")
|
||||
}
|
||||
if command.Blockchain != (domain.BlockchainReference{}) {
|
||||
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
|
||||
}
|
||||
for _, entry := range command.Entries {
|
||||
if entry.Account.Class == domain.AccountClassExternalBlockchain {
|
||||
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
|
||||
}
|
||||
}
|
||||
command.Metadata = cloneMetadata(command.Metadata)
|
||||
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
|
||||
return s.appendJournal(ctx, command)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
domain "gl/domain/ledger"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*AppendJournalCommand)
|
||||
}{
|
||||
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
|
||||
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
|
||||
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repository := newRepositoryStub()
|
||||
command := validAppendCommand()
|
||||
command.ActorID = "operator"
|
||||
tc.change(&command)
|
||||
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
|
||||
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
|
||||
t.Fatalf("invalid internal execution reached repository: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
|
||||
repository := newRepositoryStub()
|
||||
service := NewService(repository, fixedID("test"))
|
||||
command := validAppendCommand()
|
||||
command.ActorID = "operator"
|
||||
command.Metadata = map[string]string{"correlation": "value"}
|
||||
result, err := service.PostInternalJournal(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
|
||||
t.Fatal("internal provenance missing")
|
||||
}
|
||||
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
|
||||
t.Fatal("caller metadata mutated")
|
||||
}
|
||||
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
|
||||
_, err = service.AppendJournal(context.Background(), command)
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("ordinary API accepted internal marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorityValidatesInputs(t *testing.T) {
|
||||
service := NewService(newRepositoryStub(), nil)
|
||||
for _, owner := range []string{"", " "} {
|
||||
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid owner: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid mode: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ type AppendJournalResult struct {
|
||||
}
|
||||
|
||||
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
|
||||
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
|
||||
}
|
||||
return s.appendJournal(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) appendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
journalID, err := s.newID()
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user