feat: authsession service
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
// Package userservice provides runtime user-directory adapters for the
|
||||
// auth/session service.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
resolveByEmailPath = "/api/v1/internal/user-resolutions/by-email"
|
||||
existsByUserIDPath = "/api/v1/internal/users/%s/exists"
|
||||
ensureByEmailPath = "/api/v1/internal/users/ensure-by-email"
|
||||
blockByUserIDPath = "/api/v1/internal/users/%s/block"
|
||||
blockByEmailPath = "/api/v1/internal/user-blocks/by-email"
|
||||
)
|
||||
|
||||
// Config configures one HTTP-based UserDirectory client.
|
||||
type Config struct {
|
||||
// BaseURL is the absolute base URL of the future user-service internal
|
||||
// HTTP API.
|
||||
BaseURL string
|
||||
|
||||
// RequestTimeout bounds each outbound user-service request.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// RESTClient implements ports.UserDirectory over a frozen internal REST
|
||||
// contract.
|
||||
type RESTClient struct {
|
||||
baseURL string
|
||||
requestTimeout time.Duration
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewRESTClient constructs a REST-backed UserDirectory adapter from cfg.
|
||||
func NewRESTClient(cfg Config) (*RESTClient, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
return newRESTClient(cfg, &http.Client{Transport: transport})
|
||||
}
|
||||
|
||||
func newRESTClient(cfg Config, httpClient *http.Client) (*RESTClient, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return nil, errors.New("new user service REST client: base URL must not be empty")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return nil, errors.New("new user service REST client: request timeout must be positive")
|
||||
case httpClient == nil:
|
||||
return nil, errors.New("new user service REST client: http client must not be nil")
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user service REST client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
|
||||
return nil, errors.New("new user service REST client: base URL must be absolute")
|
||||
}
|
||||
|
||||
return &RESTClient{
|
||||
baseURL: parsedBaseURL.String(),
|
||||
requestTimeout: cfg.RequestTimeout,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (c *RESTClient) Close() error {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
type idleCloser interface {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveByEmail returns the current coarse user-resolution state for email
|
||||
// without creating any new user record.
|
||||
func (c *RESTClient) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) {
|
||||
if err := validateContext(ctx, "resolve by email"); err != nil {
|
||||
return userresolution.Result{}, err
|
||||
}
|
||||
if err := email.Validate(); err != nil {
|
||||
return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Kind userresolution.Kind `json:"kind"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.doJSON(ctx, "resolve by email", http.MethodPost, resolveByEmailPath, map[string]string{
|
||||
"email": email.String(),
|
||||
}, &response, true); err != nil {
|
||||
return userresolution.Result{}, err
|
||||
}
|
||||
|
||||
result := userresolution.Result{
|
||||
Kind: response.Kind,
|
||||
UserID: common.UserID(response.UserID),
|
||||
BlockReasonCode: response.BlockReasonCode,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored user
|
||||
// record.
|
||||
func (c *RESTClient) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
if err := validateContext(ctx, "exists by user id"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := userID.Validate(); err != nil {
|
||||
return false, fmt.Errorf("exists by user id: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
if err := c.doJSON(ctx, "exists by user id", http.MethodGet, fmt.Sprintf(existsByUserIDPath, url.PathEscape(userID.String())), nil, &response, true); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return response.Exists, nil
|
||||
}
|
||||
|
||||
// EnsureUserByEmail returns an existing user for email, creates a new user
|
||||
// when registration is allowed, or reports a blocked outcome.
|
||||
func (c *RESTClient) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
|
||||
if err := validateContext(ctx, "ensure user by email"); err != nil {
|
||||
return ports.EnsureUserResult{}, err
|
||||
}
|
||||
if err := email.Validate(); err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Outcome ports.EnsureUserOutcome `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, map[string]string{
|
||||
"email": email.String(),
|
||||
}, &response, false); err != nil {
|
||||
return ports.EnsureUserResult{}, err
|
||||
}
|
||||
|
||||
result := ports.EnsureUserResult{
|
||||
Outcome: response.Outcome,
|
||||
UserID: common.UserID(response.UserID),
|
||||
BlockReasonCode: response.BlockReasonCode,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BlockByUserID applies a block state to the user identified by input.UserID.
|
||||
// Unknown user ids wrap ports.ErrNotFound.
|
||||
func (c *RESTClient) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) {
|
||||
if err := validateContext(ctx, "block by user id"); err != nil {
|
||||
return ports.BlockUserResult{}, err
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err)
|
||||
}
|
||||
|
||||
payload, statusCode, err := c.doRequest(ctx, "block by user id", http.MethodPost, fmt.Sprintf(blockByUserIDPath, url.PathEscape(input.UserID.String())), map[string]string{
|
||||
"reason_code": input.ReasonCode.String(),
|
||||
}, false)
|
||||
if err != nil {
|
||||
return ports.BlockUserResult{}, err
|
||||
}
|
||||
if statusCode == http.StatusNotFound {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id %q: %w", input.UserID, ports.ErrNotFound)
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Outcome ports.BlockUserOutcome `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
if err := decodeJSONPayload(payload, &response); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err)
|
||||
}
|
||||
|
||||
result := ports.BlockUserResult{
|
||||
Outcome: response.Outcome,
|
||||
UserID: common.UserID(response.UserID),
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BlockByEmail applies a block state to input.Email even when no user record
|
||||
// currently exists for that e-mail address.
|
||||
func (c *RESTClient) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) {
|
||||
if err := validateContext(ctx, "block by email"); err != nil {
|
||||
return ports.BlockUserResult{}, err
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Outcome ports.BlockUserOutcome `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.doJSON(ctx, "block by email", http.MethodPost, blockByEmailPath, map[string]string{
|
||||
"email": input.Email.String(),
|
||||
"reason_code": input.ReasonCode.String(),
|
||||
}, &response, false); err != nil {
|
||||
return ports.BlockUserResult{}, err
|
||||
}
|
||||
|
||||
result := ports.BlockUserResult{
|
||||
Outcome: response.Outcome,
|
||||
UserID: common.UserID(response.UserID),
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *RESTClient) doJSON(ctx context.Context, operation string, method string, requestPath string, requestBody any, responseTarget any, retryRead bool) error {
|
||||
payload, statusCode, err := c.doRequest(ctx, operation, method, requestPath, requestBody, retryRead)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: unexpected HTTP status %d", operation, statusCode)
|
||||
}
|
||||
if err := decodeJSONPayload(payload, responseTarget); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RESTClient) doRequest(ctx context.Context, operation string, method string, requestPath string, requestBody any, retryRead bool) ([]byte, int, error) {
|
||||
bodyBytes, err := marshalOptionalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
attempts := 1
|
||||
if retryRead {
|
||||
attempts = 2
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
|
||||
request, err := http.NewRequestWithContext(attemptCtx, method, c.baseURL+requestPath, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, 0, fmt.Errorf("%s: build request: %w", operation, err)
|
||||
}
|
||||
if method == http.MethodPost {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
cancel()
|
||||
lastErr = fmt.Errorf("%s: %w", operation, err)
|
||||
if retryRead && attempt == 0 && ctx.Err() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
payload, readErr := io.ReadAll(response.Body)
|
||||
closeErr := response.Body.Close()
|
||||
cancel()
|
||||
if readErr != nil {
|
||||
lastErr = fmt.Errorf("%s: read response body: %w", operation, readErr)
|
||||
if retryRead && attempt == 0 && ctx.Err() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
lastErr = fmt.Errorf("%s: close response body: %w", operation, closeErr)
|
||||
if retryRead && attempt == 0 && ctx.Err() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
if retryRead && attempt == 0 && isRetriableUserServiceStatus(response.StatusCode) {
|
||||
lastErr = fmt.Errorf("%s: unexpected HTTP status %d", operation, response.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
return payload, response.StatusCode, nil
|
||||
}
|
||||
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
func marshalOptionalRequestBody(value any) ([]byte, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func decodeJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("decode response body: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("decode response body: unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
return fmt.Errorf("decode response body: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRetriableUserServiceStatus(statusCode int) bool {
|
||||
switch statusCode {
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var _ ports.UserDirectory = (*RESTClient)(nil)
|
||||
@@ -0,0 +1,622 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRESTClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: Config{
|
||||
BaseURL: "http://127.0.0.1:8080",
|
||||
RequestTimeout: time.Second,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty base url",
|
||||
cfg: Config{
|
||||
RequestTimeout: time.Second,
|
||||
},
|
||||
wantErr: "base URL must not be empty",
|
||||
},
|
||||
{
|
||||
name: "relative base url",
|
||||
cfg: Config{
|
||||
BaseURL: "/relative",
|
||||
RequestTimeout: time.Second,
|
||||
},
|
||||
wantErr: "base URL must be absolute",
|
||||
},
|
||||
{
|
||||
name: "non positive timeout",
|
||||
cfg: Config{
|
||||
BaseURL: "http://127.0.0.1:8080",
|
||||
},
|
||||
wantErr: "request timeout must be positive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := NewRESTClient(tt.cfg)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, client.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientEndpointSuccessCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*testing.T, *RESTClient)
|
||||
}{
|
||||
{
|
||||
name: "resolve by email",
|
||||
run: func(t *testing.T, client *RESTClient) {
|
||||
result, err := client.ResolveByEmail(context.Background(), common.Email("Pilot+Case@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userresolution.Result{
|
||||
Kind: userresolution.KindExisting,
|
||||
UserID: common.UserID("user-123"),
|
||||
}, result)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exists by user id",
|
||||
run: func(t *testing.T, client *RESTClient) {
|
||||
exists, err := client.ExistsByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ensure user by email",
|
||||
run: func(t *testing.T, client *RESTClient) {
|
||||
result, err := client.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.EnsureUserResult{
|
||||
Outcome: ports.EnsureUserOutcomeCreated,
|
||||
UserID: common.UserID("user-234"),
|
||||
}, result)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block by user id",
|
||||
run: func(t *testing.T, client *RESTClient) {
|
||||
result, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("user-123"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserResult{
|
||||
Outcome: ports.BlockUserOutcomeBlocked,
|
||||
UserID: common.UserID("user-123"),
|
||||
}, result)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block by email",
|
||||
run: func(t *testing.T, client *RESTClient) {
|
||||
result, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserResult{
|
||||
Outcome: ports.BlockUserOutcomeAlreadyBlocked,
|
||||
UserID: common.UserID("user-345"),
|
||||
}, result)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requestsMu sync.Mutex
|
||||
var requests []capturedRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestsMu.Lock()
|
||||
requests = append(requests, captureRequest(t, r))
|
||||
requestsMu.Unlock()
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == resolveByEmailPath:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"kind": "existing",
|
||||
"user_id": "user-123",
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/internal/users/user-123/exists":
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"exists": true})
|
||||
case r.Method == http.MethodPost && r.URL.Path == ensureByEmailPath:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"outcome": "created",
|
||||
"user_id": "user-234",
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/internal/users/user-123/block":
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"outcome": "blocked",
|
||||
"user_id": "user-123",
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == blockByEmailPath:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"outcome": "already_blocked",
|
||||
"user_id": "user-345",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
tt.run(t, client)
|
||||
|
||||
requestsMu.Lock()
|
||||
defer requestsMu.Unlock()
|
||||
|
||||
require.Len(t, requests, 1)
|
||||
switch tt.name {
|
||||
case "resolve by email":
|
||||
assert.Equal(t, capturedRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: resolveByEmailPath,
|
||||
ContentType: "application/json",
|
||||
Body: `{"email":"Pilot+Case@example.com"}`,
|
||||
}, requests[0])
|
||||
case "exists by user id":
|
||||
assert.Equal(t, capturedRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/internal/users/user-123/exists",
|
||||
}, requests[0])
|
||||
case "ensure user by email":
|
||||
assert.Equal(t, capturedRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: ensureByEmailPath,
|
||||
ContentType: "application/json",
|
||||
Body: `{"email":"created@example.com"}`,
|
||||
}, requests[0])
|
||||
case "block by user id":
|
||||
assert.Equal(t, capturedRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/internal/users/user-123/block",
|
||||
ContentType: "application/json",
|
||||
Body: `{"reason_code":"policy_blocked"}`,
|
||||
}, requests[0])
|
||||
case "block by email":
|
||||
assert.Equal(t, capturedRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: blockByEmailPath,
|
||||
ContentType: "application/json",
|
||||
Body: `{"email":"blocked@example.com","reason_code":"policy_blocked"}`,
|
||||
}, requests[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientPreservesNormalizedEmailExactly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var captured string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
request := captureRequest(t, r)
|
||||
captured = request.Body
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"kind": "creatable"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
_, err := client.ResolveByEmail(context.Background(), common.Email("Pilot+Alias@Example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"email":"Pilot+Alias@Example.com"}`, captured)
|
||||
}
|
||||
|
||||
func TestRESTClientBlockByUserIDNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
_, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("missing-user"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRESTClientReadMethodsRetryOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("resolve by email retries on 503", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"kind": "creatable"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
result, err := client.ResolveByEmail(context.Background(), common.Email("pilot@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userresolution.KindCreatable, result.Kind)
|
||||
assert.Equal(t, 2, calls)
|
||||
})
|
||||
|
||||
t.Run("exists by user id retries on transport failure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"exists": true})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
baseTransport := server.Client().Transport
|
||||
client, err := newRESTClient(Config{
|
||||
BaseURL: server.URL,
|
||||
RequestTimeout: 250 * time.Millisecond,
|
||||
}, &http.Client{
|
||||
Transport: &failOnceRoundTripper{
|
||||
next: baseTransport,
|
||||
err: errors.New("temporary transport failure"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
exists, err := client.ExistsByUserID(context.Background(), common.UserID("user-123"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRESTClientMutationMethodsDoNotRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*RESTClient) error
|
||||
}{
|
||||
{
|
||||
name: "ensure user by email",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block by user id",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("user-123"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block by email",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
err := tt.run(client)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
wantErrText string
|
||||
run func(*RESTClient) error
|
||||
}{
|
||||
{
|
||||
name: "resolve by email rejects unknown field",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"kind":"creatable","extra":true}`,
|
||||
wantErrText: "decode response body",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.ResolveByEmail(context.Background(), common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ensure user by email rejects malformed outcome",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"outcome":"mystery"}`,
|
||||
wantErrText: "unsupported",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ensure user by email rejects missing user id for created outcome",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"outcome":"created"}`,
|
||||
wantErrText: "user id",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exists by user id rejects trailing json",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"exists":true}{}`,
|
||||
wantErrText: "unexpected trailing JSON input",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.ExistsByUserID(context.Background(), common.UserID("user-123"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block by email rejects unexpected status",
|
||||
statusCode: http.StatusBadGateway,
|
||||
body: `{"error":"temporary"}`,
|
||||
wantErrText: "unexpected HTTP status 502",
|
||||
run: func(client *RESTClient) error {
|
||||
_, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, err := io.WriteString(w, tt.body)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
|
||||
err := tt.run(client)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErrText)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientRequestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"kind": "creatable"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 10*time.Millisecond)
|
||||
|
||||
_, err := client.ResolveByEmail(context.Background(), common.Email("pilot@example.com"))
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "context deadline exceeded")
|
||||
}
|
||||
|
||||
func TestRESTClientContextAndValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected upstream call")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{
|
||||
name: "nil context",
|
||||
run: func() error {
|
||||
_, err := client.ResolveByEmail(nil, common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cancelled context",
|
||||
run: func() error {
|
||||
_, err := client.ExistsByUserID(cancelledCtx, common.UserID("user-123"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
run: func() error {
|
||||
_, err := client.EnsureUserByEmail(context.Background(), common.Email(" bad@example.com "))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid user id",
|
||||
run: func() error {
|
||||
_, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID(" bad "),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid reason code",
|
||||
run: func() error {
|
||||
_, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode(" bad "),
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.run()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
ContentType string
|
||||
Body string
|
||||
}
|
||||
|
||||
func captureRequest(t *testing.T, request *http.Request) capturedRequest {
|
||||
t.Helper()
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return capturedRequest{
|
||||
Method: request.Method,
|
||||
Path: request.URL.Path,
|
||||
ContentType: request.Header.Get("Content-Type"),
|
||||
Body: strings.TrimSpace(string(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, writer http.ResponseWriter, statusCode int, value any) {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_, err = writer.Write(payload)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func newTestRESTClient(t *testing.T, baseURL string, timeout time.Duration) *RESTClient {
|
||||
t.Helper()
|
||||
|
||||
client, err := NewRESTClient(Config{
|
||||
BaseURL: baseURL,
|
||||
RequestTimeout: timeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, client.Close())
|
||||
})
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
type failOnceRoundTripper struct {
|
||||
mu sync.Mutex
|
||||
next http.RoundTripper
|
||||
err error
|
||||
done bool
|
||||
}
|
||||
|
||||
func (rt *failOnceRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
rt.mu.Lock()
|
||||
if !rt.done {
|
||||
rt.done = true
|
||||
err := rt.err
|
||||
rt.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
next := rt.next
|
||||
rt.mu.Unlock()
|
||||
|
||||
return next.RoundTrip(request)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// Package userservice provides runtime user-directory adapters for the
|
||||
// auth/session service.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
userID common.UserID
|
||||
blockReasonCode userresolution.BlockReasonCode
|
||||
}
|
||||
|
||||
// StubDirectory is a concurrency-safe in-process UserDirectory stub intended
|
||||
// for development, local integration, and explicit stub-based tests.
|
||||
//
|
||||
// The zero value is ready to use. Unknown e-mail addresses resolve as
|
||||
// creatable, unknown user identifiers do not exist, and EnsureUserByEmail
|
||||
// creates deterministic user ids such as "user-1", "user-2", and so on.
|
||||
type StubDirectory struct {
|
||||
mu sync.Mutex
|
||||
byEmail map[common.Email]entry
|
||||
emailByUserID map[common.UserID]common.Email
|
||||
createdUserIDs []common.UserID
|
||||
nextUserNumber int
|
||||
}
|
||||
|
||||
// ResolveByEmail returns the current coarse user-resolution state for email
|
||||
// without creating any new user record.
|
||||
func (d *StubDirectory) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) {
|
||||
if err := validateContext(ctx, "resolve by email"); err != nil {
|
||||
return userresolution.Result{}, err
|
||||
}
|
||||
if err := email.Validate(); err != nil {
|
||||
return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
result, err := d.resolveLocked(email)
|
||||
if err != nil {
|
||||
return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored user
|
||||
// record.
|
||||
func (d *StubDirectory) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
if err := validateContext(ctx, "exists by user id"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := userID.Validate(); err != nil {
|
||||
return false, fmt.Errorf("exists by user id: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
_, ok := d.emailByUserID[userID]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// EnsureUserByEmail returns an existing user for email, creates a new user
|
||||
// when registration is allowed, or reports a blocked outcome.
|
||||
func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
|
||||
if err := validateContext(ctx, "ensure user by email"); err != nil {
|
||||
return ports.EnsureUserResult{}, err
|
||||
}
|
||||
if err := email.Validate(); err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.ensureMapsLocked()
|
||||
|
||||
stored, ok := d.byEmail[email]
|
||||
if ok {
|
||||
if !stored.blockReasonCode.IsZero() {
|
||||
result := ports.EnsureUserResult{
|
||||
Outcome: ports.EnsureUserOutcomeBlocked,
|
||||
BlockReasonCode: stored.blockReasonCode,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result := ports.EnsureUserResult{
|
||||
Outcome: ports.EnsureUserOutcomeExisting,
|
||||
UserID: stored.userID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
userID, err := d.nextCreatedUserIDLocked()
|
||||
if err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
d.byEmail[email] = entry{userID: userID}
|
||||
d.emailByUserID[userID] = email
|
||||
|
||||
result := ports.EnsureUserResult{
|
||||
Outcome: ports.EnsureUserOutcomeCreated,
|
||||
UserID: userID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BlockByUserID applies a block state to the user identified by input.UserID.
|
||||
// Unknown user ids wrap ports.ErrNotFound.
|
||||
func (d *StubDirectory) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) {
|
||||
if err := validateContext(ctx, "block by user id"); err != nil {
|
||||
return ports.BlockUserResult{}, err
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
email, ok := d.emailByUserID[input.UserID]
|
||||
if !ok {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id %q: %w", input.UserID, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
stored := d.byEmail[email]
|
||||
if !stored.blockReasonCode.IsZero() {
|
||||
result := ports.BlockUserResult{
|
||||
Outcome: ports.BlockUserOutcomeAlreadyBlocked,
|
||||
UserID: input.UserID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
stored.blockReasonCode = input.ReasonCode
|
||||
d.byEmail[email] = stored
|
||||
|
||||
result := ports.BlockUserResult{
|
||||
Outcome: ports.BlockUserOutcomeBlocked,
|
||||
UserID: input.UserID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BlockByEmail applies a block state to input.Email even when no user record
|
||||
// currently exists for that e-mail address.
|
||||
func (d *StubDirectory) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) {
|
||||
if err := validateContext(ctx, "block by email"); err != nil {
|
||||
return ports.BlockUserResult{}, err
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.ensureMapsLocked()
|
||||
|
||||
stored := d.byEmail[input.Email]
|
||||
if !stored.blockReasonCode.IsZero() {
|
||||
result := ports.BlockUserResult{
|
||||
Outcome: ports.BlockUserOutcomeAlreadyBlocked,
|
||||
UserID: stored.userID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
stored.blockReasonCode = input.ReasonCode
|
||||
d.byEmail[input.Email] = stored
|
||||
if !stored.userID.IsZero() {
|
||||
d.emailByUserID[stored.userID] = input.Email
|
||||
}
|
||||
|
||||
result := ports.BlockUserResult{
|
||||
Outcome: ports.BlockUserOutcomeBlocked,
|
||||
UserID: stored.userID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SeedExisting preloads one existing unblocked user record into the runtime
|
||||
// stub.
|
||||
func (d *StubDirectory) SeedExisting(email common.Email, userID common.UserID) error {
|
||||
if err := email.Validate(); err != nil {
|
||||
return fmt.Errorf("seed existing email: %w", err)
|
||||
}
|
||||
if err := userID.Validate(); err != nil {
|
||||
return fmt.Errorf("seed existing user id: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.ensureMapsLocked()
|
||||
d.byEmail[email] = entry{userID: userID}
|
||||
d.emailByUserID[userID] = email
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedBlockedEmail preloads one blocked e-mail address that does not
|
||||
// necessarily belong to an existing user record.
|
||||
func (d *StubDirectory) SeedBlockedEmail(email common.Email, reasonCode userresolution.BlockReasonCode) error {
|
||||
if err := email.Validate(); err != nil {
|
||||
return fmt.Errorf("seed blocked email: %w", err)
|
||||
}
|
||||
if err := reasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("seed blocked email reason code: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.ensureMapsLocked()
|
||||
d.byEmail[email] = entry{blockReasonCode: reasonCode}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedBlockedUser preloads one blocked existing user record into the runtime
|
||||
// stub.
|
||||
func (d *StubDirectory) SeedBlockedUser(email common.Email, userID common.UserID, reasonCode userresolution.BlockReasonCode) error {
|
||||
if err := d.SeedExisting(email, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
stored := d.byEmail[email]
|
||||
stored.blockReasonCode = reasonCode
|
||||
d.byEmail[email] = stored
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueCreatedUserIDs appends deterministic user identifiers that
|
||||
// EnsureUserByEmail consumes before falling back to generated ids.
|
||||
func (d *StubDirectory) QueueCreatedUserIDs(userIDs ...common.UserID) error {
|
||||
for index, userID := range userIDs {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return fmt.Errorf("queue created user id %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.createdUserIDs = append(d.createdUserIDs, userIDs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *StubDirectory) ensureMapsLocked() {
|
||||
if d.byEmail == nil {
|
||||
d.byEmail = make(map[common.Email]entry)
|
||||
}
|
||||
if d.emailByUserID == nil {
|
||||
d.emailByUserID = make(map[common.UserID]common.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *StubDirectory) resolveLocked(email common.Email) (userresolution.Result, error) {
|
||||
stored, ok := d.byEmail[email]
|
||||
if !ok {
|
||||
result := userresolution.Result{Kind: userresolution.KindCreatable}
|
||||
if err := result.Validate(); err != nil {
|
||||
return userresolution.Result{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
if !stored.blockReasonCode.IsZero() {
|
||||
result := userresolution.Result{
|
||||
Kind: userresolution.KindBlocked,
|
||||
BlockReasonCode: stored.blockReasonCode,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return userresolution.Result{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result := userresolution.Result{
|
||||
Kind: userresolution.KindExisting,
|
||||
UserID: stored.userID,
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return userresolution.Result{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *StubDirectory) nextCreatedUserIDLocked() (common.UserID, error) {
|
||||
if len(d.createdUserIDs) > 0 {
|
||||
userID := d.createdUserIDs[0]
|
||||
d.createdUserIDs = d.createdUserIDs[1:]
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
d.nextUserNumber++
|
||||
userID := common.UserID(fmt.Sprintf("user-%d", d.nextUserNumber))
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func validateContext(ctx context.Context, operation string) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ ports.UserDirectory = (*StubDirectory)(nil)
|
||||
@@ -0,0 +1,329 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStubDirectoryResolveByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
|
||||
require.NoError(t, directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_block")))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
email common.Email
|
||||
wantKind userresolution.Kind
|
||||
wantUserID common.UserID
|
||||
wantReasonCode userresolution.BlockReasonCode
|
||||
}{
|
||||
{
|
||||
name: "zero value unknown email is creatable",
|
||||
email: common.Email("new@example.com"),
|
||||
wantKind: userresolution.KindCreatable,
|
||||
},
|
||||
{
|
||||
name: "existing email",
|
||||
email: common.Email("existing@example.com"),
|
||||
wantKind: userresolution.KindExisting,
|
||||
wantUserID: common.UserID("user-existing"),
|
||||
},
|
||||
{
|
||||
name: "blocked email",
|
||||
email: common.Email("blocked@example.com"),
|
||||
wantKind: userresolution.KindBlocked,
|
||||
wantReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := directory.ResolveByEmail(context.Background(), tt.email)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantKind, result.Kind)
|
||||
assert.Equal(t, tt.wantUserID, result.UserID)
|
||||
assert.Equal(t, tt.wantReasonCode, result.BlockReasonCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("existing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
|
||||
|
||||
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("existing@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.EnsureUserOutcomeExisting, result.Outcome)
|
||||
assert.Equal(t, common.UserID("user-existing"), result.UserID)
|
||||
})
|
||||
|
||||
t.Run("blocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_block")))
|
||||
|
||||
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("blocked@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.EnsureUserOutcomeBlocked, result.Outcome)
|
||||
assert.Equal(t, userresolution.BlockReasonCode("policy_block"), result.BlockReasonCode)
|
||||
})
|
||||
|
||||
t.Run("created queued then existing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.QueueCreatedUserIDs(common.UserID("user-created")))
|
||||
|
||||
first, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.EnsureUserOutcomeCreated, first.Outcome)
|
||||
assert.Equal(t, common.UserID("user-created"), first.UserID)
|
||||
|
||||
second, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.EnsureUserOutcomeExisting, second.Outcome)
|
||||
assert.Equal(t, common.UserID("user-created"), second.UserID)
|
||||
})
|
||||
|
||||
t.Run("created fallback id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
|
||||
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("fallback@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.EnsureUserOutcomeCreated, result.Outcome)
|
||||
assert.Equal(t, common.UserID("user-1"), result.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStubDirectoryExistsByUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
|
||||
|
||||
exists, err := directory.ExistsByUserID(context.Background(), common.UserID("user-existing"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
exists, err = directory.ExistsByUserID(context.Background(), common.UserID("missing"))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestStubDirectoryBlockByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("unknown email becomes blocked without user id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
|
||||
result, err := directory.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserOutcomeBlocked, result.Outcome)
|
||||
assert.True(t, result.UserID.IsZero())
|
||||
|
||||
resolution, err := directory.ResolveByEmail(context.Background(), common.Email("blocked@example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userresolution.KindBlocked, resolution.Kind)
|
||||
})
|
||||
|
||||
t.Run("existing user preserves linked user id and repeat is already blocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
|
||||
first, err := directory.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserOutcomeBlocked, first.Outcome)
|
||||
assert.Equal(t, common.UserID("user-1"), first.UserID)
|
||||
|
||||
second, err := directory.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserOutcomeAlreadyBlocked, second.Outcome)
|
||||
assert.Equal(t, common.UserID("user-1"), second.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStubDirectoryBlockByUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("unknown user wraps ErrNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
|
||||
_, err := directory.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("missing"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ports.ErrNotFound)
|
||||
})
|
||||
|
||||
t.Run("existing user blocks then returns already blocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
|
||||
first, err := directory.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("user-1"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserOutcomeBlocked, first.Outcome)
|
||||
assert.Equal(t, common.UserID("user-1"), first.UserID)
|
||||
|
||||
second, err := directory.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("user-1"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserOutcomeAlreadyBlocked, second.Outcome)
|
||||
assert.Equal(t, common.UserID("user-1"), second.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStubDirectoryContextAndValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "resolve nil context",
|
||||
run: func() error {
|
||||
_, err := directory.ResolveByEmail(nil, common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
want: "nil context",
|
||||
},
|
||||
{
|
||||
name: "ensure cancelled context",
|
||||
run: func() error {
|
||||
_, err := directory.EnsureUserByEmail(cancelledCtx, common.Email("pilot@example.com"))
|
||||
return err
|
||||
},
|
||||
want: context.Canceled.Error(),
|
||||
},
|
||||
{
|
||||
name: "exists invalid user id",
|
||||
run: func() error {
|
||||
_, err := directory.ExistsByUserID(context.Background(), common.UserID(" bad "))
|
||||
return err
|
||||
},
|
||||
want: "exists by user id",
|
||||
},
|
||||
{
|
||||
name: "block by email invalid email",
|
||||
run: func() error {
|
||||
_, err := directory.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("bad"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
want: "block by email",
|
||||
},
|
||||
{
|
||||
name: "seed invalid user id",
|
||||
run: func() error {
|
||||
return directory.SeedExisting(common.Email("pilot@example.com"), common.UserID(" bad "))
|
||||
},
|
||||
want: "seed existing user id",
|
||||
},
|
||||
{
|
||||
name: "queue invalid created user id",
|
||||
run: func() error {
|
||||
return directory.QueueCreatedUserIDs(common.UserID(" bad "))
|
||||
},
|
||||
want: "queue created user id 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.run()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubDirectorySeedBlockedUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
require.NoError(t, directory.SeedBlockedUser(
|
||||
common.Email("pilot@example.com"),
|
||||
common.UserID("user-1"),
|
||||
userresolution.BlockReasonCode("policy_block"),
|
||||
))
|
||||
|
||||
result, err := directory.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ports.BlockUserOutcomeAlreadyBlocked, result.Outcome)
|
||||
assert.Equal(t, common.UserID("user-1"), result.UserID)
|
||||
}
|
||||
|
||||
func TestStubDirectoryCancelledContextWrapsContextError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := &StubDirectory{}
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := directory.BlockByUserID(cancelledCtx, ports.BlockUserByIDInput{
|
||||
UserID: common.UserID("user-1"),
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_block"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, context.Canceled))
|
||||
assert.ErrorContains(t, err, "block by user id")
|
||||
}
|
||||
Reference in New Issue
Block a user