feat: authsession service

This commit is contained in:
Ilia Denisov
2026-04-08 16:23:07 +02:00
committed by GitHub
parent 28f04916af
commit 86a68ed9d0
174 changed files with 31732 additions and 112 deletions
@@ -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")
}