package publichttp import ( "bytes" "context" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" "galaxy/authsession/internal/adapters/mail" "galaxy/authsession/internal/adapters/userservice" "galaxy/authsession/internal/domain/challenge" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/devicesession" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" "galaxy/authsession/internal/service/confirmemailcode" "galaxy/authsession/internal/service/sendemailcode" "galaxy/authsession/internal/testkit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPublicHTTPEndToEndSendThenConfirm(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{}) server := httptest.NewServer(app.handler) defer server.Close() sendResponse := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, sendResponse.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, sendResponse.Body) attempts := app.mailSender.RecordedAttempts() require.Len(t, attempts, 1) confirmBody := map[string]string{ "challenge_id": "challenge-1", "code": attempts[0].Input.Code, "client_public_key": validClientPublicKey, } confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", confirmBody) assert.Equal(t, http.StatusOK, confirmResponse.StatusCode) assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, confirmResponse.Body) } func TestPublicHTTPEndToEndBlockedSendReturnsChallengeID(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{ SeedBlockedEmail: true, }) server := httptest.NewServer(app.handler) defer server.Close() response := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, response.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body) assert.Empty(t, app.mailSender.RecordedAttempts()) } func TestPublicHTTPEndToEndThrottledSendStillReturnsChallengeID(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{ AbuseProtector: &testkit.InMemorySendEmailCodeAbuseProtector{}, }) server := httptest.NewServer(app.handler) defer server.Close() first := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, first.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, first.Body) second := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, second.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-2"}`, second.Body) assert.Len(t, app.mailSender.RecordedAttempts(), 1) } func TestPublicHTTPEndToEndInvalidClientPublicKey(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{ SeedChallenge: seedChallengeOptions{ ID: "challenge-123", Code: "123456", Status: challenge.StatusSent, }, }) server := httptest.NewServer(app.handler) defer server.Close() response := postJSON( t, server.URL+"/api/v1/public/auth/confirm-email-code", `{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid"}`, ) assert.Equal(t, http.StatusBadRequest, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, response.Body) } func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{}) server := httptest.NewServer(app.handler) defer server.Close() response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{ "challenge_id": "missing", "code": "123456", "client_public_key": validClientPublicKey, }) assert.Equal(t, http.StatusNotFound, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`, response.Body) } func TestPublicHTTPEndToEndChallengeExpired(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{ SeedChallenge: seedChallengeOptions{ ID: "challenge-123", Code: "123456", Status: challenge.StatusSent, ExpiresAt: time.Date(2026, 4, 5, 11, 59, 0, 0, time.UTC), }, }) server := httptest.NewServer(app.handler) defer server.Close() response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{ "challenge_id": "challenge-123", "code": "123456", "client_public_key": validClientPublicKey, }) assert.Equal(t, http.StatusGone, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"challenge_expired","message":"challenge expired"}}`, response.Body) } func TestPublicHTTPEndToEndInvalidCode(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{ SeedChallenge: seedChallengeOptions{ ID: "challenge-123", Code: "123456", Status: challenge.StatusSent, }, }) server := httptest.NewServer(app.handler) defer server.Close() response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{ "challenge_id": "challenge-123", "code": "654321", "client_public_key": validClientPublicKey, }) assert.Equal(t, http.StatusBadRequest, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"invalid_code","message":"confirmation code is invalid"}}`, response.Body) } func TestPublicHTTPEndToEndThrottledChallengeConfirmReturnsInvalidCode(t *testing.T) { t.Parallel() app := newEndToEndApp(t, endToEndOptions{ SeedChallenge: seedChallengeOptions{ ID: "challenge-123", Code: "123456", Status: challenge.StatusDeliveryThrottled, }, }) server := httptest.NewServer(app.handler) defer server.Close() response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{ "challenge_id": "challenge-123", "code": "123456", "client_public_key": validClientPublicKey, }) assert.Equal(t, http.StatusBadRequest, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"invalid_code","message":"confirmation code is invalid"}}`, response.Body) } func TestPublicHTTPEndToEndSessionLimitExceeded(t *testing.T) { t.Parallel() limit := 1 app := newEndToEndApp(t, endToEndOptions{ Config: ports.SessionLimitConfig{ActiveSessionLimit: &limit}, SeedExistingUser: true, SeedActiveSession: &devicesession.Session{ ID: common.DeviceSessionID("device-session-existing"), UserID: common.UserID("user-1"), ClientPublicKey: mustClientPublicKey(t, secondValidClientPublicKey), Status: devicesession.StatusActive, CreatedAt: time.Date(2026, 4, 5, 11, 58, 0, 0, time.UTC), }, }) server := httptest.NewServer(app.handler) defer server.Close() sendResponse := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, sendResponse.StatusCode) attempts := app.mailSender.RecordedAttempts() require.Len(t, attempts, 1) confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{ "challenge_id": "challenge-1", "code": attempts[0].Input.Code, "client_public_key": validClientPublicKey, }) assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode) assert.JSONEq(t, `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, confirmResponse.Body) } type endToEndOptions struct { Config ports.SessionLimitConfig AbuseProtector ports.SendEmailCodeAbuseProtector SeedBlockedEmail bool SeedExistingUser bool SeedChallenge seedChallengeOptions SeedActiveSession *devicesession.Session } type seedChallengeOptions struct { ID string Code string Status challenge.Status ExpiresAt time.Time } type endToEndApp struct { handler http.Handler mailSender *mail.StubSender } func newEndToEndApp(t *testing.T, options endToEndOptions) endToEndApp { t.Helper() now := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC) challengeStore := &testkit.InMemoryChallengeStore{} sessionStore := &testkit.InMemorySessionStore{} userDirectory := &userservice.StubDirectory{} mailSender := &mail.StubSender{} idGenerator := &testkit.SequenceIDGenerator{} codeGenerator := testkit.FixedCodeGenerator{Code: "123456"} codeHasher := testkit.DeterministicCodeHasher{} clock := testkit.FixedClock{Time: now} publisher := &testkit.RecordingProjectionPublisher{} if options.SeedBlockedEmail { require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_blocked"))) } if options.SeedExistingUser { require.NoError(t, userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1"))) } if options.SeedActiveSession != nil { require.NoError(t, sessionStore.Create(context.Background(), *options.SeedActiveSession)) } if options.SeedChallenge.ID != "" { expiresAt := options.SeedChallenge.ExpiresAt if expiresAt.IsZero() { expiresAt = now.Add(challenge.InitialTTL) } record := challenge.Challenge{ ID: common.ChallengeID(options.SeedChallenge.ID), Email: common.Email("pilot@example.com"), CodeHash: mustHashCode(t, options.SeedChallenge.Code), Status: options.SeedChallenge.Status, DeliveryState: deliveryStateForSeedChallenge(options.SeedChallenge.Status), CreatedAt: now.Add(-time.Minute), ExpiresAt: expiresAt, } require.NoError(t, challengeStore.Create(context.Background(), record)) } sendService, err := sendemailcode.NewWithRuntime( challengeStore, userDirectory, idGenerator, codeGenerator, codeHasher, mailSender, options.AbuseProtector, clock, nil, ) require.NoError(t, err) confirmService, err := confirmemailcode.New( challengeStore, sessionStore, userDirectory, testkit.StaticConfigProvider{Config: options.Config}, publisher, idGenerator, codeHasher, clock, ) require.NoError(t, err) handler := mustNewHandler(t, DefaultConfig(), Dependencies{ SendEmailCode: sendService, ConfirmEmailCode: confirmService, }) return endToEndApp{ handler: handler, mailSender: mailSender, } } func deliveryStateForSeedChallenge(status challenge.Status) challenge.DeliveryState { switch status { case challenge.StatusDeliverySuppressed: return challenge.DeliverySuppressed case challenge.StatusDeliveryThrottled: return challenge.DeliveryThrottled default: return challenge.DeliverySent } } type httpResponse struct { StatusCode int Body string } func postJSON(t *testing.T, url string, body string) httpResponse { t.Helper() response, err := http.Post(url, "application/json", bytes.NewBufferString(body)) require.NoError(t, err) defer response.Body.Close() payload, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{StatusCode: response.StatusCode, Body: string(payload)} } func postJSONValue(t *testing.T, url string, value any) httpResponse { t.Helper() body, err := json.Marshal(value) require.NoError(t, err) return postJSON(t, url, string(body)) } func mustHashCode(t *testing.T, code string) []byte { t.Helper() sum := sha256.Sum256([]byte(code)) return sum[:] } func mustClientPublicKey(t *testing.T, encoded string) common.ClientPublicKey { t.Helper() decoded, err := base64.StdEncoding.DecodeString(encoded) require.NoError(t, err) key, err := common.NewClientPublicKey(ed25519.PublicKey(decoded)) require.NoError(t, err) return key } const ( validClientPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" secondValidClientPublicKey = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=" )