426 lines
13 KiB
Go
426 lines
13 KiB
Go
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,
|
|
"time_zone": publicConfirmTimeZone,
|
|
}
|
|
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","time_zone":"`+publicConfirmTimeZone+`"}`,
|
|
)
|
|
|
|
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 TestPublicHTTPEndToEndInvalidTimeZone(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":"`+validClientPublicKey+`","time_zone":"Mars/Olympus"}`,
|
|
)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
|
|
assert.JSONEq(t, `{"error":{"code":"invalid_request","message":"time_zone must be a valid IANA time zone name"}}`, 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,
|
|
"time_zone": publicConfirmTimeZone,
|
|
})
|
|
|
|
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,
|
|
"time_zone": publicConfirmTimeZone,
|
|
})
|
|
|
|
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,
|
|
"time_zone": publicConfirmTimeZone,
|
|
})
|
|
|
|
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,
|
|
"time_zone": publicConfirmTimeZone,
|
|
})
|
|
|
|
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,
|
|
"time_zone": publicConfirmTimeZone,
|
|
})
|
|
|
|
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
|
|
PreferredLanguage string
|
|
}
|
|
|
|
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),
|
|
PreferredLanguage: options.SeedChallenge.PreferredLanguage,
|
|
Status: options.SeedChallenge.Status,
|
|
DeliveryState: deliveryStateForSeedChallenge(options.SeedChallenge.Status),
|
|
CreatedAt: now.Add(-time.Minute),
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
if record.PreferredLanguage == "" {
|
|
record.PreferredLanguage = "en"
|
|
}
|
|
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="
|
|
)
|