feat: authsession service
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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", map[string]string{
|
||||
"challenge_id": existingChallengeID,
|
||||
"code": userServiceRESTCompatibilityCode,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
created := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": createdChallengeID,
|
||||
"code": userServiceRESTCompatibilityCode,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
blocked := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": blockedChallengeID,
|
||||
"code": userServiceRESTCompatibilityCode,
|
||||
"client_public_key": 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"`
|
||||
}
|
||||
if !decodeUserServiceStubRequest(writer, request, &input) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := directory.EnsureUserByEmail(request.Context(), common.Email(input.Email))
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user