feat: authsession service
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
// Package challenge defines the source-of-truth domain model for one e-mail
|
||||
// confirmation challenge.
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// Status identifies the coarse lifecycle state of one challenge.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusPendingSend reports that the challenge has been created but its
|
||||
// delivery outcome has not been recorded yet.
|
||||
StatusPendingSend Status = "pending_send"
|
||||
|
||||
// StatusSent reports that the confirmation code was delivered successfully.
|
||||
StatusSent Status = "sent"
|
||||
|
||||
// StatusDeliverySuppressed reports that outward send succeeded but actual
|
||||
// delivery was intentionally suppressed by policy.
|
||||
StatusDeliverySuppressed Status = "delivery_suppressed"
|
||||
|
||||
// StatusDeliveryThrottled reports that a fresh challenge was created but
|
||||
// delivery was skipped because the auth-side resend cooldown is still
|
||||
// active.
|
||||
StatusDeliveryThrottled Status = "delivery_throttled"
|
||||
|
||||
// StatusConfirmedPendingExpire reports that the challenge was confirmed
|
||||
// successfully and is temporarily retained for idempotent retry handling.
|
||||
StatusConfirmedPendingExpire Status = "confirmed_pending_expire"
|
||||
|
||||
// StatusExpired reports that the challenge can no longer be confirmed.
|
||||
StatusExpired Status = "expired"
|
||||
|
||||
// StatusFailed reports that the challenge reached a terminal failure state.
|
||||
StatusFailed Status = "failed"
|
||||
|
||||
// StatusCancelled reports that the challenge was cancelled explicitly.
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Status is one of the challenge states supported by
|
||||
// the current domain model.
|
||||
func (s Status) IsKnown() bool {
|
||||
switch s {
|
||||
case StatusPendingSend,
|
||||
StatusSent,
|
||||
StatusDeliverySuppressed,
|
||||
StatusDeliveryThrottled,
|
||||
StatusConfirmedPendingExpire,
|
||||
StatusExpired,
|
||||
StatusFailed,
|
||||
StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether Status can no longer accept any lifecycle
|
||||
// transition in the v1 challenge state machine.
|
||||
func (s Status) IsTerminal() bool {
|
||||
switch s {
|
||||
case StatusExpired, StatusFailed, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AcceptsFreshConfirm reports whether Status may still consume a first
|
||||
// successful confirmation attempt.
|
||||
func (s Status) AcceptsFreshConfirm() bool {
|
||||
switch s {
|
||||
case StatusSent, StatusDeliverySuppressed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmedRetryState reports whether Status should use the idempotent retry
|
||||
// path for a previously successful confirmation.
|
||||
func (s Status) IsConfirmedRetryState() bool {
|
||||
return s == StatusConfirmedPendingExpire
|
||||
}
|
||||
|
||||
// CanTransitionTo reports whether the current challenge Status may move to
|
||||
// next under the coarse lifecycle rules fixed by Stage 2.
|
||||
func (s Status) CanTransitionTo(next Status) bool {
|
||||
switch s {
|
||||
case StatusPendingSend:
|
||||
switch next {
|
||||
case StatusSent, StatusDeliverySuppressed, StatusDeliveryThrottled, StatusFailed, StatusCancelled, StatusExpired:
|
||||
return true
|
||||
}
|
||||
case StatusSent, StatusDeliverySuppressed:
|
||||
switch next {
|
||||
case StatusConfirmedPendingExpire, StatusFailed, StatusCancelled, StatusExpired:
|
||||
return true
|
||||
}
|
||||
case StatusConfirmedPendingExpire:
|
||||
return next == StatusExpired
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DeliveryState identifies the recorded delivery result of one challenge.
|
||||
type DeliveryState string
|
||||
|
||||
const (
|
||||
// DeliveryPending reports that no delivery outcome has been recorded yet.
|
||||
DeliveryPending DeliveryState = "pending"
|
||||
|
||||
// DeliverySent reports that the challenge code was sent successfully.
|
||||
DeliverySent DeliveryState = "sent"
|
||||
|
||||
// DeliverySuppressed reports that the outward flow stays success-shaped
|
||||
// while actual delivery is intentionally skipped.
|
||||
DeliverySuppressed DeliveryState = "suppressed"
|
||||
|
||||
// DeliveryThrottled reports that the outward flow stays success-shaped
|
||||
// while actual delivery is skipped because the resend cooldown is active.
|
||||
DeliveryThrottled DeliveryState = "throttled"
|
||||
|
||||
// DeliveryFailed reports that delivery was attempted and failed explicitly.
|
||||
DeliveryFailed DeliveryState = "failed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether DeliveryState is one of the delivery states
|
||||
// supported by the current domain model.
|
||||
func (s DeliveryState) IsKnown() bool {
|
||||
switch s {
|
||||
case DeliveryPending, DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CanTransitionTo reports whether the current DeliveryState may move to next
|
||||
// under the coarse delivery rules fixed by Stage 2.
|
||||
func (s DeliveryState) CanTransitionTo(next DeliveryState) bool {
|
||||
if s != DeliveryPending {
|
||||
return false
|
||||
}
|
||||
|
||||
switch next {
|
||||
case DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AttemptCounters groups the mutable send and confirm counters tracked by one
|
||||
// challenge aggregate.
|
||||
type AttemptCounters struct {
|
||||
// Send counts delivery attempts initiated for the challenge.
|
||||
Send int
|
||||
|
||||
// Confirm counts confirmation attempts evaluated against the challenge.
|
||||
Confirm int
|
||||
}
|
||||
|
||||
// Validate reports whether AttemptCounters contains only non-negative values.
|
||||
func (c AttemptCounters) Validate() error {
|
||||
if c.Send < 0 {
|
||||
return errors.New("challenge send attempt count must not be negative")
|
||||
}
|
||||
if c.Confirm < 0 {
|
||||
return errors.New("challenge confirm attempt count must not be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AbuseMetadata stores minimal abuse-related timestamps without fixing later
|
||||
// anti-abuse policy details too early.
|
||||
type AbuseMetadata struct {
|
||||
// LastAttemptAt optionally records the last send or confirm attempt time
|
||||
// associated with the challenge.
|
||||
LastAttemptAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether AbuseMetadata contains structurally valid values.
|
||||
func (m AbuseMetadata) Validate() error {
|
||||
if m.LastAttemptAt != nil && m.LastAttemptAt.IsZero() {
|
||||
return errors.New("challenge abuse metadata last attempt time must not be zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirmation stores the idempotency metadata recorded after a successful
|
||||
// challenge confirmation.
|
||||
type Confirmation struct {
|
||||
// SessionID is the created device session returned by the successful
|
||||
// confirmation.
|
||||
SessionID common.DeviceSessionID
|
||||
|
||||
// ClientPublicKey is the validated client key bound to SessionID.
|
||||
ClientPublicKey common.ClientPublicKey
|
||||
|
||||
// ConfirmedAt records when the successful confirmation happened.
|
||||
ConfirmedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether Confirmation contains all metadata required for a
|
||||
// confirmed challenge.
|
||||
func (c Confirmation) Validate() error {
|
||||
if err := c.SessionID.Validate(); err != nil {
|
||||
return fmt.Errorf("challenge confirmation session id: %w", err)
|
||||
}
|
||||
if err := c.ClientPublicKey.Validate(); err != nil {
|
||||
return fmt.Errorf("challenge confirmation client public key: %w", err)
|
||||
}
|
||||
if c.ConfirmedAt.IsZero() {
|
||||
return errors.New("challenge confirmation time must not be zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Challenge is the minimal source-of-truth aggregate shape fixed by Stage 2.
|
||||
type Challenge struct {
|
||||
// ID identifies the challenge.
|
||||
ID common.ChallengeID
|
||||
|
||||
// Email stores the normalized target e-mail address.
|
||||
Email common.Email
|
||||
|
||||
// CodeHash stores only the hashed confirmation code.
|
||||
CodeHash []byte
|
||||
|
||||
// Status reports the coarse challenge lifecycle state.
|
||||
Status Status
|
||||
|
||||
// DeliveryState reports the recorded delivery outcome.
|
||||
DeliveryState DeliveryState
|
||||
|
||||
// CreatedAt reports when the challenge was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// ExpiresAt reports when the challenge becomes unusable.
|
||||
ExpiresAt time.Time
|
||||
|
||||
// Attempts groups the send and confirm counters.
|
||||
Attempts AttemptCounters
|
||||
|
||||
// Abuse stores minimal abuse-related timestamps.
|
||||
Abuse AbuseMetadata
|
||||
|
||||
// Confirmation is present only after a successful confirm transition.
|
||||
Confirmation *Confirmation
|
||||
}
|
||||
|
||||
// IsExpiredAt reports whether the challenge is unusable at now either because
|
||||
// it is already marked expired or because its expiration timestamp has passed.
|
||||
func (c Challenge) IsExpiredAt(now time.Time) bool {
|
||||
return c.Status == StatusExpired || !c.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
// Validate reports whether Challenge satisfies the Stage-2 structural and
|
||||
// lifecycle invariants.
|
||||
func (c Challenge) Validate() error {
|
||||
if err := c.ID.Validate(); err != nil {
|
||||
return fmt.Errorf("challenge id: %w", err)
|
||||
}
|
||||
if err := c.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("challenge email: %w", err)
|
||||
}
|
||||
if len(c.CodeHash) == 0 {
|
||||
return errors.New("challenge code hash must not be empty")
|
||||
}
|
||||
if !c.Status.IsKnown() {
|
||||
return fmt.Errorf("challenge status %q is unsupported", c.Status)
|
||||
}
|
||||
if !c.DeliveryState.IsKnown() {
|
||||
return fmt.Errorf("challenge delivery state %q is unsupported", c.DeliveryState)
|
||||
}
|
||||
if c.CreatedAt.IsZero() {
|
||||
return errors.New("challenge creation time must not be zero")
|
||||
}
|
||||
if c.ExpiresAt.IsZero() {
|
||||
return errors.New("challenge expiration time must not be zero")
|
||||
}
|
||||
if c.ExpiresAt.Before(c.CreatedAt) {
|
||||
return errors.New("challenge expiration time must not be before creation time")
|
||||
}
|
||||
if err := c.Attempts.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Abuse.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch c.Status {
|
||||
case StatusPendingSend:
|
||||
if c.DeliveryState != DeliveryPending {
|
||||
return errors.New("pending_send challenge must keep pending delivery state")
|
||||
}
|
||||
case StatusSent:
|
||||
if c.DeliveryState != DeliverySent {
|
||||
return errors.New("sent challenge must keep sent delivery state")
|
||||
}
|
||||
case StatusDeliverySuppressed:
|
||||
if c.DeliveryState != DeliverySuppressed {
|
||||
return errors.New("delivery_suppressed challenge must keep suppressed delivery state")
|
||||
}
|
||||
case StatusDeliveryThrottled:
|
||||
if c.DeliveryState != DeliveryThrottled {
|
||||
return errors.New("delivery_throttled challenge must keep throttled delivery state")
|
||||
}
|
||||
case StatusConfirmedPendingExpire:
|
||||
if c.DeliveryState != DeliverySent && c.DeliveryState != DeliverySuppressed {
|
||||
return errors.New("confirmed_pending_expire challenge must come from sent or suppressed delivery state")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Status == StatusConfirmedPendingExpire {
|
||||
if c.Confirmation == nil {
|
||||
return errors.New("confirmed_pending_expire challenge must contain confirmation metadata")
|
||||
}
|
||||
if err := c.Confirmation.Validate(); err != nil {
|
||||
return fmt.Errorf("challenge confirmation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.Confirmation != nil {
|
||||
return errors.New("only confirmed_pending_expire challenge may contain confirmation metadata")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
func TestPolicyConstants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if InitialTTL != 5*time.Minute {
|
||||
require.Failf(t, "test failed", "InitialTTL = %s, want %s", InitialTTL, 5*time.Minute)
|
||||
}
|
||||
if ResendThrottleCooldown != time.Minute {
|
||||
require.Failf(t, "test failed", "ResendThrottleCooldown = %s, want %s", ResendThrottleCooldown, time.Minute)
|
||||
}
|
||||
if ConfirmedRetention != 5*time.Minute {
|
||||
require.Failf(t, "test failed", "ConfirmedRetention = %s, want %s", ConfirmedRetention, 5*time.Minute)
|
||||
}
|
||||
if MaxInvalidConfirmAttempts != 5 {
|
||||
require.Failf(t, "test failed", "MaxInvalidConfirmAttempts = %d, want %d", MaxInvalidConfirmAttempts, 5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Status
|
||||
want bool
|
||||
}{
|
||||
{name: "pending send", value: StatusPendingSend, want: true},
|
||||
{name: "sent", value: StatusSent, want: true},
|
||||
{name: "suppressed", value: StatusDeliverySuppressed, want: true},
|
||||
{name: "throttled", value: StatusDeliveryThrottled, want: true},
|
||||
{name: "confirmed", value: StatusConfirmedPendingExpire, want: true},
|
||||
{name: "expired", value: StatusExpired, want: true},
|
||||
{name: "failed", value: StatusFailed, want: true},
|
||||
{name: "cancelled", value: StatusCancelled, want: true},
|
||||
{name: "unknown", value: Status("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Status
|
||||
want bool
|
||||
}{
|
||||
{name: "pending send", value: StatusPendingSend, want: false},
|
||||
{name: "sent", value: StatusSent, want: false},
|
||||
{name: "delivery suppressed", value: StatusDeliverySuppressed, want: false},
|
||||
{name: "delivery throttled", value: StatusDeliveryThrottled, want: false},
|
||||
{name: "confirmed pending expire", value: StatusConfirmedPendingExpire, want: false},
|
||||
{name: "expired", value: StatusExpired, want: true},
|
||||
{name: "failed", value: StatusFailed, want: true},
|
||||
{name: "cancelled", value: StatusCancelled, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsTerminal(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsTerminal() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAcceptsFreshConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Status
|
||||
want bool
|
||||
}{
|
||||
{name: "pending send", value: StatusPendingSend, want: false},
|
||||
{name: "sent", value: StatusSent, want: true},
|
||||
{name: "delivery suppressed", value: StatusDeliverySuppressed, want: true},
|
||||
{name: "delivery throttled", value: StatusDeliveryThrottled, want: false},
|
||||
{name: "confirmed", value: StatusConfirmedPendingExpire, want: false},
|
||||
{name: "expired", value: StatusExpired, want: false},
|
||||
{name: "failed", value: StatusFailed, want: false},
|
||||
{name: "cancelled", value: StatusCancelled, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.AcceptsFreshConfirm(); got != tt.want {
|
||||
require.Failf(t, "test failed", "AcceptsFreshConfirm() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsConfirmedRetryState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Status
|
||||
want bool
|
||||
}{
|
||||
{name: "sent", value: StatusSent, want: false},
|
||||
{name: "delivery suppressed", value: StatusDeliverySuppressed, want: false},
|
||||
{name: "delivery throttled", value: StatusDeliveryThrottled, want: false},
|
||||
{name: "confirmed", value: StatusConfirmedPendingExpire, want: true},
|
||||
{name: "expired", value: StatusExpired, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsConfirmedRetryState(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsConfirmedRetryState() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCanTransitionTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from Status
|
||||
to Status
|
||||
want bool
|
||||
}{
|
||||
{name: "pending to sent", from: StatusPendingSend, to: StatusSent, want: true},
|
||||
{name: "pending to suppressed", from: StatusPendingSend, to: StatusDeliverySuppressed, want: true},
|
||||
{name: "pending to throttled", from: StatusPendingSend, to: StatusDeliveryThrottled, want: true},
|
||||
{name: "pending to failed", from: StatusPendingSend, to: StatusFailed, want: true},
|
||||
{name: "pending to cancelled", from: StatusPendingSend, to: StatusCancelled, want: true},
|
||||
{name: "pending to expired", from: StatusPendingSend, to: StatusExpired, want: true},
|
||||
{name: "pending to confirmed", from: StatusPendingSend, to: StatusConfirmedPendingExpire, want: false},
|
||||
{name: "sent to confirmed", from: StatusSent, to: StatusConfirmedPendingExpire, want: true},
|
||||
{name: "sent to failed", from: StatusSent, to: StatusFailed, want: true},
|
||||
{name: "suppressed to confirmed", from: StatusDeliverySuppressed, to: StatusConfirmedPendingExpire, want: true},
|
||||
{name: "throttled to confirmed", from: StatusDeliveryThrottled, to: StatusConfirmedPendingExpire, want: false},
|
||||
{name: "confirmed to expired", from: StatusConfirmedPendingExpire, to: StatusExpired, want: true},
|
||||
{name: "confirmed to failed", from: StatusConfirmedPendingExpire, to: StatusFailed, want: false},
|
||||
{name: "expired terminal", from: StatusExpired, to: StatusCancelled, want: false},
|
||||
{name: "failed terminal", from: StatusFailed, to: StatusExpired, want: false},
|
||||
{name: "cancelled terminal", from: StatusCancelled, to: StatusExpired, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
|
||||
require.Failf(t, "test failed", "CanTransitionTo() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryStateIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value DeliveryState
|
||||
want bool
|
||||
}{
|
||||
{name: "pending", value: DeliveryPending, want: true},
|
||||
{name: "sent", value: DeliverySent, want: true},
|
||||
{name: "suppressed", value: DeliverySuppressed, want: true},
|
||||
{name: "throttled", value: DeliveryThrottled, want: true},
|
||||
{name: "failed", value: DeliveryFailed, want: true},
|
||||
{name: "unknown", value: DeliveryState("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryStateCanTransitionTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from DeliveryState
|
||||
to DeliveryState
|
||||
want bool
|
||||
}{
|
||||
{name: "pending to sent", from: DeliveryPending, to: DeliverySent, want: true},
|
||||
{name: "pending to suppressed", from: DeliveryPending, to: DeliverySuppressed, want: true},
|
||||
{name: "pending to throttled", from: DeliveryPending, to: DeliveryThrottled, want: true},
|
||||
{name: "pending to failed", from: DeliveryPending, to: DeliveryFailed, want: true},
|
||||
{name: "sent terminal", from: DeliverySent, to: DeliveryFailed, want: false},
|
||||
{name: "suppressed terminal", from: DeliverySuppressed, to: DeliverySent, want: false},
|
||||
{name: "failed terminal", from: DeliveryFailed, to: DeliverySent, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
|
||||
require.Failf(t, "test failed", "CanTransitionTo() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeIsExpiredAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_121_700, 0).UTC()
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Challenge)
|
||||
want bool
|
||||
}{
|
||||
{name: "active before expiration", want: false},
|
||||
{
|
||||
name: "expired status",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Status = StatusExpired
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "expiration timestamp passed",
|
||||
mutate: func(c *Challenge) {
|
||||
c.ExpiresAt = now
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "confirmed retained before expiration",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Status = StatusConfirmedPendingExpire
|
||||
c.DeliveryState = DeliverySent
|
||||
c.Confirmation = validConfirmation(t)
|
||||
c.ExpiresAt = now.Add(time.Second)
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challenge := validChallenge(t)
|
||||
challenge.CreatedAt = now.Add(-time.Minute)
|
||||
challenge.ExpiresAt = now.Add(time.Minute)
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(&challenge)
|
||||
}
|
||||
if err := challenge.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := challenge.IsExpiredAt(now); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsExpiredAt() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Challenge)
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid pending"},
|
||||
{
|
||||
name: "valid confirmed",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Status = StatusConfirmedPendingExpire
|
||||
c.DeliveryState = DeliverySent
|
||||
c.Confirmation = validConfirmation(t)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "confirmed requires metadata",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Status = StatusConfirmedPendingExpire
|
||||
c.DeliveryState = DeliverySent
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unconfirmed rejects metadata",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Confirmation = validConfirmation(t)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "pending requires pending delivery",
|
||||
mutate: func(c *Challenge) {
|
||||
c.DeliveryState = DeliverySent
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "sent requires sent delivery",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Status = StatusSent
|
||||
c.DeliveryState = DeliverySuppressed
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "throttled requires throttled delivery",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Status = StatusDeliveryThrottled
|
||||
c.DeliveryState = DeliverySent
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "expiration before creation",
|
||||
mutate: func(c *Challenge) {
|
||||
c.ExpiresAt = c.CreatedAt.Add(-time.Second)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative confirm attempts",
|
||||
mutate: func(c *Challenge) {
|
||||
c.Attempts.Confirm = -1
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challenge := validChallenge(t)
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(&challenge)
|
||||
}
|
||||
|
||||
err := challenge.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validChallenge(t *testing.T) Challenge {
|
||||
t.Helper()
|
||||
|
||||
return Challenge{
|
||||
ID: common.ChallengeID("challenge-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
CodeHash: []byte("hash-123"),
|
||||
Status: StatusPendingSend,
|
||||
DeliveryState: DeliveryPending,
|
||||
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
|
||||
ExpiresAt: time.Unix(1_775_121_900, 0).UTC(),
|
||||
Attempts: AttemptCounters{
|
||||
Send: 0,
|
||||
Confirm: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validConfirmation(t *testing.T) *Confirmation {
|
||||
t.Helper()
|
||||
|
||||
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
|
||||
for index := range raw {
|
||||
raw[index] = byte(index + 1)
|
||||
}
|
||||
|
||||
key, err := common.NewClientPublicKey(raw)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "NewClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
|
||||
return &Confirmation{
|
||||
SessionID: common.DeviceSessionID("device-session-123"),
|
||||
ClientPublicKey: key,
|
||||
ConfirmedAt: time.Unix(1_775_121_700, 0).UTC(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package challenge
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// InitialTTL is the v1 lifetime of a newly created challenge before it
|
||||
// becomes expired.
|
||||
InitialTTL = 5 * time.Minute
|
||||
|
||||
// ResendThrottleCooldown is the fixed Stage-17 cooldown applied to repeated
|
||||
// public send-email-code requests for the same normalized e-mail address.
|
||||
ResendThrottleCooldown = time.Minute
|
||||
|
||||
// ConfirmedRetention is the v1 idempotency window kept after a successful
|
||||
// challenge confirmation.
|
||||
ConfirmedRetention = 5 * time.Minute
|
||||
|
||||
// MaxInvalidConfirmAttempts is the v1 threshold after which repeated invalid
|
||||
// confirmation codes move a challenge into the failed state.
|
||||
MaxInvalidConfirmAttempts = 5
|
||||
)
|
||||
|
||||
// V1 resend policy keeps every public send-email-code request independent:
|
||||
// each call creates a fresh challenge, existing challenges are not reused or
|
||||
// deduplicated, and Stage 17 adds a fixed auth-side resend cooldown that may
|
||||
// record the fresh challenge as delivery_throttled.
|
||||
@@ -0,0 +1,201 @@
|
||||
// Package common defines small shared domain primitives used by auth/session
|
||||
// aggregates and integration models.
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChallengeID identifies one auth confirmation challenge owned by the service.
|
||||
type ChallengeID string
|
||||
|
||||
// String returns ChallengeID as a plain string identifier.
|
||||
func (id ChallengeID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether ChallengeID does not contain a usable identifier.
|
||||
func (id ChallengeID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ChallengeID is non-empty and already normalized for
|
||||
// domain use.
|
||||
func (id ChallengeID) Validate() error {
|
||||
return validateToken("challenge id", string(id))
|
||||
}
|
||||
|
||||
// DeviceSessionID identifies one persisted device session.
|
||||
type DeviceSessionID string
|
||||
|
||||
// String returns DeviceSessionID as a plain string identifier.
|
||||
func (id DeviceSessionID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether DeviceSessionID does not contain a usable identifier.
|
||||
func (id DeviceSessionID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether DeviceSessionID is non-empty and already
|
||||
// normalized for domain use.
|
||||
func (id DeviceSessionID) Validate() error {
|
||||
return validateToken("device session id", string(id))
|
||||
}
|
||||
|
||||
// UserID identifies one user resolved through the user-service boundary.
|
||||
type UserID string
|
||||
|
||||
// String returns UserID as a plain string identifier.
|
||||
func (id UserID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether UserID does not contain a usable identifier.
|
||||
func (id UserID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether UserID is non-empty and already normalized for
|
||||
// domain use.
|
||||
func (id UserID) Validate() error {
|
||||
return validateToken("user id", string(id))
|
||||
}
|
||||
|
||||
// Email stores one already-normalized e-mail address used by the auth domain.
|
||||
type Email string
|
||||
|
||||
// String returns Email as the stored canonical e-mail string.
|
||||
func (e Email) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
// IsZero reports whether Email does not contain a usable e-mail value.
|
||||
func (e Email) IsZero() bool {
|
||||
return strings.TrimSpace(string(e)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Email is non-empty, does not contain surrounding
|
||||
// whitespace, and matches the same single-address syntax expected by the
|
||||
// public gateway contract.
|
||||
func (e Email) Validate() error {
|
||||
raw := string(e)
|
||||
if err := validateToken("email", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAddress, err := mail.ParseAddress(raw)
|
||||
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
|
||||
return fmt.Errorf("email %q must be a single valid email address", raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeReasonCode stores one machine-readable revoke reason code.
|
||||
type RevokeReasonCode string
|
||||
|
||||
// String returns RevokeReasonCode as its stored code value.
|
||||
func (code RevokeReasonCode) String() string {
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// IsZero reports whether RevokeReasonCode is empty.
|
||||
func (code RevokeReasonCode) IsZero() bool {
|
||||
return strings.TrimSpace(string(code)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeReasonCode is non-empty and normalized for
|
||||
// domain use.
|
||||
func (code RevokeReasonCode) Validate() error {
|
||||
return validateToken("revoke reason code", string(code))
|
||||
}
|
||||
|
||||
// RevokeActorType stores one machine-readable actor type for revoke audit.
|
||||
type RevokeActorType string
|
||||
|
||||
// String returns RevokeActorType as its stored type value.
|
||||
func (actorType RevokeActorType) String() string {
|
||||
return string(actorType)
|
||||
}
|
||||
|
||||
// IsZero reports whether RevokeActorType is empty.
|
||||
func (actorType RevokeActorType) IsZero() bool {
|
||||
return strings.TrimSpace(string(actorType)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeActorType is non-empty and normalized for
|
||||
// domain use.
|
||||
func (actorType RevokeActorType) Validate() error {
|
||||
return validateToken("revoke actor type", string(actorType))
|
||||
}
|
||||
|
||||
// ClientPublicKey stores one validated Ed25519 public key in parsed binary
|
||||
// form inside the domain model.
|
||||
type ClientPublicKey struct {
|
||||
value ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewClientPublicKey validates value and returns a defensive copy suitable for
|
||||
// storing inside domain aggregates.
|
||||
func NewClientPublicKey(value ed25519.PublicKey) (ClientPublicKey, error) {
|
||||
key := ClientPublicKey{
|
||||
value: bytes.Clone(value),
|
||||
}
|
||||
if err := key.Validate(); err != nil {
|
||||
return ClientPublicKey{}, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// String returns ClientPublicKey as the standard base64-encoded raw 32-byte
|
||||
// Ed25519 public key string.
|
||||
func (key ClientPublicKey) String() string {
|
||||
if key.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(key.value)
|
||||
}
|
||||
|
||||
// IsZero reports whether ClientPublicKey does not contain key material.
|
||||
func (key ClientPublicKey) IsZero() bool {
|
||||
return len(key.value) == 0
|
||||
}
|
||||
|
||||
// Validate reports whether ClientPublicKey contains exactly one Ed25519 public
|
||||
// key.
|
||||
func (key ClientPublicKey) Validate() error {
|
||||
switch len(key.value) {
|
||||
case 0:
|
||||
return errors.New("client public key must not be empty")
|
||||
case ed25519.PublicKeySize:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("client public key must contain exactly %d bytes", ed25519.PublicKeySize)
|
||||
}
|
||||
}
|
||||
|
||||
// PublicKey returns a defensive copy of the parsed Ed25519 public key.
|
||||
func (key ClientPublicKey) PublicKey() ed25519.PublicKey {
|
||||
return bytes.Clone(key.value)
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChallengeIDValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value ChallengeID
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: ChallengeID("challenge-123")},
|
||||
{name: "empty", value: ChallengeID(""), wantErr: true},
|
||||
{name: "whitespace", value: ChallengeID(" challenge-123 "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Email
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: Email("pilot@example.com")},
|
||||
{name: "invalid", value: Email("pilot"), wantErr: true},
|
||||
{name: "surrounding whitespace", value: Email(" pilot@example.com "), wantErr: true},
|
||||
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
|
||||
for i := range raw {
|
||||
raw[i] = byte(i)
|
||||
}
|
||||
|
||||
key, err := NewClientPublicKey(raw)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "NewClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
|
||||
if key.IsZero() {
|
||||
require.FailNow(t, "IsZero() = true, want false")
|
||||
}
|
||||
|
||||
cloned := key.PublicKey()
|
||||
if len(cloned) != ed25519.PublicKeySize {
|
||||
require.Failf(t, "test failed", "PublicKey() length = %d, want %d", len(cloned), ed25519.PublicKeySize)
|
||||
}
|
||||
|
||||
raw[0] = 99
|
||||
if key.PublicKey()[0] == 99 {
|
||||
require.FailNow(t, "PublicKey() was mutated through constructor input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPublicKeyValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value ClientPublicKey
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty", value: ClientPublicKey{}, wantErr: true},
|
||||
{
|
||||
name: "short",
|
||||
value: ClientPublicKey{value: make(ed25519.PublicKey, ed25519.PublicKeySize-1)},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
value: ClientPublicKey{value: make(ed25519.PublicKey, ed25519.PublicKeySize)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package devicesession defines the source-of-truth domain model for one
|
||||
// authenticated device session.
|
||||
package devicesession
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// Status identifies the coarse lifecycle state of one device session.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusActive reports that the session may be used for authenticated
|
||||
// request verification.
|
||||
StatusActive Status = "active"
|
||||
|
||||
// StatusRevoked reports that the session has been revoked and must no
|
||||
// longer authenticate requests.
|
||||
StatusRevoked Status = "revoked"
|
||||
)
|
||||
|
||||
// RevokeReasonDeviceLogout reports that one device logged itself out.
|
||||
const RevokeReasonDeviceLogout common.RevokeReasonCode = "device_logout"
|
||||
|
||||
// RevokeReasonLogoutAll reports that the session was revoked by a
|
||||
// user-scoped logout-all action.
|
||||
const RevokeReasonLogoutAll common.RevokeReasonCode = "logout_all"
|
||||
|
||||
// RevokeReasonAdminRevoke reports that the session was revoked
|
||||
// administratively.
|
||||
const RevokeReasonAdminRevoke common.RevokeReasonCode = "admin_revoke"
|
||||
|
||||
// RevokeReasonUserBlocked reports that the session was revoked because future
|
||||
// auth flow for the user or e-mail was blocked.
|
||||
const RevokeReasonUserBlocked common.RevokeReasonCode = "user_blocked"
|
||||
|
||||
// IsKnown reports whether Status is one of the device-session states
|
||||
// supported by the current domain model.
|
||||
func (s Status) IsKnown() bool {
|
||||
switch s {
|
||||
case StatusActive, StatusRevoked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CanTransitionTo reports whether the current device-session Status may move
|
||||
// to next under the Stage-2 lifecycle rules.
|
||||
func (s Status) CanTransitionTo(next Status) bool {
|
||||
return s == StatusActive && next == StatusRevoked
|
||||
}
|
||||
|
||||
// IsKnownRevokeReasonCode reports whether code is one of the built-in revoke
|
||||
// reasons fixed by the Stage-2 domain model.
|
||||
func IsKnownRevokeReasonCode(code common.RevokeReasonCode) bool {
|
||||
switch code {
|
||||
case RevokeReasonDeviceLogout,
|
||||
RevokeReasonLogoutAll,
|
||||
RevokeReasonAdminRevoke,
|
||||
RevokeReasonUserBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation stores the audit metadata recorded when a session is revoked.
|
||||
type Revocation struct {
|
||||
// At reports when the revoke took effect.
|
||||
At time.Time
|
||||
|
||||
// ReasonCode stores one machine-readable revoke reason code.
|
||||
ReasonCode common.RevokeReasonCode
|
||||
|
||||
// ActorType stores one machine-readable initiator type.
|
||||
ActorType common.RevokeActorType
|
||||
|
||||
// ActorID optionally stores a stable initiator identifier.
|
||||
ActorID string
|
||||
}
|
||||
|
||||
// Validate reports whether Revocation contains all metadata required for a
|
||||
// revoked session.
|
||||
func (r Revocation) Validate() error {
|
||||
if r.At.IsZero() {
|
||||
return errors.New("session revocation time must not be zero")
|
||||
}
|
||||
if err := r.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("session revocation reason code: %w", err)
|
||||
}
|
||||
if err := r.ActorType.Validate(); err != nil {
|
||||
return fmt.Errorf("session revocation actor type: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(r.ActorID) != r.ActorID {
|
||||
return errors.New("session revocation actor id must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Session is the minimal source-of-truth aggregate shape fixed by Stage 2.
|
||||
type Session struct {
|
||||
// ID identifies the device session.
|
||||
ID common.DeviceSessionID
|
||||
|
||||
// UserID identifies the durable user linkage for the session.
|
||||
UserID common.UserID
|
||||
|
||||
// ClientPublicKey stores the validated device public key in parsed form.
|
||||
ClientPublicKey common.ClientPublicKey
|
||||
|
||||
// Status reports the coarse lifecycle state of the session.
|
||||
Status Status
|
||||
|
||||
// CreatedAt reports when the session was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// Revocation is present only when Status is StatusRevoked.
|
||||
Revocation *Revocation
|
||||
}
|
||||
|
||||
// Validate reports whether Session satisfies the Stage-2 structural and
|
||||
// lifecycle invariants.
|
||||
func (s Session) Validate() error {
|
||||
if err := s.ID.Validate(); err != nil {
|
||||
return fmt.Errorf("session id: %w", err)
|
||||
}
|
||||
if err := s.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("session user id: %w", err)
|
||||
}
|
||||
if err := s.ClientPublicKey.Validate(); err != nil {
|
||||
return fmt.Errorf("session client public key: %w", err)
|
||||
}
|
||||
if !s.Status.IsKnown() {
|
||||
return fmt.Errorf("session status %q is unsupported", s.Status)
|
||||
}
|
||||
if s.CreatedAt.IsZero() {
|
||||
return errors.New("session creation time must not be zero")
|
||||
}
|
||||
|
||||
switch s.Status {
|
||||
case StatusActive:
|
||||
if s.Revocation != nil {
|
||||
return errors.New("active session must not contain revocation metadata")
|
||||
}
|
||||
case StatusRevoked:
|
||||
if s.Revocation == nil {
|
||||
return errors.New("revoked session must contain revocation metadata")
|
||||
}
|
||||
if err := s.Revocation.Validate(); err != nil {
|
||||
return fmt.Errorf("session revocation: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package devicesession
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Status
|
||||
want bool
|
||||
}{
|
||||
{name: "active", value: StatusActive, want: true},
|
||||
{name: "revoked", value: StatusRevoked, want: true},
|
||||
{name: "unknown", value: Status("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCanTransitionTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from Status
|
||||
to Status
|
||||
want bool
|
||||
}{
|
||||
{name: "active to revoked", from: StatusActive, to: StatusRevoked, want: true},
|
||||
{name: "active to active", from: StatusActive, to: StatusActive, want: false},
|
||||
{name: "revoked terminal", from: StatusRevoked, to: StatusActive, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
|
||||
require.Failf(t, "test failed", "CanTransitionTo() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownRevokeReasonCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value common.RevokeReasonCode
|
||||
want bool
|
||||
}{
|
||||
{name: "device logout", value: RevokeReasonDeviceLogout, want: true},
|
||||
{name: "logout all", value: RevokeReasonLogoutAll, want: true},
|
||||
{name: "admin revoke", value: RevokeReasonAdminRevoke, want: true},
|
||||
{name: "user blocked", value: RevokeReasonUserBlocked, want: true},
|
||||
{name: "custom code", value: common.RevokeReasonCode("custom_policy"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := IsKnownRevokeReasonCode(tt.value); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnownRevokeReasonCode() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Session)
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "active valid"},
|
||||
{
|
||||
name: "revoked valid",
|
||||
mutate: func(s *Session) {
|
||||
s.Status = StatusRevoked
|
||||
s.Revocation = validRevocation()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active rejects revocation",
|
||||
mutate: func(s *Session) {
|
||||
s.Revocation = validRevocation()
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "revoked requires revocation",
|
||||
mutate: func(s *Session) {
|
||||
s.Status = StatusRevoked
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "revoked requires complete metadata",
|
||||
mutate: func(s *Session) {
|
||||
s.Status = StatusRevoked
|
||||
revocation := validRevocation()
|
||||
revocation.ReasonCode = ""
|
||||
s.Revocation = revocation
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := validSession(t)
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(&session)
|
||||
}
|
||||
|
||||
err := session.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validSession(t *testing.T) Session {
|
||||
t.Helper()
|
||||
|
||||
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
|
||||
for index := range raw {
|
||||
raw[index] = byte(index + 7)
|
||||
}
|
||||
|
||||
key, err := common.NewClientPublicKey(raw)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "NewClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
|
||||
return Session{
|
||||
ID: common.DeviceSessionID("device-session-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
ClientPublicKey: key,
|
||||
Status: StatusActive,
|
||||
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func validRevocation() *Revocation {
|
||||
return &Revocation{
|
||||
At: time.Unix(1_775_121_800, 0).UTC(),
|
||||
ReasonCode: RevokeReasonAdminRevoke,
|
||||
ActorType: common.RevokeActorType("admin"),
|
||||
ActorID: "admin-123",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Package gatewayprojection defines the gateway-facing integration snapshot
|
||||
// model that stays separate from source-of-truth session entities.
|
||||
package gatewayprojection
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// Status identifies the coarse lifecycle state projected to the gateway.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusActive reports that the projected session may authenticate
|
||||
// requests on the gateway hot path.
|
||||
StatusActive Status = "active"
|
||||
|
||||
// StatusRevoked reports that the projected session must be rejected on the
|
||||
// gateway hot path.
|
||||
StatusRevoked Status = "revoked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Status is one of the projection states supported by
|
||||
// the current integration model.
|
||||
func (s Status) IsKnown() bool {
|
||||
switch s {
|
||||
case StatusActive, StatusRevoked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot stores the gateway-facing session projection without exposing any
|
||||
// Redis-specific field naming or storage encoding.
|
||||
type Snapshot struct {
|
||||
// DeviceSessionID identifies the projected device session.
|
||||
DeviceSessionID common.DeviceSessionID
|
||||
|
||||
// UserID identifies the projected user.
|
||||
UserID common.UserID
|
||||
|
||||
// ClientPublicKey stores the standard base64-encoded raw 32-byte Ed25519
|
||||
// public key string expected by the gateway.
|
||||
ClientPublicKey string
|
||||
|
||||
// Status reports whether the projected session is active or revoked.
|
||||
Status Status
|
||||
|
||||
// RevokedAt optionally reports when the revoke took effect.
|
||||
RevokedAt *time.Time
|
||||
|
||||
// RevokeReasonCode optionally stores the machine-readable revoke reason.
|
||||
RevokeReasonCode common.RevokeReasonCode
|
||||
|
||||
// RevokeActorType optionally stores the machine-readable revoke actor type.
|
||||
RevokeActorType common.RevokeActorType
|
||||
|
||||
// RevokeActorID optionally stores a stable revoke actor identifier.
|
||||
RevokeActorID string
|
||||
}
|
||||
|
||||
// Validate reports whether Snapshot satisfies the Stage-2 structural
|
||||
// invariants.
|
||||
func (s Snapshot) Validate() error {
|
||||
if err := s.DeviceSessionID.Validate(); err != nil {
|
||||
return fmt.Errorf("gateway projection device session id: %w", err)
|
||||
}
|
||||
if err := s.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("gateway projection user id: %w", err)
|
||||
}
|
||||
if err := validateClientPublicKey(s.ClientPublicKey); err != nil {
|
||||
return fmt.Errorf("gateway projection client public key: %w", err)
|
||||
}
|
||||
if !s.Status.IsKnown() {
|
||||
return fmt.Errorf("gateway projection status %q is unsupported", s.Status)
|
||||
}
|
||||
|
||||
if s.Status == StatusActive {
|
||||
if s.RevokedAt != nil {
|
||||
return errors.New("active gateway projection must not contain revoked time")
|
||||
}
|
||||
if !s.RevokeReasonCode.IsZero() {
|
||||
return errors.New("active gateway projection must not contain revoke reason code")
|
||||
}
|
||||
if !s.RevokeActorType.IsZero() {
|
||||
return errors.New("active gateway projection must not contain revoke actor type")
|
||||
}
|
||||
if s.RevokeActorID != "" {
|
||||
return errors.New("active gateway projection must not contain revoke actor id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.RevokedAt != nil && s.RevokedAt.IsZero() {
|
||||
return errors.New("gateway projection revoked time must not be zero")
|
||||
}
|
||||
if !s.RevokeReasonCode.IsZero() {
|
||||
if err := s.RevokeReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("gateway projection revoke reason code: %w", err)
|
||||
}
|
||||
}
|
||||
if !s.RevokeActorType.IsZero() {
|
||||
if err := s.RevokeActorType.Validate(); err != nil {
|
||||
return fmt.Errorf("gateway projection revoke actor type: %w", err)
|
||||
}
|
||||
}
|
||||
if s.RevokeActorType.IsZero() && s.RevokeActorID != "" {
|
||||
return errors.New("gateway projection revoke actor id requires revoke actor type")
|
||||
}
|
||||
if strings.TrimSpace(s.RevokeActorID) != s.RevokeActorID {
|
||||
return errors.New("gateway projection revoke actor id must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClientPublicKey(value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return errors.New("client public key must not be empty")
|
||||
case strings.TrimSpace(value) != value:
|
||||
return errors.New("client public key must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client public key must be valid base64: %w", err)
|
||||
}
|
||||
if len(decoded) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("client public key must contain exactly %d bytes", ed25519.PublicKeySize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package gatewayprojection
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"github.com/stretchr/testify/require"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
)
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Status
|
||||
want bool
|
||||
}{
|
||||
{name: "active", value: StatusActive, want: true},
|
||||
{name: "revoked", value: StatusRevoked, want: true},
|
||||
{name: "unknown", value: Status("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Snapshot)
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "active valid"},
|
||||
{
|
||||
name: "revoked valid",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Status = StatusRevoked
|
||||
revokedAt := time.Unix(1_775_121_900, 0).UTC()
|
||||
snapshot.RevokedAt = &revokedAt
|
||||
snapshot.RevokeReasonCode = common.RevokeReasonCode("admin_revoke")
|
||||
snapshot.RevokeActorType = common.RevokeActorType("admin")
|
||||
snapshot.RevokeActorID = "admin-123"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active rejects revoke metadata",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.RevokeReasonCode = common.RevokeReasonCode("admin_revoke")
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid key encoding",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.ClientPublicKey = "not-base64"
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "actor id requires actor type",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Status = StatusRevoked
|
||||
snapshot.RevokeActorID = "admin-123"
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshot := validSnapshot()
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(&snapshot)
|
||||
}
|
||||
|
||||
err := snapshot.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotStaysSeparateFromSessionDomainShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshotType := reflect.TypeOf(Snapshot{})
|
||||
sessionType := reflect.TypeOf(devicesession.Session{})
|
||||
|
||||
clientPublicKeyField, ok := snapshotType.FieldByName("ClientPublicKey")
|
||||
if !ok {
|
||||
require.FailNow(t, "Snapshot is missing ClientPublicKey field")
|
||||
}
|
||||
if clientPublicKeyField.Type.Kind() != reflect.String {
|
||||
require.Failf(t, "test failed", "Snapshot.ClientPublicKey kind = %s, want string", clientPublicKeyField.Type.Kind())
|
||||
}
|
||||
|
||||
sessionClientPublicKeyField, ok := sessionType.FieldByName("ClientPublicKey")
|
||||
if !ok {
|
||||
require.FailNow(t, "devicesession.Session is missing ClientPublicKey field")
|
||||
}
|
||||
if clientPublicKeyField.Type == sessionClientPublicKeyField.Type {
|
||||
require.FailNow(t, "Snapshot.ClientPublicKey must stay separate from devicesession.Session.ClientPublicKey type")
|
||||
}
|
||||
|
||||
if _, ok := snapshotType.FieldByName("RevokedAtMS"); ok {
|
||||
require.FailNow(t, "Snapshot must not expose Redis-specific RevokedAtMS field")
|
||||
}
|
||||
}
|
||||
|
||||
func validSnapshot() Snapshot {
|
||||
raw := make(ed25519.PublicKey, ed25519.PublicKeySize)
|
||||
for index := range raw {
|
||||
raw[index] = byte(index + 17)
|
||||
}
|
||||
|
||||
return Snapshot{
|
||||
DeviceSessionID: common.DeviceSessionID("device-session-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
ClientPublicKey: base64.StdEncoding.EncodeToString(raw),
|
||||
Status: StatusActive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package sessionlimit defines the domain decision shape used for active
|
||||
// device-session limit evaluation.
|
||||
package sessionlimit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Kind identifies the coarse outcome of evaluating the active-session limit.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindDisabled reports that no configured limit is currently active.
|
||||
KindDisabled Kind = "disabled"
|
||||
|
||||
// KindAllowed reports that creating the next session is allowed.
|
||||
KindAllowed Kind = "allowed"
|
||||
|
||||
// KindExceeded reports that creating the next session would exceed the
|
||||
// configured limit.
|
||||
KindExceeded Kind = "exceeded"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Kind is one of the session-limit outcomes supported
|
||||
// by the current domain model.
|
||||
func (k Kind) IsKnown() bool {
|
||||
switch k {
|
||||
case KindDisabled, KindAllowed, KindExceeded:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Decision stores the result of evaluating one possible next session creation.
|
||||
type Decision struct {
|
||||
// Kind reports the coarse decision outcome.
|
||||
Kind Kind
|
||||
|
||||
// ConfiguredLimit stores the active configured limit when one exists.
|
||||
ConfiguredLimit *int
|
||||
|
||||
// ActiveSessionCount stores the current active-session count before create.
|
||||
ActiveSessionCount int
|
||||
|
||||
// NextSessionCount stores the count that would exist after creating the next
|
||||
// session.
|
||||
NextSessionCount int
|
||||
}
|
||||
|
||||
// Validate reports whether Decision satisfies the Stage-2 structural
|
||||
// invariants.
|
||||
func (d Decision) Validate() error {
|
||||
if !d.Kind.IsKnown() {
|
||||
return fmt.Errorf("session-limit decision kind %q is unsupported", d.Kind)
|
||||
}
|
||||
if d.ActiveSessionCount < 0 {
|
||||
return errors.New("session-limit active session count must not be negative")
|
||||
}
|
||||
if d.NextSessionCount < 0 {
|
||||
return errors.New("session-limit next session count must not be negative")
|
||||
}
|
||||
if d.NextSessionCount != d.ActiveSessionCount+1 {
|
||||
return errors.New("session-limit next session count must equal active session count plus one")
|
||||
}
|
||||
|
||||
switch d.Kind {
|
||||
case KindDisabled:
|
||||
if d.ConfiguredLimit != nil {
|
||||
return errors.New("disabled session-limit decision must not contain configured limit")
|
||||
}
|
||||
case KindAllowed, KindExceeded:
|
||||
if d.ConfiguredLimit == nil {
|
||||
return errors.New("limited session-limit decision must contain configured limit")
|
||||
}
|
||||
if *d.ConfiguredLimit <= 0 {
|
||||
return errors.New("session-limit configured limit must be positive")
|
||||
}
|
||||
if d.Kind == KindAllowed && d.NextSessionCount > *d.ConfiguredLimit {
|
||||
return errors.New("allowed session-limit decision must not exceed configured limit")
|
||||
}
|
||||
if d.Kind == KindExceeded && d.NextSessionCount <= *d.ConfiguredLimit {
|
||||
return errors.New("exceeded session-limit decision must be above configured limit")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package sessionlimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKindIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Kind
|
||||
want bool
|
||||
}{
|
||||
{name: "disabled", value: KindDisabled, want: true},
|
||||
{name: "allowed", value: KindAllowed, want: true},
|
||||
{name: "exceeded", value: KindExceeded, want: true},
|
||||
{name: "unknown", value: Kind("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limitTwo := 2
|
||||
limitThree := 3
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Decision
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "disabled valid",
|
||||
value: Decision{
|
||||
Kind: KindDisabled,
|
||||
ActiveSessionCount: 0,
|
||||
NextSessionCount: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowed valid",
|
||||
value: Decision{
|
||||
Kind: KindAllowed,
|
||||
ConfiguredLimit: &limitThree,
|
||||
ActiveSessionCount: 1,
|
||||
NextSessionCount: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exceeded valid",
|
||||
value: Decision{
|
||||
Kind: KindExceeded,
|
||||
ConfiguredLimit: &limitTwo,
|
||||
ActiveSessionCount: 2,
|
||||
NextSessionCount: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled rejects limit",
|
||||
value: Decision{
|
||||
Kind: KindDisabled,
|
||||
ConfiguredLimit: &limitTwo,
|
||||
ActiveSessionCount: 0,
|
||||
NextSessionCount: 1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "allowed requires limit",
|
||||
value: Decision{
|
||||
Kind: KindAllowed,
|
||||
ActiveSessionCount: 0,
|
||||
NextSessionCount: 1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "allowed rejects overflow",
|
||||
value: Decision{
|
||||
Kind: KindAllowed,
|
||||
ConfiguredLimit: &limitTwo,
|
||||
ActiveSessionCount: 2,
|
||||
NextSessionCount: 3,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "next count must be active plus one",
|
||||
value: Decision{
|
||||
Kind: KindDisabled,
|
||||
ActiveSessionCount: 2,
|
||||
NextSessionCount: 2,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package userresolution defines the domain result returned by the user
|
||||
// resolution boundary before session creation.
|
||||
package userresolution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
// Kind identifies the coarse user-resolution result for one normalized e-mail.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindExisting reports that the e-mail belongs to an existing user.
|
||||
KindExisting Kind = "existing"
|
||||
|
||||
// KindCreatable reports that the e-mail is free and user creation is
|
||||
// allowed.
|
||||
KindCreatable Kind = "creatable"
|
||||
|
||||
// KindBlocked reports that the e-mail or subject is blocked from login or
|
||||
// registration.
|
||||
KindBlocked Kind = "blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether Kind is one of the user-resolution kinds supported
|
||||
// by the current domain model.
|
||||
func (k Kind) IsKnown() bool {
|
||||
switch k {
|
||||
case KindExisting, KindCreatable, KindBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BlockReasonCode stores one machine-readable user-block reason.
|
||||
type BlockReasonCode string
|
||||
|
||||
// String returns BlockReasonCode as its stored code value.
|
||||
func (code BlockReasonCode) String() string {
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// IsZero reports whether BlockReasonCode is empty.
|
||||
func (code BlockReasonCode) IsZero() bool {
|
||||
return strings.TrimSpace(string(code)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether BlockReasonCode is non-empty and normalized for
|
||||
// domain use.
|
||||
func (code BlockReasonCode) Validate() error {
|
||||
switch {
|
||||
case code.IsZero():
|
||||
return errors.New("block reason code must not be empty")
|
||||
case strings.TrimSpace(string(code)) != string(code):
|
||||
return errors.New("block reason code must not contain surrounding whitespace")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Result stores the coarse user-resolution outcome consumed by later auth
|
||||
// workflow stages.
|
||||
type Result struct {
|
||||
// Kind reports the coarse resolution outcome.
|
||||
Kind Kind
|
||||
|
||||
// UserID is set only when Kind is KindExisting.
|
||||
UserID common.UserID
|
||||
|
||||
// BlockReasonCode is set only when Kind is KindBlocked.
|
||||
BlockReasonCode BlockReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether Result satisfies the Stage-2 structural invariants.
|
||||
func (r Result) Validate() error {
|
||||
if !r.Kind.IsKnown() {
|
||||
return fmt.Errorf("user resolution kind %q is unsupported", r.Kind)
|
||||
}
|
||||
|
||||
switch r.Kind {
|
||||
case KindExisting:
|
||||
if err := r.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("user resolution user id: %w", err)
|
||||
}
|
||||
if !r.BlockReasonCode.IsZero() {
|
||||
return errors.New("existing user resolution must not contain block reason code")
|
||||
}
|
||||
case KindCreatable:
|
||||
if !r.UserID.IsZero() {
|
||||
return errors.New("creatable user resolution must not contain user id")
|
||||
}
|
||||
if !r.BlockReasonCode.IsZero() {
|
||||
return errors.New("creatable user resolution must not contain block reason code")
|
||||
}
|
||||
case KindBlocked:
|
||||
if !r.UserID.IsZero() {
|
||||
return errors.New("blocked user resolution must not contain user id")
|
||||
}
|
||||
if err := r.BlockReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("user resolution block reason code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package userresolution
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
)
|
||||
|
||||
func TestKindIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Kind
|
||||
want bool
|
||||
}{
|
||||
{name: "existing", value: KindExisting, want: true},
|
||||
{name: "creatable", value: KindCreatable, want: true},
|
||||
{name: "blocked", value: KindBlocked, want: true},
|
||||
{name: "unknown", value: Kind("unknown"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.value.IsKnown(); got != tt.want {
|
||||
require.Failf(t, "test failed", "IsKnown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Result
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "existing valid",
|
||||
value: Result{
|
||||
Kind: KindExisting,
|
||||
UserID: common.UserID("user-123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creatable valid",
|
||||
value: Result{
|
||||
Kind: KindCreatable,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocked valid",
|
||||
value: Result{
|
||||
Kind: KindBlocked,
|
||||
BlockReasonCode: BlockReasonCode("policy_blocked"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "existing requires user id",
|
||||
value: Result{
|
||||
Kind: KindExisting,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "creatable rejects user id",
|
||||
value: Result{
|
||||
Kind: KindCreatable,
|
||||
UserID: common.UserID("user-123"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "blocked requires reason",
|
||||
value: Result{
|
||||
Kind: KindBlocked,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "blocked rejects user id",
|
||||
value: Result{
|
||||
Kind: KindBlocked,
|
||||
UserID: common.UserID("user-123"),
|
||||
BlockReasonCode: BlockReasonCode("policy_blocked"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
require.FailNow(t, "Validate() returned nil error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user