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.
|
||||
Reference in New Issue
Block a user