feat: authsession service
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
// Package shared provides cross-use-case application helpers for auth/session
|
||||
// services, including typed service errors, input normalization, DTO mapping,
|
||||
// and application-level retry helpers.
|
||||
package shared
|
||||
@@ -0,0 +1,407 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeInvalidRequest reports malformed or semantically invalid service
|
||||
// input.
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeChallengeNotFound reports that the requested challenge does not
|
||||
// exist.
|
||||
ErrorCodeChallengeNotFound = "challenge_not_found"
|
||||
|
||||
// ErrorCodeChallengeExpired reports that the requested challenge may no
|
||||
// longer be confirmed.
|
||||
ErrorCodeChallengeExpired = "challenge_expired"
|
||||
|
||||
// ErrorCodeInvalidCode reports that the submitted confirmation code does not
|
||||
// match the stored challenge.
|
||||
ErrorCodeInvalidCode = "invalid_code"
|
||||
|
||||
// ErrorCodeInvalidClientPublicKey reports that the submitted client public
|
||||
// key does not satisfy the Ed25519/base64 contract.
|
||||
ErrorCodeInvalidClientPublicKey = "invalid_client_public_key"
|
||||
|
||||
// ErrorCodeBlockedByPolicy reports that the auth flow is denied by current
|
||||
// user or registration policy.
|
||||
ErrorCodeBlockedByPolicy = "blocked_by_policy"
|
||||
|
||||
// ErrorCodeSessionLimitExceeded reports that creating another active session
|
||||
// would violate the configured limit.
|
||||
ErrorCodeSessionLimitExceeded = "session_limit_exceeded"
|
||||
|
||||
// ErrorCodeSessionNotFound reports that the requested device session does
|
||||
// not exist.
|
||||
ErrorCodeSessionNotFound = "session_not_found"
|
||||
|
||||
// ErrorCodeSubjectNotFound reports that the requested trusted internal
|
||||
// subject does not exist.
|
||||
ErrorCodeSubjectNotFound = "subject_not_found"
|
||||
|
||||
// ErrorCodeServiceUnavailable reports that a required dependency or
|
||||
// propagation step is temporarily unavailable.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeInternalError reports that local state is inconsistent or an
|
||||
// invariant was broken unexpectedly.
|
||||
ErrorCodeInternalError = "internal_error"
|
||||
)
|
||||
|
||||
const genericInvalidRequestMessage = "request is invalid"
|
||||
|
||||
var publicErrorStatusCodes = map[string]int{
|
||||
ErrorCodeInvalidRequest: http.StatusBadRequest,
|
||||
ErrorCodeInvalidClientPublicKey: http.StatusBadRequest,
|
||||
ErrorCodeInvalidCode: http.StatusBadRequest,
|
||||
ErrorCodeChallengeNotFound: http.StatusNotFound,
|
||||
ErrorCodeChallengeExpired: http.StatusGone,
|
||||
ErrorCodeBlockedByPolicy: http.StatusForbidden,
|
||||
ErrorCodeSessionLimitExceeded: http.StatusConflict,
|
||||
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
}
|
||||
|
||||
var publicStableMessages = map[string]string{
|
||||
ErrorCodeChallengeNotFound: "challenge not found",
|
||||
ErrorCodeChallengeExpired: "challenge expired",
|
||||
ErrorCodeInvalidCode: "confirmation code is invalid",
|
||||
ErrorCodeInvalidClientPublicKey: "client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key",
|
||||
ErrorCodeBlockedByPolicy: "authentication is blocked by policy",
|
||||
ErrorCodeSessionLimitExceeded: "active session limit would be exceeded",
|
||||
ErrorCodeServiceUnavailable: "service is unavailable",
|
||||
}
|
||||
|
||||
var internalErrorStatusCodes = map[string]int{
|
||||
ErrorCodeInvalidRequest: http.StatusBadRequest,
|
||||
ErrorCodeSessionNotFound: http.StatusNotFound,
|
||||
ErrorCodeSubjectNotFound: http.StatusNotFound,
|
||||
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
ErrorCodeInternalError: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
var internalStableMessages = map[string]string{
|
||||
ErrorCodeSessionNotFound: "session not found",
|
||||
ErrorCodeSubjectNotFound: "subject not found",
|
||||
ErrorCodeServiceUnavailable: "service is unavailable",
|
||||
ErrorCodeInternalError: "internal server error",
|
||||
}
|
||||
|
||||
// PublicErrorProjection describes one transport-ready public auth error after
|
||||
// internal service errors have been normalized to the frozen client-safe
|
||||
// surface.
|
||||
type PublicErrorProjection struct {
|
||||
// StatusCode is the HTTP status that should be returned to the public auth
|
||||
// caller.
|
||||
StatusCode int
|
||||
|
||||
// Code is the stable client-safe error code written into the public JSON
|
||||
// envelope.
|
||||
Code string
|
||||
|
||||
// Message is the client-safe error description exposed to the public auth
|
||||
// caller.
|
||||
Message string
|
||||
}
|
||||
|
||||
// InternalErrorProjection describes one transport-ready internal API error
|
||||
// after service-layer failures have been normalized to the frozen trusted
|
||||
// caller surface.
|
||||
type InternalErrorProjection struct {
|
||||
// StatusCode is the HTTP status that should be returned to the trusted
|
||||
// caller.
|
||||
StatusCode int
|
||||
|
||||
// Code is the stable error code written into the internal JSON envelope.
|
||||
Code string
|
||||
|
||||
// Message is the trusted-caller-safe error description exposed by the
|
||||
// internal HTTP API.
|
||||
Message string
|
||||
}
|
||||
|
||||
// ServiceError projects one stable application-layer failure with a service
|
||||
// error code and a caller-safe message.
|
||||
type ServiceError struct {
|
||||
// Code is the stable error code expected by later transport mapping.
|
||||
Code string
|
||||
|
||||
// Message is the caller-safe error description.
|
||||
Message string
|
||||
|
||||
// Err optionally stores the wrapped underlying cause.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the caller-safe error description.
|
||||
func (e *ServiceError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(e.Message) != "":
|
||||
return e.Message
|
||||
case strings.TrimSpace(e.Code) != "":
|
||||
return e.Code
|
||||
case e.Err != nil:
|
||||
return e.Err.Error()
|
||||
default:
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped cause, if any.
|
||||
func (e *ServiceError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewServiceError returns a new typed application-layer error.
|
||||
func NewServiceError(code string, message string, err error) *ServiceError {
|
||||
return &ServiceError{
|
||||
Code: strings.TrimSpace(code),
|
||||
Message: strings.TrimSpace(message),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// IsPublicErrorCode reports whether code belongs to the frozen public auth
|
||||
// error surface.
|
||||
func IsPublicErrorCode(code string) bool {
|
||||
_, ok := publicErrorStatusCodes[strings.TrimSpace(code)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsInternalOnlyErrorCode reports whether code is intentionally excluded from
|
||||
// the public auth transport surface.
|
||||
func IsInternalOnlyErrorCode(code string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeSessionNotFound, ErrorCodeSubjectNotFound, ErrorCodeInternalError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsSendEmailCodePublicErrorCode reports whether code may be exposed by the
|
||||
// public send-email-code route after public projection.
|
||||
func IsSendEmailCodePublicErrorCode(code string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest, ErrorCodeServiceUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmEmailCodePublicErrorCode reports whether code may be exposed by the
|
||||
// public confirm-email-code route after public projection.
|
||||
func IsConfirmEmailCodePublicErrorCode(code string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest,
|
||||
ErrorCodeChallengeNotFound,
|
||||
ErrorCodeChallengeExpired,
|
||||
ErrorCodeInvalidCode,
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
ErrorCodeBlockedByPolicy,
|
||||
ErrorCodeSessionLimitExceeded,
|
||||
ErrorCodeServiceUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PublicHTTPStatusCode reports the frozen public HTTP status for code. Unknown
|
||||
// or internal-only codes are normalized to 503 service_unavailable.
|
||||
func PublicHTTPStatusCode(code string) int {
|
||||
if statusCode, ok := publicErrorStatusCodes[strings.TrimSpace(code)]; ok {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
// ProjectPublicError normalizes err to the frozen public-auth error surface.
|
||||
// Unknown and internal-only service failures are intentionally projected as
|
||||
// 503 service_unavailable so internal invariants do not leak to public callers.
|
||||
func ProjectPublicError(err error) PublicErrorProjection {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
code := CodeOf(err)
|
||||
if !IsPublicErrorCode(code) {
|
||||
return PublicErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: publicMessageForCode(ErrorCodeServiceUnavailable, ""),
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ok && serviceErr != nil {
|
||||
message = serviceErr.Message
|
||||
}
|
||||
|
||||
return PublicErrorProjection{
|
||||
StatusCode: PublicHTTPStatusCode(code),
|
||||
Code: code,
|
||||
Message: publicMessageForCode(code, message),
|
||||
}
|
||||
}
|
||||
|
||||
// InternalHTTPStatusCode reports the frozen internal HTTP status for code.
|
||||
// Unknown codes are normalized to 500 internal_error.
|
||||
func InternalHTTPStatusCode(code string) int {
|
||||
if statusCode, ok := internalErrorStatusCodes[strings.TrimSpace(code)]; ok {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// ProjectInternalError normalizes err to the frozen internal trusted HTTP
|
||||
// error surface. Unknown failures are intentionally projected as
|
||||
// 500 internal_error so transport callers do not depend on unclassified local
|
||||
// failures.
|
||||
func ProjectInternalError(err error) InternalErrorProjection {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
code := CodeOf(err)
|
||||
if _, known := internalErrorStatusCodes[code]; !known {
|
||||
return InternalErrorProjection{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: internalMessageForCode(ErrorCodeInternalError, ""),
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ok && serviceErr != nil {
|
||||
message = serviceErr.Message
|
||||
}
|
||||
|
||||
return InternalErrorProjection{
|
||||
StatusCode: InternalHTTPStatusCode(code),
|
||||
Code: code,
|
||||
Message: internalMessageForCode(code, message),
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidRequest reports one malformed or semantically invalid caller input.
|
||||
func InvalidRequest(message string) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInvalidRequest, message, nil)
|
||||
}
|
||||
|
||||
// ChallengeNotFound reports that the requested challenge does not exist.
|
||||
func ChallengeNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeChallengeNotFound, "challenge not found", nil)
|
||||
}
|
||||
|
||||
// ChallengeExpired reports that the requested challenge is expired.
|
||||
func ChallengeExpired() *ServiceError {
|
||||
return NewServiceError(ErrorCodeChallengeExpired, "challenge expired", nil)
|
||||
}
|
||||
|
||||
// InvalidCode reports that the submitted confirmation code is invalid.
|
||||
func InvalidCode() *ServiceError {
|
||||
return NewServiceError(ErrorCodeInvalidCode, "confirmation code is invalid", nil)
|
||||
}
|
||||
|
||||
// InvalidClientPublicKey reports that the submitted client public key does not
|
||||
// satisfy the frozen contract.
|
||||
func InvalidClientPublicKey() *ServiceError {
|
||||
return NewServiceError(
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// BlockedByPolicy reports that the current auth flow is denied by policy.
|
||||
func BlockedByPolicy() *ServiceError {
|
||||
return NewServiceError(ErrorCodeBlockedByPolicy, "authentication is blocked by policy", nil)
|
||||
}
|
||||
|
||||
// SessionLimitExceeded reports that creating another active session would
|
||||
// exceed the current configured limit.
|
||||
func SessionLimitExceeded() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSessionLimitExceeded, "active session limit would be exceeded", nil)
|
||||
}
|
||||
|
||||
// SessionNotFound reports that the requested session does not exist.
|
||||
func SessionNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSessionNotFound, "session not found", nil)
|
||||
}
|
||||
|
||||
// SubjectNotFound reports that the requested internal subject does not exist.
|
||||
func SubjectNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSubjectNotFound, "subject not found", nil)
|
||||
}
|
||||
|
||||
// ServiceUnavailable reports that a required dependency or propagation step is
|
||||
// temporarily unavailable.
|
||||
func ServiceUnavailable(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeServiceUnavailable, "service is unavailable", err)
|
||||
}
|
||||
|
||||
// InternalError reports an invariant-breaking local failure.
|
||||
func InternalError(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInternalError, "internal error", err)
|
||||
}
|
||||
|
||||
// CodeOf returns the stable service error code of err when err wraps a
|
||||
// ServiceError. Otherwise it returns ErrorCodeInternalError.
|
||||
func CodeOf(err error) string {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
if !ok || serviceErr == nil || strings.TrimSpace(serviceErr.Code) == "" {
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
|
||||
return serviceErr.Code
|
||||
}
|
||||
|
||||
func publicMessageForCode(code string, message string) string {
|
||||
trimmedMessage := strings.TrimSpace(message)
|
||||
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest:
|
||||
if trimmedMessage != "" {
|
||||
return trimmedMessage
|
||||
}
|
||||
return genericInvalidRequestMessage
|
||||
case ErrorCodeServiceUnavailable:
|
||||
return publicStableMessages[ErrorCodeServiceUnavailable]
|
||||
default:
|
||||
if stableMessage, ok := publicStableMessages[strings.TrimSpace(code)]; ok {
|
||||
return stableMessage
|
||||
}
|
||||
return publicStableMessages[ErrorCodeServiceUnavailable]
|
||||
}
|
||||
}
|
||||
|
||||
func internalMessageForCode(code string, message string) string {
|
||||
trimmedMessage := strings.TrimSpace(message)
|
||||
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest:
|
||||
if trimmedMessage != "" {
|
||||
return trimmedMessage
|
||||
}
|
||||
return genericInvalidRequestMessage
|
||||
case ErrorCodeSessionNotFound,
|
||||
ErrorCodeSubjectNotFound,
|
||||
ErrorCodeServiceUnavailable,
|
||||
ErrorCodeInternalError:
|
||||
if stableMessage, ok := internalStableMessages[strings.TrimSpace(code)]; ok {
|
||||
return stableMessage
|
||||
}
|
||||
return internalStableMessages[ErrorCodeInternalError]
|
||||
default:
|
||||
return internalStableMessages[ErrorCodeInternalError]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
)
|
||||
|
||||
// NormalizeString trims surrounding Unicode whitespace from value.
|
||||
func NormalizeString(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// ParseEmail trims value and validates it against the frozen public e-mail
|
||||
// contract.
|
||||
func ParseEmail(value string) (common.Email, error) {
|
||||
email := common.Email(NormalizeString(value))
|
||||
if err := email.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// ParseChallengeID trims value and validates it as one challenge identifier.
|
||||
func ParseChallengeID(value string) (common.ChallengeID, error) {
|
||||
challengeID := common.ChallengeID(NormalizeString(value))
|
||||
if err := challengeID.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return challengeID, nil
|
||||
}
|
||||
|
||||
// ParseDeviceSessionID trims value and validates it as one device-session
|
||||
// identifier.
|
||||
func ParseDeviceSessionID(value string) (common.DeviceSessionID, error) {
|
||||
deviceSessionID := common.DeviceSessionID(NormalizeString(value))
|
||||
if err := deviceSessionID.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return deviceSessionID, nil
|
||||
}
|
||||
|
||||
// ParseUserID trims value and validates it as one user identifier.
|
||||
func ParseUserID(value string) (common.UserID, error) {
|
||||
userID := common.UserID(NormalizeString(value))
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ParseRequiredCode trims value and validates it as a required non-empty
|
||||
// confirmation code.
|
||||
func ParseRequiredCode(value string) (string, error) {
|
||||
code := NormalizeString(value)
|
||||
if code == "" {
|
||||
return "", InvalidRequest("code must not be empty")
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ParseClientPublicKey trims value and validates it as the standard
|
||||
// base64-encoded raw 32-byte Ed25519 public key expected by the public auth
|
||||
// contract.
|
||||
func ParseClientPublicKey(value string) (common.ClientPublicKey, error) {
|
||||
normalized := NormalizeString(value)
|
||||
if normalized == "" {
|
||||
return common.ClientPublicKey{}, InvalidClientPublicKey()
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.Strict().DecodeString(normalized)
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize {
|
||||
return common.ClientPublicKey{}, InvalidClientPublicKey()
|
||||
}
|
||||
|
||||
key, err := common.NewClientPublicKey(ed25519.PublicKey(decoded))
|
||||
if err != nil {
|
||||
return common.ClientPublicKey{}, InvalidClientPublicKey()
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ParseRevokeReasonCode trims value and validates it as one machine-readable
|
||||
// revoke reason code.
|
||||
func ParseRevokeReasonCode(value string) (common.RevokeReasonCode, error) {
|
||||
code := common.RevokeReasonCode(NormalizeString(value))
|
||||
if err := code.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ParseRevokeActorType trims value and validates it as one machine-readable
|
||||
// revoke actor type.
|
||||
func ParseRevokeActorType(value string) (common.RevokeActorType, error) {
|
||||
actorType := common.RevokeActorType(NormalizeString(value))
|
||||
if err := actorType.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return actorType, nil
|
||||
}
|
||||
|
||||
// ParseOptionalActorID trims value and validates it as one optional stable
|
||||
// actor identifier.
|
||||
func ParseOptionalActorID(value string) (string, error) {
|
||||
actorID := NormalizeString(value)
|
||||
if actorID != value {
|
||||
return "", InvalidRequest("actor_id must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return actorID, nil
|
||||
}
|
||||
|
||||
// BuildRevocation validates one revoke request payload and returns the domain
|
||||
// revocation metadata applied to a session mutation.
|
||||
func BuildRevocation(reasonCode string, actorType string, actorID string, at time.Time) (devicesession.Revocation, error) {
|
||||
if at.IsZero() {
|
||||
return devicesession.Revocation{}, InternalError(fmt.Errorf("revocation time must not be zero"))
|
||||
}
|
||||
|
||||
parsedReasonCode, err := ParseRevokeReasonCode(reasonCode)
|
||||
if err != nil {
|
||||
return devicesession.Revocation{}, err
|
||||
}
|
||||
parsedActorType, err := ParseRevokeActorType(actorType)
|
||||
if err != nil {
|
||||
return devicesession.Revocation{}, err
|
||||
}
|
||||
parsedActorID, err := ParseOptionalActorID(actorID)
|
||||
if err != nil {
|
||||
return devicesession.Revocation{}, err
|
||||
}
|
||||
|
||||
revocation := devicesession.Revocation{
|
||||
At: at.UTC(),
|
||||
ReasonCode: parsedReasonCode,
|
||||
ActorType: parsedActorType,
|
||||
ActorID: parsedActorID,
|
||||
}
|
||||
if err := revocation.Validate(); err != nil {
|
||||
return devicesession.Revocation{}, InternalError(fmt.Errorf("build revocation: %w", err))
|
||||
}
|
||||
|
||||
return revocation, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authlogging "galaxy/authsession/internal/logging"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LogServiceOutcome writes one structured service-level outcome log with a
|
||||
// stable severity derived from err and with trace fields attached when ctx
|
||||
// carries an active span.
|
||||
func LogServiceOutcome(logger *zap.Logger, ctx context.Context, message string, err error, fields ...zap.Field) {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
fields = append(fields, authlogging.TraceFieldsFromContext(ctx)...)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
logger.Info(message, fields...)
|
||||
case isExpectedServiceErrorCode(CodeOf(err)):
|
||||
logger.Warn(message, append(fields, zap.Error(err))...)
|
||||
default:
|
||||
logger.Error(message, append(fields, zap.Error(err))...)
|
||||
}
|
||||
}
|
||||
|
||||
func isExpectedServiceErrorCode(code string) bool {
|
||||
switch code {
|
||||
case ErrorCodeInvalidRequest,
|
||||
ErrorCodeChallengeNotFound,
|
||||
ErrorCodeChallengeExpired,
|
||||
ErrorCodeInvalidCode,
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
ErrorCodeBlockedByPolicy,
|
||||
ErrorCodeSessionLimitExceeded,
|
||||
ErrorCodeSessionNotFound,
|
||||
ErrorCodeSubjectNotFound:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package shared
|
||||
|
||||
const (
|
||||
// MaxCompareAndSwapRetries bounds application-level retry loops around
|
||||
// compare-and-swap challenge updates.
|
||||
MaxCompareAndSwapRetries = 3
|
||||
|
||||
// MaxProjectionPublishAttempts bounds synchronous request-path retries
|
||||
// around gateway session projection publication.
|
||||
MaxProjectionPublishAttempts = 3
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/gatewayprojection"
|
||||
"galaxy/authsession/internal/ports"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
)
|
||||
|
||||
// PublishProjectionSnapshot publishes snapshot through publisher with a small
|
||||
// bounded retry loop suitable for request-path consistency repair.
|
||||
func PublishProjectionSnapshot(ctx context.Context, publisher ports.GatewaySessionProjectionPublisher, snapshot gatewayprojection.Snapshot) error {
|
||||
return PublishProjectionSnapshotWithTelemetry(ctx, publisher, snapshot, nil, "")
|
||||
}
|
||||
|
||||
// PublishProjectionSnapshotWithTelemetry publishes snapshot through publisher
|
||||
// with the bounded request-path retry policy and optional publish-failure
|
||||
// telemetry.
|
||||
func PublishProjectionSnapshotWithTelemetry(
|
||||
ctx context.Context,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
snapshot gatewayprojection.Snapshot,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
operation string,
|
||||
) error {
|
||||
if publisher == nil {
|
||||
return InternalError(errors.New("projection publisher must not be nil"))
|
||||
}
|
||||
if ctx == nil {
|
||||
return ServiceUnavailable(errors.New("projection publish context must not be nil"))
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return InternalError(fmt.Errorf("publish projection snapshot: %w", err))
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < MaxProjectionPublishAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
if err := publisher.PublishSession(ctx, snapshot); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
telemetryRuntime.RecordProjectionPublishFailure(ctx, operation)
|
||||
return ServiceUnavailable(
|
||||
fmt.Errorf(
|
||||
"publish projection snapshot %q after %d attempts: %w",
|
||||
snapshot.DeviceSessionID,
|
||||
MaxProjectionPublishAttempts,
|
||||
lastErr,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// PublishSessionProjection converts record into the gateway-facing snapshot and
|
||||
// publishes it with the bounded request-path retry policy.
|
||||
func PublishSessionProjection(ctx context.Context, publisher ports.GatewaySessionProjectionPublisher, record devicesession.Session) error {
|
||||
return PublishSessionProjectionWithTelemetry(ctx, publisher, record, nil, "")
|
||||
}
|
||||
|
||||
// PublishSessionProjectionWithTelemetry converts record into the
|
||||
// gateway-facing snapshot and publishes it with the bounded request-path retry
|
||||
// policy and optional publish-failure telemetry.
|
||||
func PublishSessionProjectionWithTelemetry(
|
||||
ctx context.Context,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
record devicesession.Session,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
operation string,
|
||||
) error {
|
||||
snapshot, err := ToGatewayProjectionSnapshot(record)
|
||||
if err != nil {
|
||||
return InternalError(err)
|
||||
}
|
||||
|
||||
return PublishProjectionSnapshotWithTelemetry(ctx, publisher, snapshot, telemetryRuntime, operation)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/gatewayprojection"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublishSessionProjectionRetriesUntilSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
errors []error
|
||||
wantAttempts int
|
||||
}{
|
||||
{
|
||||
name: "success on second attempt",
|
||||
errors: []error{errors.New("transient publish failure"), nil},
|
||||
wantAttempts: 2,
|
||||
},
|
||||
{
|
||||
name: "success on third attempt",
|
||||
errors: []error{errors.New("transient publish failure"), errors.New("transient publish failure"), nil},
|
||||
wantAttempts: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
publisher := &testkit.RecordingProjectionPublisher{Errors: tt.errors}
|
||||
|
||||
err := PublishSessionProjection(context.Background(), publisher, revokedSessionFixture())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, publisher.PublishedSnapshots(), tt.wantAttempts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSessionProjectionReturnsServiceUnavailableAfterExhaustedRetries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
publisher := &testkit.RecordingProjectionPublisher{Err: errors.New("publish failed")}
|
||||
|
||||
err := PublishSessionProjection(context.Background(), publisher, revokedSessionFixture())
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrorCodeServiceUnavailable, CodeOf(err))
|
||||
require.Len(t, publisher.PublishedSnapshots(), MaxProjectionPublishAttempts)
|
||||
}
|
||||
|
||||
func TestPublishProjectionSnapshotStopsRetriesWhenContextIsCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
publisher := &cancelingProjectionPublisher{
|
||||
cancel: cancel,
|
||||
err: errors.New("publish failed"),
|
||||
}
|
||||
|
||||
err := PublishProjectionSnapshot(ctx, publisher, mustProjectionSnapshot(t))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrorCodeServiceUnavailable, CodeOf(err))
|
||||
assert.Equal(t, 1, publisher.attempts)
|
||||
}
|
||||
|
||||
func TestPublishSessionProjectionReturnsInternalErrorForInvalidLocalRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
publisher := &testkit.RecordingProjectionPublisher{}
|
||||
|
||||
err := PublishSessionProjection(context.Background(), publisher, invalidSessionFixture())
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrorCodeInternalError, CodeOf(err))
|
||||
assert.Empty(t, publisher.PublishedSnapshots())
|
||||
}
|
||||
|
||||
type cancelingProjectionPublisher struct {
|
||||
attempts int
|
||||
cancel context.CancelFunc
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *cancelingProjectionPublisher) PublishSession(_ context.Context, snapshot gatewayprojection.Snapshot) error {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.attempts++
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
p.cancel = nil
|
||||
}
|
||||
|
||||
return p.err
|
||||
}
|
||||
|
||||
func mustProjectionSnapshot(t *testing.T) gatewayprojection.Snapshot {
|
||||
t.Helper()
|
||||
|
||||
snapshot, err := ToGatewayProjectionSnapshot(revokedSessionFixture())
|
||||
require.NoError(t, err)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func invalidSessionFixture() devicesession.Session {
|
||||
return devicesession.Session{}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/gatewayprojection"
|
||||
)
|
||||
|
||||
// Session mirrors the frozen internal read-model DTO used by later trusted
|
||||
// transport handlers.
|
||||
type Session struct {
|
||||
// DeviceSessionID is the stable identifier of one device session.
|
||||
DeviceSessionID string
|
||||
|
||||
// UserID is the stable identifier of the session owner.
|
||||
UserID string
|
||||
|
||||
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key of
|
||||
// the device session.
|
||||
ClientPublicKey string
|
||||
|
||||
// Status reports whether the session is active or revoked.
|
||||
Status string
|
||||
|
||||
// CreatedAt is the RFC3339 UTC timestamp at which the session was created.
|
||||
CreatedAt string
|
||||
|
||||
// RevokedAt is the RFC3339 UTC timestamp at which the session was revoked,
|
||||
// when the session is revoked.
|
||||
RevokedAt *string
|
||||
|
||||
// RevokeReasonCode is the machine-readable revoke reason code when the
|
||||
// session is revoked.
|
||||
RevokeReasonCode *string
|
||||
|
||||
// RevokeActorType is the machine-readable revoke actor type when the
|
||||
// session is revoked.
|
||||
RevokeActorType *string
|
||||
|
||||
// RevokeActorID is the optional stable revoke actor identifier when the
|
||||
// session is revoked.
|
||||
RevokeActorID *string
|
||||
}
|
||||
|
||||
// ToSession converts source-of-truth session into the frozen internal read DTO
|
||||
// shape.
|
||||
func ToSession(record devicesession.Session) (Session, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return Session{}, fmt.Errorf("map session: %w", err)
|
||||
}
|
||||
|
||||
result := Session{
|
||||
DeviceSessionID: record.ID.String(),
|
||||
UserID: record.UserID.String(),
|
||||
ClientPublicKey: record.ClientPublicKey.String(),
|
||||
Status: string(record.Status),
|
||||
CreatedAt: formatTime(record.CreatedAt),
|
||||
}
|
||||
|
||||
if record.Revocation != nil {
|
||||
revokedAt := formatTime(record.Revocation.At)
|
||||
reasonCode := record.Revocation.ReasonCode.String()
|
||||
actorType := record.Revocation.ActorType.String()
|
||||
result.RevokedAt = &revokedAt
|
||||
result.RevokeReasonCode = &reasonCode
|
||||
result.RevokeActorType = &actorType
|
||||
if record.Revocation.ActorID != "" {
|
||||
actorID := record.Revocation.ActorID
|
||||
result.RevokeActorID = &actorID
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ToSessions converts every source-of-truth session into the frozen internal
|
||||
// read DTO shape.
|
||||
func ToSessions(records []devicesession.Session) ([]Session, error) {
|
||||
result := make([]Session, 0, len(records))
|
||||
for index, record := range records {
|
||||
mapped, err := ToSession(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map session %d: %w", index, err)
|
||||
}
|
||||
result = append(result, mapped)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ToGatewayProjectionSnapshot converts source-of-truth session into the
|
||||
// separate gateway-facing projection model.
|
||||
func ToGatewayProjectionSnapshot(record devicesession.Session) (gatewayprojection.Snapshot, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return gatewayprojection.Snapshot{}, fmt.Errorf("map gateway projection snapshot: %w", err)
|
||||
}
|
||||
|
||||
snapshot := gatewayprojection.Snapshot{
|
||||
DeviceSessionID: record.ID,
|
||||
UserID: record.UserID,
|
||||
ClientPublicKey: record.ClientPublicKey.String(),
|
||||
Status: gatewayprojection.Status(record.Status),
|
||||
}
|
||||
if record.Revocation != nil {
|
||||
snapshot.RevokedAt = cloneTimePointer(commonTimePointer(record.Revocation.At.UTC()))
|
||||
snapshot.RevokeReasonCode = record.Revocation.ReasonCode
|
||||
snapshot.RevokeActorType = record.Revocation.ActorType
|
||||
snapshot.RevokeActorID = record.Revocation.ActorID
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return gatewayprojection.Snapshot{}, fmt.Errorf("map gateway projection snapshot: %w", err)
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func commonTimePointer(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func cloneTimePointer(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"galaxy/authsession/internal/domain/sessionlimit"
|
||||
"galaxy/authsession/internal/ports"
|
||||
)
|
||||
|
||||
// EvaluateSessionLimit evaluates the Stage-4 active-session creation decision
|
||||
// from the loaded configuration and current active-session count.
|
||||
func EvaluateSessionLimit(config ports.SessionLimitConfig, activeSessionCount int) (sessionlimit.Decision, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return sessionlimit.Decision{}, InternalError(fmt.Errorf("evaluate session limit: %w", err))
|
||||
}
|
||||
if activeSessionCount < 0 {
|
||||
return sessionlimit.Decision{}, InternalError(fmt.Errorf("evaluate session limit: active session count %d is negative", activeSessionCount))
|
||||
}
|
||||
|
||||
decision := sessionlimit.Decision{
|
||||
ActiveSessionCount: activeSessionCount,
|
||||
NextSessionCount: activeSessionCount + 1,
|
||||
}
|
||||
|
||||
if config.ActiveSessionLimit == nil {
|
||||
decision.Kind = sessionlimit.KindDisabled
|
||||
} else {
|
||||
decision.ConfiguredLimit = config.ActiveSessionLimit
|
||||
if decision.NextSessionCount <= *config.ActiveSessionLimit {
|
||||
decision.Kind = sessionlimit.KindAllowed
|
||||
} else {
|
||||
decision.Kind = sessionlimit.KindExceeded
|
||||
}
|
||||
}
|
||||
if err := decision.Validate(); err != nil {
|
||||
return sessionlimit.Decision{}, InternalError(fmt.Errorf("evaluate session limit: %w", err))
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/gatewayprojection"
|
||||
"galaxy/authsession/internal/domain/sessionlimit"
|
||||
"galaxy/authsession/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "pilot@example.com", NormalizeString(" pilot@example.com \n"))
|
||||
}
|
||||
|
||||
func TestParseClientPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := ParseClientPublicKey(" AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", key.String())
|
||||
|
||||
_, err = ParseClientPublicKey("invalid")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrorCodeInvalidClientPublicKey, CodeOf(err))
|
||||
}
|
||||
|
||||
func TestToSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := revokedSessionFixture()
|
||||
|
||||
dto, err := ToSession(record)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ID.String(), dto.DeviceSessionID)
|
||||
require.NotNil(t, dto.RevokedAt)
|
||||
assert.Equal(t, record.Revocation.At.UTC().Format(time.RFC3339), *dto.RevokedAt)
|
||||
}
|
||||
|
||||
func TestToGatewayProjectionSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := revokedSessionFixture()
|
||||
|
||||
snapshot, err := ToGatewayProjectionSnapshot(record)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gatewayprojection.StatusRevoked, snapshot.Status)
|
||||
}
|
||||
|
||||
func TestEvaluateSessionLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limit := 2
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config ports.SessionLimitConfig
|
||||
active int
|
||||
want sessionlimit.Kind
|
||||
}{
|
||||
{name: "disabled", config: ports.SessionLimitConfig{}, active: 3, want: sessionlimit.KindDisabled},
|
||||
{name: "allowed", config: ports.SessionLimitConfig{ActiveSessionLimit: &limit}, active: 1, want: sessionlimit.KindAllowed},
|
||||
{name: "exceeded", config: ports.SessionLimitConfig{ActiveSessionLimit: &limit}, active: 2, want: sessionlimit.KindExceeded},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
decision, err := EvaluateSessionLimit(tt.config, tt.active)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, decision.Kind)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceErrorCodePreservation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseErr := errors.New("base")
|
||||
err := ServiceUnavailable(baseErr)
|
||||
|
||||
assert.Equal(t, ErrorCodeServiceUnavailable, CodeOf(err))
|
||||
assert.ErrorIs(t, err, baseErr)
|
||||
}
|
||||
|
||||
func TestErrorCodeClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
publicCodes := []string{
|
||||
ErrorCodeInvalidRequest,
|
||||
ErrorCodeChallengeNotFound,
|
||||
ErrorCodeChallengeExpired,
|
||||
ErrorCodeInvalidCode,
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
ErrorCodeBlockedByPolicy,
|
||||
ErrorCodeSessionLimitExceeded,
|
||||
ErrorCodeServiceUnavailable,
|
||||
}
|
||||
for _, code := range publicCodes {
|
||||
assert.Truef(t, IsPublicErrorCode(code), "IsPublicErrorCode(%q)", code)
|
||||
assert.Falsef(t, IsInternalOnlyErrorCode(code), "IsInternalOnlyErrorCode(%q)", code)
|
||||
}
|
||||
|
||||
internalOnlyCodes := []string{
|
||||
ErrorCodeSessionNotFound,
|
||||
ErrorCodeSubjectNotFound,
|
||||
ErrorCodeInternalError,
|
||||
}
|
||||
for _, code := range internalOnlyCodes {
|
||||
assert.Falsef(t, IsPublicErrorCode(code), "IsPublicErrorCode(%q)", code)
|
||||
assert.Truef(t, IsInternalOnlyErrorCode(code), "IsInternalOnlyErrorCode(%q)", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicUseCaseErrorCodeSets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, IsSendEmailCodePublicErrorCode(ErrorCodeInvalidRequest))
|
||||
assert.True(t, IsSendEmailCodePublicErrorCode(ErrorCodeServiceUnavailable))
|
||||
assert.False(t, IsSendEmailCodePublicErrorCode(ErrorCodeBlockedByPolicy))
|
||||
assert.False(t, IsSendEmailCodePublicErrorCode(ErrorCodeChallengeNotFound))
|
||||
|
||||
confirmCodes := []string{
|
||||
ErrorCodeInvalidRequest,
|
||||
ErrorCodeChallengeNotFound,
|
||||
ErrorCodeChallengeExpired,
|
||||
ErrorCodeInvalidCode,
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
ErrorCodeBlockedByPolicy,
|
||||
ErrorCodeSessionLimitExceeded,
|
||||
ErrorCodeServiceUnavailable,
|
||||
}
|
||||
for _, code := range confirmCodes {
|
||||
assert.Truef(t, IsConfirmEmailCodePublicErrorCode(code), "IsConfirmEmailCodePublicErrorCode(%q)", code)
|
||||
}
|
||||
assert.False(t, IsConfirmEmailCodePublicErrorCode(ErrorCodeInternalError))
|
||||
assert.False(t, IsConfirmEmailCodePublicErrorCode(ErrorCodeSessionNotFound))
|
||||
}
|
||||
|
||||
func TestPublicHTTPStatusCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want int
|
||||
}{
|
||||
{name: "invalid request", code: ErrorCodeInvalidRequest, want: http.StatusBadRequest},
|
||||
{name: "invalid client public key", code: ErrorCodeInvalidClientPublicKey, want: http.StatusBadRequest},
|
||||
{name: "invalid code", code: ErrorCodeInvalidCode, want: http.StatusBadRequest},
|
||||
{name: "challenge not found", code: ErrorCodeChallengeNotFound, want: http.StatusNotFound},
|
||||
{name: "challenge expired", code: ErrorCodeChallengeExpired, want: http.StatusGone},
|
||||
{name: "blocked by policy", code: ErrorCodeBlockedByPolicy, want: http.StatusForbidden},
|
||||
{name: "session limit exceeded", code: ErrorCodeSessionLimitExceeded, want: http.StatusConflict},
|
||||
{name: "service unavailable", code: ErrorCodeServiceUnavailable, want: http.StatusServiceUnavailable},
|
||||
{name: "internal error normalized", code: ErrorCodeInternalError, want: http.StatusServiceUnavailable},
|
||||
{name: "unknown normalized", code: "unknown", want: http.StatusServiceUnavailable},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.want, PublicHTTPStatusCode(tt.code))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalHTTPStatusCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want int
|
||||
}{
|
||||
{name: "invalid request", code: ErrorCodeInvalidRequest, want: http.StatusBadRequest},
|
||||
{name: "session not found", code: ErrorCodeSessionNotFound, want: http.StatusNotFound},
|
||||
{name: "subject not found", code: ErrorCodeSubjectNotFound, want: http.StatusNotFound},
|
||||
{name: "service unavailable", code: ErrorCodeServiceUnavailable, want: http.StatusServiceUnavailable},
|
||||
{name: "internal error", code: ErrorCodeInternalError, want: http.StatusInternalServerError},
|
||||
{name: "unknown normalized", code: "unknown", want: http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.want, InternalHTTPStatusCode(tt.code))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPublicError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want PublicErrorProjection
|
||||
}{
|
||||
{
|
||||
name: "invalid request keeps detailed message",
|
||||
err: InvalidRequest("email must be a single valid email address"),
|
||||
want: PublicErrorProjection{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Code: ErrorCodeInvalidRequest,
|
||||
Message: "email must be a single valid email address",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid code keeps canonical message",
|
||||
err: NewServiceError(ErrorCodeInvalidCode, "custom detail should not leak", nil),
|
||||
want: PublicErrorProjection{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Code: ErrorCodeInvalidCode,
|
||||
Message: "confirmation code is invalid",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service unavailable keeps generic message",
|
||||
err: NewServiceError(ErrorCodeServiceUnavailable, "dependency timeout", errors.New("dependency timeout")),
|
||||
want: PublicErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal error is hidden",
|
||||
err: InternalError(errors.New("broken invariant")),
|
||||
want: PublicErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal only session not found is hidden",
|
||||
err: SessionNotFound(),
|
||||
want: PublicErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non service error is hidden",
|
||||
err: errors.New("boom"),
|
||||
want: PublicErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.want, ProjectPublicError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectInternalError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want InternalErrorProjection
|
||||
}{
|
||||
{
|
||||
name: "invalid request keeps detailed message",
|
||||
err: InvalidRequest("reason_code must not be empty"),
|
||||
want: InternalErrorProjection{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Code: ErrorCodeInvalidRequest,
|
||||
Message: "reason_code must not be empty",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session not found keeps canonical message",
|
||||
err: NewServiceError(ErrorCodeSessionNotFound, "custom detail should not leak", nil),
|
||||
want: InternalErrorProjection{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Code: ErrorCodeSessionNotFound,
|
||||
Message: "session not found",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subject not found keeps canonical message",
|
||||
err: SubjectNotFound(),
|
||||
want: InternalErrorProjection{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Code: ErrorCodeSubjectNotFound,
|
||||
Message: "subject not found",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service unavailable keeps generic message",
|
||||
err: NewServiceError(ErrorCodeServiceUnavailable, "redis timeout", errors.New("redis timeout")),
|
||||
want: InternalErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal error uses internal server error message",
|
||||
err: InternalError(errors.New("broken invariant")),
|
||||
want: InternalErrorProjection{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: "internal server error",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unexpected error is hidden",
|
||||
err: errors.New("boom"),
|
||||
want: InternalErrorProjection{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: "internal server error",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.want, ProjectInternalError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func revokedSessionFixture() devicesession.Session {
|
||||
key, err := common.NewClientPublicKey(make([]byte, 32))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
revokedAt := time.Unix(20, 0).UTC()
|
||||
return devicesession.Session{
|
||||
ID: common.DeviceSessionID("device-session-1"),
|
||||
UserID: common.UserID("user-1"),
|
||||
ClientPublicKey: key,
|
||||
Status: devicesession.StatusRevoked,
|
||||
CreatedAt: time.Unix(10, 0).UTC(),
|
||||
Revocation: &devicesession.Revocation{
|
||||
At: revokedAt,
|
||||
ReasonCode: devicesession.RevokeReasonLogoutAll,
|
||||
ActorType: common.RevokeActorType("system"),
|
||||
ActorID: "actor-1",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user