472 lines
18 KiB
Go
472 lines
18 KiB
Go
package authsession
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/authsession/internal/adapters/mail"
|
|
"galaxy/authsession/internal/adapters/userservice"
|
|
"galaxy/authsession/internal/api/internalhttp"
|
|
"galaxy/authsession/internal/api/publichttp"
|
|
"galaxy/authsession/internal/domain/common"
|
|
"galaxy/authsession/internal/domain/userresolution"
|
|
"galaxy/authsession/internal/ports"
|
|
"galaxy/authsession/internal/service/blockuser"
|
|
"galaxy/authsession/internal/service/confirmemailcode"
|
|
"galaxy/authsession/internal/service/getsession"
|
|
"galaxy/authsession/internal/service/listusersessions"
|
|
"galaxy/authsession/internal/service/revokeallusersessions"
|
|
"galaxy/authsession/internal/service/revokedevicesession"
|
|
"galaxy/authsession/internal/service/sendemailcode"
|
|
"galaxy/authsession/internal/testkit"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const userServiceRESTCompatibilityCode = "123456"
|
|
|
|
func TestUserServiceRESTCompatibilityPublicSendUsesResolveByEmailOutcomes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
harness := newUserServiceRESTCompatibilityHarness(t)
|
|
require.NoError(t, harness.directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
|
|
require.NoError(t, harness.directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_blocked")))
|
|
|
|
existing := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"existing@example.com"}`)
|
|
creatable := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"creatable@example.com"}`)
|
|
blocked := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"blocked@example.com"}`)
|
|
|
|
assert.Equal(t, http.StatusOK, existing.StatusCode)
|
|
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, existing.Body)
|
|
assert.Equal(t, http.StatusOK, creatable.StatusCode)
|
|
assert.JSONEq(t, `{"challenge_id":"challenge-2"}`, creatable.Body)
|
|
assert.Equal(t, http.StatusOK, blocked.StatusCode)
|
|
assert.JSONEq(t, `{"challenge_id":"challenge-3"}`, blocked.Body)
|
|
|
|
attempts := harness.mailSender.RecordedAttempts()
|
|
require.Len(t, attempts, 2)
|
|
assert.Equal(t, common.Email("existing@example.com"), attempts[0].Input.Email)
|
|
assert.Equal(t, common.Email("creatable@example.com"), attempts[1].Input.Email)
|
|
}
|
|
|
|
func TestUserServiceRESTCompatibilityPublicConfirmUsesEnsureOutcomes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
harness := newUserServiceRESTCompatibilityHarness(t)
|
|
require.NoError(t, harness.directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
|
|
require.NoError(t, harness.directory.QueueCreatedUserIDs(common.UserID("user-created")))
|
|
require.NoError(t, harness.directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_blocked")))
|
|
|
|
existingChallengeID := harness.sendChallengeID(t, "existing@example.com")
|
|
createdChallengeID := harness.sendChallengeID(t, "created@example.com")
|
|
blockedChallengeID := harness.sendChallengeID(t, "blocked@example.com")
|
|
|
|
existing := gatewayCompatibilityPostJSONValue(
|
|
t,
|
|
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
|
|
gatewayCompatibilityConfirmRequest(existingChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
|
|
)
|
|
created := gatewayCompatibilityPostJSONValue(
|
|
t,
|
|
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
|
|
gatewayCompatibilityConfirmRequest(createdChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
|
|
)
|
|
blocked := gatewayCompatibilityPostJSONValue(
|
|
t,
|
|
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
|
|
gatewayCompatibilityConfirmRequest(blockedChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
|
|
)
|
|
|
|
assert.Equal(t, http.StatusOK, existing.StatusCode)
|
|
assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, existing.Body)
|
|
assert.Equal(t, http.StatusOK, created.StatusCode)
|
|
assert.JSONEq(t, `{"device_session_id":"device-session-2"}`, created.Body)
|
|
assert.Equal(t, http.StatusForbidden, blocked.StatusCode)
|
|
assert.JSONEq(t, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, blocked.Body)
|
|
|
|
existingSession, err := harness.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, common.UserID("user-existing"), existingSession.UserID)
|
|
|
|
createdSession, err := harness.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-2"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, common.UserID("user-created"), createdSession.UserID)
|
|
}
|
|
|
|
func TestUserServiceRESTCompatibilityInternalRevokeAllUsesExistsByUserID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
harness := newUserServiceRESTCompatibilityHarness(t)
|
|
require.NoError(t, harness.directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
|
|
|
existing := gatewayCompatibilityPostJSON(
|
|
t,
|
|
harness.internalBaseURL+"/api/v1/internal/users/user-1/sessions/revoke-all",
|
|
`{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
|
)
|
|
missing := gatewayCompatibilityPostJSON(
|
|
t,
|
|
harness.internalBaseURL+"/api/v1/internal/users/missing-user/sessions/revoke-all",
|
|
`{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
|
)
|
|
|
|
assert.Equal(t, http.StatusOK, existing.StatusCode)
|
|
assert.JSONEq(t, `{"outcome":"no_active_sessions","user_id":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, existing.Body)
|
|
assert.Equal(t, http.StatusNotFound, missing.StatusCode)
|
|
assert.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, missing.Body)
|
|
}
|
|
|
|
func TestUserServiceRESTCompatibilityInternalBlockUserUsesRESTClient(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("block by user id", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
harness := newUserServiceRESTCompatibilityHarness(t)
|
|
require.NoError(t, harness.directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
|
|
|
response := gatewayCompatibilityPostJSON(
|
|
t,
|
|
harness.internalBaseURL+"/api/v1/internal/user-blocks",
|
|
`{"user_id":"user-1","reason_code":"policy_blocked","actor":{"type":"admin"}}`,
|
|
)
|
|
|
|
assert.Equal(t, http.StatusOK, response.StatusCode)
|
|
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"user_id","subject_value":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body)
|
|
})
|
|
|
|
t.Run("block by email", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
harness := newUserServiceRESTCompatibilityHarness(t)
|
|
|
|
response := gatewayCompatibilityPostJSON(
|
|
t,
|
|
harness.internalBaseURL+"/api/v1/internal/user-blocks",
|
|
`{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin"}}`,
|
|
)
|
|
|
|
assert.Equal(t, http.StatusOK, response.StatusCode)
|
|
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"email","subject_value":"pilot@example.com","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body)
|
|
})
|
|
}
|
|
|
|
type userServiceRESTCompatibilityHarness struct {
|
|
publicBaseURL string
|
|
internalBaseURL string
|
|
mailSender *mail.StubSender
|
|
sessionStore *testkit.InMemorySessionStore
|
|
directory *userservice.StubDirectory
|
|
}
|
|
|
|
func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompatibilityHarness {
|
|
t.Helper()
|
|
|
|
challengeStore := &testkit.InMemoryChallengeStore{}
|
|
sessionStore := &testkit.InMemorySessionStore{}
|
|
directory := &userservice.StubDirectory{}
|
|
|
|
userServiceServer := httptest.NewServer(newUserServiceStubHandler(directory))
|
|
t.Cleanup(userServiceServer.Close)
|
|
|
|
userDirectory, err := userservice.NewRESTClient(userservice.Config{
|
|
BaseURL: userServiceServer.URL,
|
|
RequestTimeout: 250 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, userDirectory.Close())
|
|
})
|
|
|
|
configProvider := testkit.StaticConfigProvider{}
|
|
publisher := &testkit.RecordingProjectionPublisher{}
|
|
mailSender := &mail.StubSender{}
|
|
idGenerator := &testkit.SequenceIDGenerator{}
|
|
codeGenerator := testkit.FixedCodeGenerator{Code: userServiceRESTCompatibilityCode}
|
|
codeHasher := testkit.DeterministicCodeHasher{}
|
|
clock := testkit.FixedClock{Time: time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)}
|
|
|
|
sendEmailCodeService, err := sendemailcode.NewWithObservability(
|
|
challengeStore,
|
|
userDirectory,
|
|
idGenerator,
|
|
codeGenerator,
|
|
codeHasher,
|
|
mailSender,
|
|
nil,
|
|
clock,
|
|
zap.NewNop(),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
confirmEmailCodeService, err := confirmemailcode.NewWithObservability(
|
|
challengeStore,
|
|
sessionStore,
|
|
userDirectory,
|
|
configProvider,
|
|
publisher,
|
|
idGenerator,
|
|
codeHasher,
|
|
clock,
|
|
zap.NewNop(),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
getSessionService, err := getsession.New(sessionStore)
|
|
require.NoError(t, err)
|
|
listUserSessionsService, err := listusersessions.New(sessionStore)
|
|
require.NoError(t, err)
|
|
revokeDeviceSessionService, err := revokedevicesession.NewWithObservability(sessionStore, publisher, clock, zap.NewNop(), nil)
|
|
require.NoError(t, err)
|
|
revokeAllUserSessionsService, err := revokeallusersessions.NewWithObservability(sessionStore, userDirectory, publisher, clock, zap.NewNop(), nil)
|
|
require.NoError(t, err)
|
|
blockUserService, err := blockuser.NewWithObservability(userDirectory, sessionStore, publisher, clock, zap.NewNop(), nil)
|
|
require.NoError(t, err)
|
|
|
|
publicCfg := publichttp.DefaultConfig()
|
|
publicCfg.Addr = gatewayCompatibilityFreeAddr(t)
|
|
publicServer, err := publichttp.NewServer(publicCfg, publichttp.Dependencies{
|
|
SendEmailCode: sendEmailCodeService,
|
|
ConfirmEmailCode: confirmEmailCodeService,
|
|
Logger: zap.NewNop(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
internalCfg := internalhttp.DefaultConfig()
|
|
internalCfg.Addr = gatewayCompatibilityFreeAddr(t)
|
|
internalServer, err := internalhttp.NewServer(internalCfg, internalhttp.Dependencies{
|
|
GetSession: getSessionService,
|
|
ListUserSessions: listUserSessionsService,
|
|
RevokeDeviceSession: revokeDeviceSessionService,
|
|
RevokeAllUserSessions: revokeAllUserSessionsService,
|
|
BlockUser: blockUserService,
|
|
Logger: zap.NewNop(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
gatewayCompatibilityRunServer(t, publicServer.Run, publicServer.Shutdown, publicCfg.Addr)
|
|
gatewayCompatibilityRunServer(t, internalServer.Run, internalServer.Shutdown, internalCfg.Addr)
|
|
|
|
return userServiceRESTCompatibilityHarness{
|
|
publicBaseURL: "http://" + publicCfg.Addr,
|
|
internalBaseURL: "http://" + internalCfg.Addr,
|
|
mailSender: mailSender,
|
|
sessionStore: sessionStore,
|
|
directory: directory,
|
|
}
|
|
}
|
|
|
|
func (h userServiceRESTCompatibilityHarness) sendChallengeID(t *testing.T, email string) string {
|
|
t.Helper()
|
|
|
|
response := gatewayCompatibilityPostJSON(t, h.publicBaseURL+"/api/v1/public/auth/send-email-code", fmt.Sprintf(`{"email":"%s"}`, email))
|
|
assert.Equal(t, http.StatusOK, response.StatusCode)
|
|
|
|
var body struct {
|
|
ChallengeID string `json:"challenge_id"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(response.Body), &body))
|
|
require.NotEmpty(t, body.ChallengeID)
|
|
|
|
return body.ChallengeID
|
|
}
|
|
|
|
func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
switch {
|
|
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/user-resolutions/by-email":
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if !decodeUserServiceStubRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
|
|
result, err := directory.ResolveByEmail(request.Context(), common.Email(input.Email))
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
response := map[string]any{"kind": result.Kind}
|
|
if !result.UserID.IsZero() {
|
|
response["user_id"] = result.UserID.String()
|
|
}
|
|
if !result.BlockReasonCode.IsZero() {
|
|
response["block_reason_code"] = result.BlockReasonCode.String()
|
|
}
|
|
writeUserServiceStubJSON(writer, http.StatusOK, response)
|
|
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/exists"):
|
|
userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/exists")
|
|
userIDValue, err := url.PathUnescape(userIDValue)
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
exists, err := directory.ExistsByUserID(request.Context(), common.UserID(userIDValue))
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writeUserServiceStubJSON(writer, http.StatusOK, map[string]bool{"exists": exists})
|
|
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/users/ensure-by-email":
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
RegistrationContext *struct {
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
} `json:"registration_context"`
|
|
}
|
|
if !decodeUserServiceStubRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
|
|
ensureInput := ports.EnsureUserInput{
|
|
Email: common.Email(input.Email),
|
|
}
|
|
if input.RegistrationContext != nil {
|
|
ensureInput.RegistrationContext = &ports.RegistrationContext{
|
|
PreferredLanguage: input.RegistrationContext.PreferredLanguage,
|
|
TimeZone: input.RegistrationContext.TimeZone,
|
|
}
|
|
}
|
|
if ensureInput.RegistrationContext == nil {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context must be present"))
|
|
return
|
|
}
|
|
if ensureInput.RegistrationContext.PreferredLanguage != "en" {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.preferred_language must equal en during rollout"))
|
|
return
|
|
}
|
|
if ensureInput.RegistrationContext.TimeZone != gatewayCompatibilityTimeZone {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.time_zone must match public confirm time_zone"))
|
|
return
|
|
}
|
|
|
|
result, err := directory.EnsureUserByEmail(request.Context(), ensureInput)
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
response := map[string]any{"outcome": result.Outcome}
|
|
if !result.UserID.IsZero() {
|
|
response["user_id"] = result.UserID.String()
|
|
}
|
|
if !result.BlockReasonCode.IsZero() {
|
|
response["block_reason_code"] = result.BlockReasonCode.String()
|
|
}
|
|
writeUserServiceStubJSON(writer, http.StatusOK, response)
|
|
case request.Method == http.MethodPost && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/block"):
|
|
userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/block")
|
|
userIDValue, err := url.PathUnescape(userIDValue)
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
ReasonCode string `json:"reason_code"`
|
|
}
|
|
if !decodeUserServiceStubRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
|
|
result, err := directory.BlockByUserID(request.Context(), ports.BlockUserByIDInput{
|
|
UserID: common.UserID(userIDValue),
|
|
ReasonCode: userresolution.BlockReasonCode(input.ReasonCode),
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ports.ErrNotFound) {
|
|
writeUserServiceStubJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
response := map[string]any{"outcome": result.Outcome}
|
|
if !result.UserID.IsZero() {
|
|
response["user_id"] = result.UserID.String()
|
|
}
|
|
writeUserServiceStubJSON(writer, http.StatusOK, response)
|
|
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/user-blocks/by-email":
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
ReasonCode string `json:"reason_code"`
|
|
}
|
|
if !decodeUserServiceStubRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
|
|
result, err := directory.BlockByEmail(request.Context(), ports.BlockUserByEmailInput{
|
|
Email: common.Email(input.Email),
|
|
ReasonCode: userresolution.BlockReasonCode(input.ReasonCode),
|
|
})
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
response := map[string]any{"outcome": result.Outcome}
|
|
if !result.UserID.IsZero() {
|
|
response["user_id"] = result.UserID.String()
|
|
}
|
|
writeUserServiceStubJSON(writer, http.StatusOK, response)
|
|
default:
|
|
http.NotFound(writer, request)
|
|
}
|
|
})
|
|
}
|
|
|
|
func decodeUserServiceStubRequest(writer http.ResponseWriter, request *http.Request, target any) bool {
|
|
decoder := json.NewDecoder(request.Body)
|
|
decoder.DisallowUnknownFields()
|
|
|
|
if err := decoder.Decode(target); err != nil {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, err)
|
|
return false
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("unexpected trailing JSON input"))
|
|
return false
|
|
}
|
|
writeUserServiceStubError(writer, http.StatusBadRequest, err)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func writeUserServiceStubJSON(writer http.ResponseWriter, statusCode int, value any) {
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(statusCode)
|
|
_, _ = writer.Write(payload)
|
|
}
|
|
|
|
func writeUserServiceStubError(writer http.ResponseWriter, statusCode int, err error) {
|
|
http.Error(writer, err.Error(), statusCode)
|
|
}
|