docs: reorder & testing
This commit is contained in:
@@ -12,19 +12,35 @@ import (
|
||||
|
||||
// ActorRef identifies the principal that produced an audit-bearing
|
||||
// mutation. The wire shape mirrors the OpenAPI ActorRef schema. Type is
|
||||
// a free-form string ("user", "admin", "system" in MVP); ID is opaque
|
||||
// (a user UUID, an admin username, or empty for system).
|
||||
// one of "user", "admin", "system" in MVP. ID carries a user UUID for
|
||||
// Type=="user", an admin username for Type=="admin", and is empty for
|
||||
// Type=="system".
|
||||
type ActorRef struct {
|
||||
Type string
|
||||
ID string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Validate rejects empty actor types. Admin handlers always populate
|
||||
// Type; user-side mutations supply Type internally.
|
||||
// Validate rejects empty actor types and enforces the per-type shape
|
||||
// of ID: a user actor requires a UUID id, a system actor must have an
|
||||
// empty id. Other types pass through with no further check.
|
||||
func (a ActorRef) Validate() error {
|
||||
if strings.TrimSpace(a.Type) == "" {
|
||||
t := strings.TrimSpace(a.Type)
|
||||
if t == "" {
|
||||
return ErrInvalidActor
|
||||
}
|
||||
switch t {
|
||||
case "user":
|
||||
if strings.TrimSpace(a.ID) == "" {
|
||||
return fmt.Errorf("%w: user actor requires id", ErrInvalidActor)
|
||||
}
|
||||
if _, err := uuid.Parse(a.ID); err != nil {
|
||||
return fmt.Errorf("%w: user actor id must be a uuid: %v", ErrInvalidActor, err)
|
||||
}
|
||||
case "system":
|
||||
if strings.TrimSpace(a.ID) != "" {
|
||||
return fmt.Errorf("%w: system actor must have an empty id", ErrInvalidActor)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,34 @@ type GeoCascade interface {
|
||||
// canonical implementation wraps `*auth.Service.RevokeAllForUser`. The
|
||||
// adapter lives in `cmd/backend/main.go` so `auth` does not export an
|
||||
// extra method shape.
|
||||
//
|
||||
// The actor argument carries audit context: who initiated the revoke
|
||||
// and why. The auth side persists it into `session_revocations`; user
|
||||
// callers populate it with a fixed kind matching the trigger.
|
||||
type SessionRevoker interface {
|
||||
RevokeAllForUser(ctx context.Context, userID uuid.UUID) error
|
||||
RevokeAllForUser(ctx context.Context, userID uuid.UUID, actor SessionRevokeActor) error
|
||||
}
|
||||
|
||||
// SessionRevokeActor describes the principal behind a session revoke.
|
||||
// Kind is a closed vocabulary mirrored by `auth.ActorKind`; ID is the
|
||||
// stable identifier of the principal (a user UUID for self-driven
|
||||
// flows, an admin username for admin-driven flows). Reason is a
|
||||
// free-form note recorded in the audit row.
|
||||
type SessionRevokeActor struct {
|
||||
Kind string
|
||||
ID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Closed Kind vocabulary. Mirror constants live in
|
||||
// `auth.ActorKind*`; the values must stay in sync because the auth
|
||||
// adapter forwards them verbatim.
|
||||
const (
|
||||
SessionRevokeActorSoftDeleteUser = "soft_delete_user"
|
||||
SessionRevokeActorSoftDeleteAdmin = "soft_delete_admin"
|
||||
SessionRevokeActorAdminSanction = "admin_sanction"
|
||||
)
|
||||
|
||||
// NewNoopLobbyCascade returns a LobbyCascade that logs every invocation
|
||||
// at info level and returns nil. The canonical lobby is wired in `cmd/backend/main.go`.
|
||||
// implementation; until then the no-op keeps the cascade orchestration
|
||||
|
||||
@@ -59,14 +59,13 @@ func (s *Service) ApplyLimit(ctx context.Context, input ApplyLimitInput) (Accoun
|
||||
}
|
||||
|
||||
if err := s.deps.Store.ApplyLimitTx(ctx, limitInsert{
|
||||
UserID: input.UserID,
|
||||
LimitCode: input.LimitCode,
|
||||
Value: input.Value,
|
||||
UserID: input.UserID,
|
||||
LimitCode: input.LimitCode,
|
||||
Value: input.Value,
|
||||
ReasonCode: input.ReasonCode,
|
||||
ActorType: input.Actor.Type,
|
||||
ActorID: input.Actor.ID,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
Actor: input.Actor,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
return Account{}, err
|
||||
|
||||
@@ -77,14 +77,13 @@ func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (
|
||||
|
||||
flipPermanent := input.SanctionCode == SanctionCodePermanentBlock
|
||||
if err := s.deps.Store.ApplySanctionTx(ctx, sanctionInsert{
|
||||
UserID: input.UserID,
|
||||
SanctionCode: input.SanctionCode,
|
||||
Scope: input.Scope,
|
||||
ReasonCode: input.ReasonCode,
|
||||
ActorType: input.Actor.Type,
|
||||
ActorID: input.Actor.ID,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: input.UserID,
|
||||
SanctionCode: input.SanctionCode,
|
||||
Scope: input.Scope,
|
||||
ReasonCode: input.ReasonCode,
|
||||
Actor: input.Actor,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
FlipPermanent: flipPermanent,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
@@ -94,7 +93,7 @@ func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (
|
||||
}
|
||||
|
||||
if flipPermanent {
|
||||
if err := s.cascadePermanentBlock(ctx, input.UserID); err != nil {
|
||||
if err := s.cascadePermanentBlock(ctx, input.UserID, input.Actor, input.ReasonCode); err != nil {
|
||||
s.deps.Logger.Warn("permanent-block cascade returned error",
|
||||
zap.String("user_id", input.UserID.String()),
|
||||
zap.Error(err),
|
||||
@@ -117,10 +116,15 @@ func validateSanctionCode(code string) error {
|
||||
// lobby on-user-blocked hook. Both calls are best-effort — they run
|
||||
// after the database commit and only join errors for the caller to
|
||||
// log.
|
||||
func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID) error {
|
||||
func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID, actor ActorRef, reasonCode string) error {
|
||||
var joined error
|
||||
if s.deps.SessionRevoker != nil {
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); err != nil {
|
||||
revokeActor := SessionRevokeActor{
|
||||
Kind: SessionRevokeActorAdminSanction,
|
||||
ID: actor.ID,
|
||||
Reason: SanctionCodePermanentBlock + ":" + reasonCode,
|
||||
}
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID, revokeActor); err != nil {
|
||||
joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,17 +45,26 @@ func (s *Service) SoftDelete(ctx context.Context, userID uuid.UUID, actor ActorR
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("actor_type", actor.Type),
|
||||
)
|
||||
return s.runSoftDeleteCascade(ctx, userID)
|
||||
return s.runSoftDeleteCascade(ctx, userID, actor)
|
||||
}
|
||||
|
||||
// runSoftDeleteCascade fans the soft-delete signal out to dependent
|
||||
// modules in the documented order: auth → lobby → notification → geo.
|
||||
// Each call's error is joined; the loop continues even after a
|
||||
// failure so the remaining modules still get notified.
|
||||
func (s *Service) runSoftDeleteCascade(ctx context.Context, userID uuid.UUID) error {
|
||||
func (s *Service) runSoftDeleteCascade(ctx context.Context, userID uuid.UUID, actor ActorRef) error {
|
||||
var joined error
|
||||
if s.deps.SessionRevoker != nil {
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); err != nil {
|
||||
kind := SessionRevokeActorSoftDeleteAdmin
|
||||
if actor.Type == "user" {
|
||||
kind = SessionRevokeActorSoftDeleteUser
|
||||
}
|
||||
revokeActor := SessionRevokeActor{
|
||||
Kind: kind,
|
||||
ID: actor.ID,
|
||||
Reason: "soft delete",
|
||||
}
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID, revokeActor); err != nil {
|
||||
joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,15 +119,17 @@ func equalStrings(a, b []string) bool {
|
||||
// orderTracker spies on a single call kind and pushes its name into
|
||||
// the ordered slice when invoked. It satisfies user.SessionRevoker.
|
||||
type orderTracker struct {
|
||||
name string
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
appendTo func(string)
|
||||
name string
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
lastActor user.SessionRevokeActor
|
||||
appendTo func(string)
|
||||
}
|
||||
|
||||
func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
|
||||
func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error {
|
||||
r.calls++
|
||||
r.lastUser = userID
|
||||
r.lastActor = actor
|
||||
if r.appendTo != nil && r.name != "" {
|
||||
r.appendTo(r.name)
|
||||
}
|
||||
|
||||
+108
-49
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
@@ -72,8 +73,7 @@ type sanctionInsert struct {
|
||||
SanctionCode string
|
||||
Scope string
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID string
|
||||
Actor ActorRef
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
FlipPermanent bool
|
||||
@@ -85,8 +85,7 @@ type limitInsert struct {
|
||||
LimitCode string
|
||||
Value int32
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID string
|
||||
Actor ActorRef
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
@@ -113,7 +112,8 @@ func accountColumns() postgres.ColumnList {
|
||||
func snapshotColumns() postgres.ColumnList {
|
||||
s := table.EntitlementSnapshots
|
||||
return postgres.ColumnList{
|
||||
s.UserID, s.Tier, s.IsPaid, s.Source, s.ActorType, s.ActorID,
|
||||
s.UserID, s.Tier, s.IsPaid, s.Source,
|
||||
s.ActorType, s.ActorUserID, s.ActorUsername,
|
||||
s.ReasonCode, s.StartsAt, s.EndsAt, s.MaxRegisteredRaceNames, s.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]Ac
|
||||
r := table.SanctionRecords
|
||||
stmt := postgres.SELECT(
|
||||
r.SanctionCode, r.Scope, r.ReasonCode,
|
||||
r.ActorType, r.ActorID,
|
||||
r.ActorType, r.ActorUserID, r.ActorUsername,
|
||||
r.AppliedAt, r.ExpiresAt,
|
||||
).
|
||||
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
|
||||
@@ -292,7 +292,7 @@ func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]Ac
|
||||
SanctionCode: row.SanctionCode,
|
||||
Scope: row.Scope,
|
||||
ReasonCode: row.ReasonCode,
|
||||
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
|
||||
Actor: actorFromColumns(row.ActorType, row.ActorUserID, row.ActorUsername),
|
||||
AppliedAt: row.AppliedAt,
|
||||
}
|
||||
if row.ExpiresAt != nil {
|
||||
@@ -311,7 +311,7 @@ func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]Activ
|
||||
r := table.LimitRecords
|
||||
stmt := postgres.SELECT(
|
||||
r.LimitCode, a.Value, r.ReasonCode,
|
||||
r.ActorType, r.ActorID,
|
||||
r.ActorType, r.ActorUserID, r.ActorUsername,
|
||||
r.AppliedAt, r.ExpiresAt,
|
||||
).
|
||||
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
|
||||
@@ -331,7 +331,7 @@ func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]Activ
|
||||
LimitCode: row.LimitRecords.LimitCode,
|
||||
Value: row.LimitActive.Value,
|
||||
ReasonCode: row.LimitRecords.ReasonCode,
|
||||
Actor: ActorRef{Type: row.LimitRecords.ActorType, ID: derefString(row.LimitRecords.ActorID)},
|
||||
Actor: actorFromColumns(row.LimitRecords.ActorType, row.LimitRecords.ActorUserID, row.LimitRecords.ActorUsername),
|
||||
AppliedAt: row.LimitRecords.AppliedAt,
|
||||
}
|
||||
if row.LimitRecords.ExpiresAt != nil {
|
||||
@@ -395,9 +395,12 @@ func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot
|
||||
if err := s.assertAccountLive(ctx, snap.UserID); err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
}
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(snap.Actor)
|
||||
if err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
}
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
@@ -409,20 +412,21 @@ func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot
|
||||
table.EntitlementRecords.IsPaid,
|
||||
table.EntitlementRecords.Source,
|
||||
table.EntitlementRecords.ActorType,
|
||||
table.EntitlementRecords.ActorID,
|
||||
table.EntitlementRecords.ActorUserID,
|
||||
table.EntitlementRecords.ActorUsername,
|
||||
table.EntitlementRecords.ReasonCode,
|
||||
table.EntitlementRecords.StartsAt,
|
||||
table.EntitlementRecords.EndsAt,
|
||||
table.EntitlementRecords.CreatedAt,
|
||||
).VALUES(
|
||||
recordID, snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorID, snap.ReasonCode,
|
||||
snap.Actor.Type, actorUserID, actorUsername, snap.ReasonCode,
|
||||
snap.StartsAt, endsAt, snap.UpdatedAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert entitlement record: %w", err)
|
||||
}
|
||||
return upsertSnapshotTx(ctx, tx, snap)
|
||||
return upsertSnapshotTx(ctx, tx, snap, actorUserID, actorUsername)
|
||||
})
|
||||
if err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
@@ -437,9 +441,12 @@ func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error
|
||||
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(input.Actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(input.ActorID)
|
||||
var expiresAt any
|
||||
if input.ExpiresAt != nil {
|
||||
expiresAt = *input.ExpiresAt
|
||||
@@ -451,12 +458,13 @@ func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error
|
||||
table.SanctionRecords.Scope,
|
||||
table.SanctionRecords.ReasonCode,
|
||||
table.SanctionRecords.ActorType,
|
||||
table.SanctionRecords.ActorID,
|
||||
table.SanctionRecords.ActorUserID,
|
||||
table.SanctionRecords.ActorUsername,
|
||||
table.SanctionRecords.AppliedAt,
|
||||
table.SanctionRecords.ExpiresAt,
|
||||
).VALUES(
|
||||
recordID, input.UserID, input.SanctionCode, input.Scope, input.ReasonCode,
|
||||
input.ActorType, actorID, input.AppliedAt, expiresAt,
|
||||
input.Actor.Type, actorUserID, actorUsername, input.AppliedAt, expiresAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert sanction record: %w", err)
|
||||
@@ -498,9 +506,12 @@ func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(input.Actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(input.ActorID)
|
||||
var expiresAt any
|
||||
if input.ExpiresAt != nil {
|
||||
expiresAt = *input.ExpiresAt
|
||||
@@ -512,12 +523,13 @@ func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
table.LimitRecords.Value,
|
||||
table.LimitRecords.ReasonCode,
|
||||
table.LimitRecords.ActorType,
|
||||
table.LimitRecords.ActorID,
|
||||
table.LimitRecords.ActorUserID,
|
||||
table.LimitRecords.ActorUsername,
|
||||
table.LimitRecords.AppliedAt,
|
||||
table.LimitRecords.ExpiresAt,
|
||||
).VALUES(
|
||||
recordID, input.UserID, input.LimitCode, input.Value, input.ReasonCode,
|
||||
input.ActorType, actorID, input.AppliedAt, expiresAt,
|
||||
input.Actor.Type, actorUserID, actorUsername, input.AppliedAt, expiresAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert limit record: %w", err)
|
||||
@@ -547,12 +559,16 @@ func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
// successful idempotent operation.
|
||||
func (s *Store) SoftDeleteAccount(ctx context.Context, userID uuid.UUID, actor ActorRef, now time.Time) (bool, error) {
|
||||
a := table.Accounts
|
||||
actorIDExpr := nullableStringExpr(actor.ID)
|
||||
actorUserIDExpr, actorUsernameExpr, err := actorToColumnExprs(actor)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stmt := a.UPDATE().
|
||||
SET(
|
||||
a.DeletedAt.SET(postgres.TimestampzT(now)),
|
||||
a.DeletedActorType.SET(postgres.String(actor.Type)),
|
||||
a.DeletedActorID.SET(actorIDExpr),
|
||||
a.DeletedActorUserID.SET(actorUserIDExpr),
|
||||
a.DeletedActorUsername.SET(actorUsernameExpr),
|
||||
a.UpdatedAt.SET(postgres.TimestampzT(now)),
|
||||
).
|
||||
WHERE(
|
||||
@@ -593,18 +609,23 @@ func (s *Store) assertAccountLive(ctx context.Context, userID uuid.UUID) error {
|
||||
}
|
||||
|
||||
func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(snap.Actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
es := table.EntitlementSnapshots
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
stmt := es.INSERT(
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source,
|
||||
es.ActorType, es.ActorUserID, es.ActorUsername,
|
||||
es.ReasonCode, es.StartsAt, es.EndsAt,
|
||||
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
||||
).VALUES(
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorUserID, actorUsername,
|
||||
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
@@ -613,19 +634,20 @@ func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
|
||||
func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot, actorUserID, actorUsername any) error {
|
||||
es := table.EntitlementSnapshots
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
stmt := es.INSERT(
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source,
|
||||
es.ActorType, es.ActorUserID, es.ActorUsername,
|
||||
es.ReasonCode, es.StartsAt, es.EndsAt,
|
||||
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
||||
).VALUES(
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorUserID, actorUsername,
|
||||
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
|
||||
).
|
||||
ON_CONFLICT(es.UserID).
|
||||
@@ -634,7 +656,8 @@ func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot)
|
||||
es.IsPaid.SET(es.EXCLUDED.IsPaid),
|
||||
es.Source.SET(es.EXCLUDED.Source),
|
||||
es.ActorType.SET(es.EXCLUDED.ActorType),
|
||||
es.ActorID.SET(es.EXCLUDED.ActorID),
|
||||
es.ActorUserID.SET(es.EXCLUDED.ActorUserID),
|
||||
es.ActorUsername.SET(es.EXCLUDED.ActorUsername),
|
||||
es.ReasonCode.SET(es.EXCLUDED.ReasonCode),
|
||||
es.StartsAt.SET(es.EXCLUDED.StartsAt),
|
||||
es.EndsAt.SET(es.EXCLUDED.EndsAt),
|
||||
@@ -680,7 +703,7 @@ func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot {
|
||||
Tier: row.Tier,
|
||||
IsPaid: row.IsPaid,
|
||||
Source: row.Source,
|
||||
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
|
||||
Actor: actorFromColumns(row.ActorType, row.ActorUserID, row.ActorUsername),
|
||||
ReasonCode: row.ReasonCode,
|
||||
StartsAt: row.StartsAt,
|
||||
MaxRegisteredRaceNames: row.MaxRegisteredRaceNames,
|
||||
@@ -693,31 +716,67 @@ func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot {
|
||||
return out
|
||||
}
|
||||
|
||||
// nullableString converts a Go string to the `any` form expected by jet
|
||||
// VALUES: an empty string becomes nil so the column receives NULL.
|
||||
func nullableString(v string) any {
|
||||
if v == "" {
|
||||
return nil
|
||||
// actorToColumnArgs converts an ActorRef into the (actor_user_id,
|
||||
// actor_username) values for jet INSERT VALUES. A nil-typed `any` lands
|
||||
// as SQL NULL through the database/sql driver. Type=="user" parses ID
|
||||
// as a UUID; Type=="admin" stores ID verbatim as the username;
|
||||
// everything else (system, unknown) writes both columns as NULL. An
|
||||
// empty ID is allowed for "user" so synthetic system events that label
|
||||
// themselves as "user" do not fail.
|
||||
func actorToColumnArgs(actor ActorRef) (any, any, error) {
|
||||
switch strings.TrimSpace(actor.Type) {
|
||||
case "user":
|
||||
id := strings.TrimSpace(actor.ID)
|
||||
if id == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("user store: actor id %q is not a uuid: %w", actor.ID, err)
|
||||
}
|
||||
return uid, nil, nil
|
||||
case "admin":
|
||||
if strings.TrimSpace(actor.ID) == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, actor.ID, nil
|
||||
default:
|
||||
return nil, nil, nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// nullableStringExpr returns a typed jet expression: the empty string
|
||||
// produces NULL, otherwise a String literal. Used by UPDATE SET paths
|
||||
// where jet's SET wants a typed Expression rather than `any`.
|
||||
func nullableStringExpr(v string) postgres.StringExpression {
|
||||
if v == "" {
|
||||
return postgres.StringExp(postgres.NULL)
|
||||
// actorToColumnExprs is the typed-expression analogue of
|
||||
// actorToColumnArgs for the UPDATE SET sites. jet's generated bindings
|
||||
// type uuid columns as ColumnString (the dialect emits an explicit
|
||||
// CAST), so both returned expressions are StringExpression.
|
||||
func actorToColumnExprs(actor ActorRef) (postgres.StringExpression, postgres.StringExpression, error) {
|
||||
uidArg, nameArg, err := actorToColumnArgs(actor)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return postgres.String(v)
|
||||
uidExpr := postgres.StringExp(postgres.NULL)
|
||||
if uid, ok := uidArg.(uuid.UUID); ok {
|
||||
uidExpr = postgres.UUID(uid)
|
||||
}
|
||||
nameExpr := postgres.StringExp(postgres.NULL)
|
||||
if name, ok := nameArg.(string); ok {
|
||||
nameExpr = postgres.String(name)
|
||||
}
|
||||
return uidExpr, nameExpr, nil
|
||||
}
|
||||
|
||||
// derefString returns the empty string when p is nil, otherwise *p.
|
||||
func derefString(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
// actorFromColumns reconstructs an ActorRef from the (actor_type,
|
||||
// actor_user_id, actor_username) triple read from an audit row. The
|
||||
// non-nil column wins; both nil yields an empty ID.
|
||||
func actorFromColumns(actorType string, userID *uuid.UUID, username *string) ActorRef {
|
||||
out := ActorRef{Type: actorType}
|
||||
switch {
|
||||
case userID != nil:
|
||||
out.ID = userID.String()
|
||||
case username != nil:
|
||||
out.ID = *username
|
||||
}
|
||||
return *p
|
||||
return out
|
||||
}
|
||||
|
||||
// rowsAffectedOrNotFound returns ErrAccountNotFound when the UPDATE
|
||||
|
||||
@@ -68,7 +68,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = testOpTimeout
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
@@ -508,13 +508,15 @@ func TestListAccountsExcludesSoftDeleted(t *testing.T) {
|
||||
// recordingRevoker is a SessionRevoker spy that captures every call
|
||||
// for assertion. It is shared across tests in this package.
|
||||
type recordingRevoker struct {
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
lastActor user.SessionRevokeActor
|
||||
}
|
||||
|
||||
func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
|
||||
func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error {
|
||||
r.calls++
|
||||
r.lastUser = userID
|
||||
r.lastActor = actor
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user