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,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)
}