feat: authsession service
This commit is contained in:
@@ -0,0 +1,837 @@
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/adapters/mail"
|
||||
"galaxy/authsession/internal/adapters/redis/challengestore"
|
||||
"galaxy/authsession/internal/adapters/redis/configprovider"
|
||||
"galaxy/authsession/internal/adapters/redis/projectionpublisher"
|
||||
"galaxy/authsession/internal/adapters/redis/sessionstore"
|
||||
"galaxy/authsession/internal/adapters/userservice"
|
||||
"galaxy/authsession/internal/api/internalhttp"
|
||||
"galaxy/authsession/internal/api/publichttp"
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/gatewayprojection"
|
||||
"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/service/shared"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const hardeningLargeSessionCount = 256
|
||||
|
||||
// hardeningEnvironment owns one reusable Redis-backed integration environment
|
||||
// for Stage 22 tests.
|
||||
type hardeningEnvironment struct {
|
||||
redisAddr string
|
||||
redisServer *miniredis.Miniredis
|
||||
redisClient *redis.Client
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// newHardeningEnvironment starts one miniredis-backed environment on a stable
|
||||
// local address so tests can restart Redis on the same endpoint when needed.
|
||||
func newHardeningEnvironment(t *testing.T) *hardeningEnvironment {
|
||||
t.Helper()
|
||||
|
||||
env := &hardeningEnvironment{
|
||||
redisAddr: gatewayCompatibilityFreeAddr(t),
|
||||
now: time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
env.startRedis(t)
|
||||
|
||||
env.redisClient = redis.NewClient(&redis.Options{
|
||||
Addr: env.redisAddr,
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
env.Close()
|
||||
})
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// startRedis starts one miniredis instance on the environment's configured
|
||||
// address.
|
||||
func (e *hardeningEnvironment) startRedis(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if e.redisServer != nil {
|
||||
require.Fail(t, "hardening environment redis already running")
|
||||
}
|
||||
|
||||
server := miniredis.NewMiniRedis()
|
||||
require.NoError(t, server.StartAddr(e.redisAddr))
|
||||
e.redisServer = server
|
||||
}
|
||||
|
||||
// StopRedis stops the current Redis server and keeps the configured address
|
||||
// reserved for later restart tests.
|
||||
func (e *hardeningEnvironment) StopRedis() {
|
||||
if e == nil || e.redisServer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.redisServer.Close()
|
||||
e.redisServer = nil
|
||||
}
|
||||
|
||||
// RestartRedis starts a fresh Redis server on the same configured address.
|
||||
func (e *hardeningEnvironment) RestartRedis(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
e.StopRedis()
|
||||
e.startRedis(t)
|
||||
}
|
||||
|
||||
// FastForward advances miniredis time to exercise TTL-based cleanup behavior.
|
||||
func (e *hardeningEnvironment) FastForward(t *testing.T, duration time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
require.NotNil(t, e.redisServer)
|
||||
e.redisServer.FastForward(duration)
|
||||
}
|
||||
|
||||
// Close releases the Redis client and any still-running Redis server.
|
||||
func (e *hardeningEnvironment) Close() {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if e.redisClient != nil {
|
||||
_ = e.redisClient.Close()
|
||||
e.redisClient = nil
|
||||
}
|
||||
if e.redisServer != nil {
|
||||
e.redisServer.Close()
|
||||
e.redisServer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// GatewayCacheExists reports whether the gateway-compatible cache record for
|
||||
// deviceSessionID is currently present in Redis.
|
||||
func (e *hardeningEnvironment) GatewayCacheExists(ctx context.Context, deviceSessionID string) bool {
|
||||
if e == nil || e.redisClient == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := e.redisClient.Get(ctx, gatewayCompatibilitySessionCacheKeyPrefix+deviceSessionID).Bytes()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MustReadGatewayCacheRecord reads one strict gateway-compatible cache record
|
||||
// from Redis.
|
||||
func (e *hardeningEnvironment) MustReadGatewayCacheRecord(t *testing.T, deviceSessionID string) gatewayCacheRecord {
|
||||
t.Helper()
|
||||
|
||||
payload, err := e.redisClient.Get(context.Background(), gatewayCompatibilitySessionCacheKeyPrefix+deviceSessionID).Bytes()
|
||||
require.NoError(t, err)
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var record gatewayCacheRecord
|
||||
require.NoError(t, decoder.Decode(&record))
|
||||
|
||||
err = decoder.Decode(&struct{}{})
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
|
||||
require.Equal(t, deviceSessionID, record.DeviceSessionID)
|
||||
require.NotEmpty(t, record.UserID)
|
||||
require.NotEmpty(t, record.ClientPublicKey)
|
||||
require.Contains(t, []string{"active", "revoked"}, record.Status)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// MustReadGatewaySessionEvents reads every gateway-compatible stream event for
|
||||
// deviceSessionID from the shared session-events stream.
|
||||
func (e *hardeningEnvironment) MustReadGatewaySessionEvents(t *testing.T, deviceSessionID string) []gatewaySessionEventRecord {
|
||||
t.Helper()
|
||||
|
||||
entries, err := e.redisClient.XRange(context.Background(), gatewayCompatibilitySessionEventsStream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
records := make([]gatewaySessionEventRecord, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
record := decodeGatewaySessionEvent(t, entry.Values)
|
||||
if record.DeviceSessionID == deviceSessionID {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// hardeningAppOptions configures one runnable Stage-22 integration app.
|
||||
type hardeningAppOptions struct {
|
||||
SeedExistingUser bool
|
||||
SeedBlockedEmail bool
|
||||
SessionLimit *int
|
||||
SeedSessions []devicesession.Session
|
||||
PublisherErrors []error
|
||||
WrapSessionStore func(ports.SessionStore) ports.SessionStore
|
||||
}
|
||||
|
||||
// hardeningApp owns one pair of real public and internal HTTP servers backed
|
||||
// by real Redis adapters and seedable stub dependencies.
|
||||
type hardeningApp struct {
|
||||
publicBaseURL string
|
||||
internalBaseURL string
|
||||
|
||||
challengeStore *challengestore.Store
|
||||
sessionStore *sessionstore.Store
|
||||
configStore *configprovider.Store
|
||||
publisher *projectionpublisher.Publisher
|
||||
|
||||
mailSender *mail.StubSender
|
||||
userDirectory *userservice.StubDirectory
|
||||
|
||||
closeOnce sync.Once
|
||||
closeFn func()
|
||||
}
|
||||
|
||||
// newHardeningApp builds and starts one real authsession HTTP pair over the
|
||||
// shared hardening environment.
|
||||
func newHardeningApp(t *testing.T, env *hardeningEnvironment, options hardeningAppOptions) *hardeningApp {
|
||||
t.Helper()
|
||||
|
||||
require.NotNil(t, env)
|
||||
|
||||
if options.SessionLimit == nil {
|
||||
require.NoError(t, env.redisClient.Del(context.Background(), gatewayCompatibilitySessionLimitKey).Err())
|
||||
} else {
|
||||
env.redisServer.Set(gatewayCompatibilitySessionLimitKey, strconv.Itoa(*options.SessionLimit))
|
||||
}
|
||||
|
||||
challengeStore, err := challengestore.New(challengestore.Config{
|
||||
Addr: env.redisAddr,
|
||||
DB: 0,
|
||||
KeyPrefix: gatewayCompatibilityChallengeKeyPrefix,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
redisSessionStore, err := sessionstore.New(sessionstore.Config{
|
||||
Addr: env.redisAddr,
|
||||
DB: 0,
|
||||
SessionKeyPrefix: gatewayCompatibilitySessionKeyPrefix,
|
||||
UserSessionsKeyPrefix: gatewayCompatibilityUserSessionsKeyPrefix,
|
||||
UserActiveSessionsKeyPrefix: gatewayCompatibilityUserActiveKeyPrefix,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
configStore, err := configprovider.New(configprovider.Config{
|
||||
Addr: env.redisAddr,
|
||||
DB: 0,
|
||||
SessionLimitKey: gatewayCompatibilitySessionLimitKey,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
redisPublisher, err := projectionpublisher.New(projectionpublisher.Config{
|
||||
Addr: env.redisAddr,
|
||||
DB: 0,
|
||||
SessionCacheKeyPrefix: gatewayCompatibilitySessionCacheKeyPrefix,
|
||||
SessionEventsStream: gatewayCompatibilitySessionEventsStream,
|
||||
StreamMaxLen: gatewayCompatibilityStreamMaxLen,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userDirectory := &userservice.StubDirectory{}
|
||||
if options.SeedBlockedEmail {
|
||||
require.NoError(t, userDirectory.SeedBlockedEmail(common.Email(gatewayCompatibilityEmail), "policy_blocked"))
|
||||
}
|
||||
if options.SeedExistingUser {
|
||||
require.NoError(t, userDirectory.SeedExisting(common.Email(gatewayCompatibilityEmail), common.UserID("user-1")))
|
||||
}
|
||||
|
||||
for _, session := range options.SeedSessions {
|
||||
require.NoError(t, redisSessionStore.Create(context.Background(), session))
|
||||
}
|
||||
|
||||
publisherPort := ports.GatewaySessionProjectionPublisher(redisPublisher)
|
||||
if len(options.PublisherErrors) > 0 {
|
||||
publisherPort = &scriptedProjectionPublisher{
|
||||
delegate: redisPublisher,
|
||||
errors: append([]error(nil), options.PublisherErrors...),
|
||||
}
|
||||
}
|
||||
|
||||
sessionStorePort := ports.SessionStore(redisSessionStore)
|
||||
if options.WrapSessionStore != nil {
|
||||
sessionStorePort = options.WrapSessionStore(sessionStorePort)
|
||||
}
|
||||
|
||||
mailSender := &mail.StubSender{}
|
||||
idGenerator := &testkit.SequenceIDGenerator{}
|
||||
codeHasher := testkit.DeterministicCodeHasher{}
|
||||
clock := testkit.FixedClock{Time: env.now}
|
||||
|
||||
sendEmailCodeService, err := sendemailcode.NewWithObservability(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
idGenerator,
|
||||
testkit.FixedCodeGenerator{Code: gatewayCompatibilityCode},
|
||||
codeHasher,
|
||||
mailSender,
|
||||
nil,
|
||||
clock,
|
||||
zap.NewNop(),
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
confirmEmailCodeService, err := confirmemailcode.NewWithObservability(
|
||||
challengeStore,
|
||||
sessionStorePort,
|
||||
userDirectory,
|
||||
configStore,
|
||||
publisherPort,
|
||||
idGenerator,
|
||||
codeHasher,
|
||||
clock,
|
||||
zap.NewNop(),
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
getSessionService, err := getsession.New(sessionStorePort)
|
||||
require.NoError(t, err)
|
||||
listUserSessionsService, err := listusersessions.New(sessionStorePort)
|
||||
require.NoError(t, err)
|
||||
revokeDeviceSessionService, err := revokedevicesession.NewWithObservability(sessionStorePort, publisherPort, clock, zap.NewNop(), nil)
|
||||
require.NoError(t, err)
|
||||
revokeAllUserSessionsService, err := revokeallusersessions.NewWithObservability(sessionStorePort, userDirectory, publisherPort, clock, zap.NewNop(), nil)
|
||||
require.NoError(t, err)
|
||||
blockUserService, err := blockuser.NewWithObservability(userDirectory, sessionStorePort, publisherPort, 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)
|
||||
|
||||
stopPublic := startHardeningServer(t, publicServer.Run, publicServer.Shutdown, publicCfg.Addr)
|
||||
stopInternal := startHardeningServer(t, internalServer.Run, internalServer.Shutdown, internalCfg.Addr)
|
||||
|
||||
app := &hardeningApp{
|
||||
publicBaseURL: "http://" + publicCfg.Addr,
|
||||
internalBaseURL: "http://" + internalCfg.Addr,
|
||||
challengeStore: challengeStore,
|
||||
sessionStore: redisSessionStore,
|
||||
configStore: configStore,
|
||||
publisher: redisPublisher,
|
||||
mailSender: mailSender,
|
||||
userDirectory: userDirectory,
|
||||
}
|
||||
app.closeFn = func() {
|
||||
stopPublic()
|
||||
stopInternal()
|
||||
assert.NoError(t, challengeStore.Close())
|
||||
assert.NoError(t, redisSessionStore.Close())
|
||||
assert.NoError(t, configStore.Close())
|
||||
assert.NoError(t, redisPublisher.Close())
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
app.Close()
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Close stops the app servers and releases the real Redis adapters.
|
||||
func (a *hardeningApp) Close() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.closeOnce.Do(func() {
|
||||
if a.closeFn != nil {
|
||||
a.closeFn()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SendChallenge exercises the public send endpoint and returns the issued
|
||||
// challenge identifier together with the cleartext code observed by the stub
|
||||
// mail sender.
|
||||
func (a *hardeningApp) SendChallenge(t *testing.T, email string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
response := gatewayCompatibilityPostJSONValue(t, a.publicBaseURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": 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))
|
||||
|
||||
attempts := a.mailSender.RecordedAttempts()
|
||||
require.NotEmpty(t, attempts)
|
||||
|
||||
return body.ChallengeID, attempts[len(attempts)-1].Input.Code
|
||||
}
|
||||
|
||||
// CreateSessionThroughPublicFlow creates one active user session through the
|
||||
// real public send and confirm handlers.
|
||||
func (a *hardeningApp) CreateSessionThroughPublicFlow(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
challengeID, code := a.SendChallenge(t, gatewayCompatibilityEmail)
|
||||
response := gatewayCompatibilityPostJSONValue(t, a.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": challengeID,
|
||||
"code": code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
|
||||
var body struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(response.Body), &body))
|
||||
|
||||
return body.DeviceSessionID
|
||||
}
|
||||
|
||||
// scriptedProjectionPublisher fails selected publish attempts before
|
||||
// delegating to the real Redis projection publisher.
|
||||
type scriptedProjectionPublisher struct {
|
||||
mu sync.Mutex
|
||||
|
||||
delegate ports.GatewaySessionProjectionPublisher
|
||||
errors []error
|
||||
}
|
||||
|
||||
// PublishSession returns scripted errors first and delegates only after the
|
||||
// script is exhausted.
|
||||
func (p *scriptedProjectionPublisher) PublishSession(ctx context.Context, snapshot gatewayprojection.Snapshot) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if len(p.errors) > 0 {
|
||||
err := p.errors[0]
|
||||
p.errors = append([]error(nil), p.errors[1:]...)
|
||||
p.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.delegate.PublishSession(ctx, snapshot)
|
||||
}
|
||||
|
||||
var _ ports.GatewaySessionProjectionPublisher = (*scriptedProjectionPublisher)(nil)
|
||||
|
||||
// startHardeningServer starts one HTTP server and returns a stop function that
|
||||
// performs graceful shutdown exactly once.
|
||||
func startHardeningServer(
|
||||
t *testing.T,
|
||||
run func(context.Context) error,
|
||||
shutdown func(context.Context) error,
|
||||
addr string,
|
||||
) func() {
|
||||
t.Helper()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- run(context.Background())
|
||||
}()
|
||||
|
||||
gatewayCompatibilityWaitForTCP(t, addr)
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
assert.NoError(t, shutdown(shutdownCtx))
|
||||
assert.NoError(t, <-errCh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hardeningGetJSON sends one GET request and returns the captured response.
|
||||
func hardeningGetJSON(t *testing.T, url string) gatewayCompatibilityHTTPResponse {
|
||||
t.Helper()
|
||||
|
||||
response, err := http.Get(url)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return gatewayCompatibilityHTTPResponse{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: string(payload),
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionHardeningRedisReconnectRecoversOnSameLiveProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
app := newHardeningApp(t, env, hardeningAppOptions{})
|
||||
|
||||
_, _ = app.SendChallenge(t, gatewayCompatibilityEmail)
|
||||
|
||||
env.StopRedis()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
response := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": gatewayCompatibilityEmail,
|
||||
})
|
||||
return response.StatusCode == http.StatusServiceUnavailable
|
||||
}, 5*time.Second, 50*time.Millisecond)
|
||||
|
||||
env.RestartRedis(t)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
response := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": gatewayCompatibilityEmail,
|
||||
})
|
||||
return response.StatusCode == http.StatusOK
|
||||
}, 5*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestProductionHardeningConfirmRetryRepairsProjectionAfterProcessRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
publishErr := errors.New("hardening publish failure")
|
||||
|
||||
failingApp := newHardeningApp(t, env, hardeningAppOptions{
|
||||
PublisherErrors: repeatHardeningError(publishErr, shared.MaxProjectionPublishAttempts),
|
||||
})
|
||||
|
||||
challengeID, code := failingApp.SendChallenge(t, gatewayCompatibilityEmail)
|
||||
firstConfirm := gatewayCompatibilityPostJSONValue(t, failingApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": challengeID,
|
||||
"code": code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusServiceUnavailable, firstConfirm.StatusCode)
|
||||
assert.False(t, env.GatewayCacheExists(context.Background(), "device-session-1"))
|
||||
|
||||
failingApp.Close()
|
||||
|
||||
healthyApp := newHardeningApp(t, env, hardeningAppOptions{})
|
||||
secondConfirm := gatewayCompatibilityPostJSONValue(t, healthyApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": challengeID,
|
||||
"code": code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, secondConfirm.StatusCode)
|
||||
|
||||
var body struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(secondConfirm.Body), &body))
|
||||
assert.Equal(t, "device-session-1", body.DeviceSessionID)
|
||||
|
||||
record := env.MustReadGatewayCacheRecord(t, body.DeviceSessionID)
|
||||
assert.Equal(t, gatewayCacheRecord{
|
||||
DeviceSessionID: "device-session-1",
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "active",
|
||||
}, record)
|
||||
}
|
||||
|
||||
func TestProductionHardeningRepeatedRevokeRepairsProjectionAfterProcessRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
createApp := newHardeningApp(t, env, hardeningAppOptions{SeedExistingUser: true})
|
||||
sessionID := createApp.CreateSessionThroughPublicFlow(t)
|
||||
createApp.Close()
|
||||
|
||||
publishErr := errors.New("hardening publish failure")
|
||||
failingApp := newHardeningApp(t, env, hardeningAppOptions{
|
||||
SeedExistingUser: true,
|
||||
PublisherErrors: repeatHardeningError(publishErr, shared.MaxProjectionPublishAttempts),
|
||||
})
|
||||
|
||||
firstRevoke := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
failingApp.internalBaseURL+"/api/v1/internal/sessions/"+sessionID+"/revoke",
|
||||
`{"reason_code":"admin_revoke","actor":{"type":"system"}}`,
|
||||
)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, firstRevoke.StatusCode)
|
||||
|
||||
activeRecord := env.MustReadGatewayCacheRecord(t, sessionID)
|
||||
assert.Equal(t, "active", activeRecord.Status)
|
||||
|
||||
failingApp.Close()
|
||||
|
||||
healthyApp := newHardeningApp(t, env, hardeningAppOptions{SeedExistingUser: true})
|
||||
secondRevoke := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
healthyApp.internalBaseURL+"/api/v1/internal/sessions/"+sessionID+"/revoke",
|
||||
`{"reason_code":"admin_revoke","actor":{"type":"system"}}`,
|
||||
)
|
||||
assert.Equal(t, http.StatusOK, secondRevoke.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"already_revoked","device_session_id":"`+sessionID+`","affected_session_count":0}`, secondRevoke.Body)
|
||||
|
||||
revokedRecord := env.MustReadGatewayCacheRecord(t, sessionID)
|
||||
require.NotNil(t, revokedRecord.RevokedAtMS)
|
||||
assert.Equal(t, "revoked", revokedRecord.Status)
|
||||
}
|
||||
|
||||
func TestProductionHardeningRepeatedRevokeAllRepairsProjectionAfterProcessRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
createApp := newHardeningApp(t, env, hardeningAppOptions{SeedExistingUser: true})
|
||||
firstSessionID := createApp.CreateSessionThroughPublicFlow(t)
|
||||
secondSessionID := createApp.CreateSessionThroughPublicFlow(t)
|
||||
createApp.Close()
|
||||
|
||||
publishErr := errors.New("hardening publish failure")
|
||||
failingApp := newHardeningApp(t, env, hardeningAppOptions{
|
||||
SeedExistingUser: true,
|
||||
PublisherErrors: repeatHardeningError(publishErr, shared.MaxProjectionPublishAttempts),
|
||||
})
|
||||
|
||||
firstRevokeAll := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
failingApp.internalBaseURL+"/api/v1/internal/users/user-1/sessions/revoke-all",
|
||||
`{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
||||
)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, firstRevokeAll.StatusCode)
|
||||
|
||||
assert.Equal(t, "active", env.MustReadGatewayCacheRecord(t, firstSessionID).Status)
|
||||
assert.Equal(t, "active", env.MustReadGatewayCacheRecord(t, secondSessionID).Status)
|
||||
|
||||
failingApp.Close()
|
||||
|
||||
healthyApp := newHardeningApp(t, env, hardeningAppOptions{SeedExistingUser: true})
|
||||
secondRevokeAll := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
healthyApp.internalBaseURL+"/api/v1/internal/users/user-1/sessions/revoke-all",
|
||||
`{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
||||
)
|
||||
assert.Equal(t, http.StatusOK, secondRevokeAll.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"no_active_sessions","user_id":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, secondRevokeAll.Body)
|
||||
|
||||
firstRecord := env.MustReadGatewayCacheRecord(t, firstSessionID)
|
||||
secondRecord := env.MustReadGatewayCacheRecord(t, secondSessionID)
|
||||
require.NotNil(t, firstRecord.RevokedAtMS)
|
||||
require.NotNil(t, secondRecord.RevokedAtMS)
|
||||
assert.Equal(t, "revoked", firstRecord.Status)
|
||||
assert.Equal(t, "revoked", secondRecord.Status)
|
||||
}
|
||||
|
||||
func TestProductionHardeningDuplicatePublishKeepsGatewayCacheCanonical(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
publisher, err := projectionpublisher.New(projectionpublisher.Config{
|
||||
Addr: env.redisAddr,
|
||||
DB: 0,
|
||||
SessionCacheKeyPrefix: gatewayCompatibilitySessionCacheKeyPrefix,
|
||||
SessionEventsStream: gatewayCompatibilitySessionEventsStream,
|
||||
StreamMaxLen: gatewayCompatibilityStreamMaxLen,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, publisher.Close())
|
||||
}()
|
||||
|
||||
snapshot := gatewayprojection.Snapshot{
|
||||
DeviceSessionID: common.DeviceSessionID("device-session-1"),
|
||||
UserID: common.UserID("user-1"),
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: gatewayprojection.StatusActive,
|
||||
}
|
||||
require.NoError(t, snapshot.Validate())
|
||||
|
||||
require.NoError(t, publisher.PublishSession(context.Background(), snapshot))
|
||||
require.NoError(t, publisher.PublishSession(context.Background(), snapshot))
|
||||
|
||||
record := env.MustReadGatewayCacheRecord(t, "device-session-1")
|
||||
assert.Equal(t, gatewayCacheRecord{
|
||||
DeviceSessionID: "device-session-1",
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "active",
|
||||
}, record)
|
||||
|
||||
events := env.MustReadGatewaySessionEvents(t, "device-session-1")
|
||||
require.Len(t, events, 2)
|
||||
assert.Equal(t, gatewaySessionEventRecord{
|
||||
DeviceSessionID: "device-session-1",
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "active",
|
||||
}, events[0])
|
||||
assert.Equal(t, events[0], events[1])
|
||||
}
|
||||
|
||||
func TestProductionHardeningExpiredChallengeReturnsExpiredDuringGraceAndNotFoundAfterGC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
app := newHardeningApp(t, env, hardeningAppOptions{})
|
||||
|
||||
hasher := testkit.DeterministicCodeHasher{}
|
||||
codeHash, err := hasher.Hash(gatewayCompatibilityCode)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := challenge.Challenge{
|
||||
ID: common.ChallengeID("challenge-expired"),
|
||||
Email: common.Email(gatewayCompatibilityEmail),
|
||||
CodeHash: codeHash,
|
||||
Status: challenge.StatusSent,
|
||||
DeliveryState: challenge.DeliverySent,
|
||||
CreatedAt: env.now.Add(-2 * time.Minute),
|
||||
ExpiresAt: env.now.Add(-time.Second),
|
||||
}
|
||||
require.NoError(t, record.Validate())
|
||||
require.NoError(t, app.challengeStore.Create(context.Background(), record))
|
||||
|
||||
firstConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": "challenge-expired",
|
||||
"code": gatewayCompatibilityCode,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusGone, firstConfirm.StatusCode)
|
||||
assert.JSONEq(t, `{"error":{"code":"challenge_expired","message":"challenge expired"}}`, firstConfirm.Body)
|
||||
|
||||
env.FastForward(t, 5*time.Minute+time.Second)
|
||||
|
||||
secondConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": "challenge-expired",
|
||||
"code": gatewayCompatibilityCode,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusNotFound, secondConfirm.StatusCode)
|
||||
assert.JSONEq(t, `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`, secondConfirm.Body)
|
||||
}
|
||||
|
||||
func TestProductionHardeningLargeUserSessionListAndRevokeAllStayStable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sessions := make([]devicesession.Session, 0, hardeningLargeSessionCount)
|
||||
for index := 0; index < hardeningLargeSessionCount; index++ {
|
||||
sessions = append(sessions, gatewayCompatibilityActiveSession(
|
||||
t,
|
||||
fmt.Sprintf("bulk-session-%03d", index+1),
|
||||
"user-1",
|
||||
gatewayCompatibilityClientPublicKey,
|
||||
time.Date(2026, 4, 5, 10, 0, index, 0, time.UTC),
|
||||
))
|
||||
}
|
||||
|
||||
env := newHardeningEnvironment(t)
|
||||
app := newHardeningApp(t, env, hardeningAppOptions{
|
||||
SeedExistingUser: true,
|
||||
SeedSessions: sessions,
|
||||
})
|
||||
|
||||
listResponse := hardeningGetJSON(t, app.internalBaseURL+"/api/v1/internal/users/user-1/sessions")
|
||||
assert.Equal(t, http.StatusOK, listResponse.StatusCode)
|
||||
|
||||
var listBody struct {
|
||||
Sessions []struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"sessions"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(listResponse.Body), &listBody))
|
||||
require.Len(t, listBody.Sessions, hardeningLargeSessionCount)
|
||||
assert.Equal(t, "bulk-session-256", listBody.Sessions[0].DeviceSessionID)
|
||||
assert.Equal(t, "bulk-session-001", listBody.Sessions[len(listBody.Sessions)-1].DeviceSessionID)
|
||||
for _, session := range listBody.Sessions {
|
||||
assert.Equal(t, "active", session.Status)
|
||||
}
|
||||
|
||||
revokeResponse := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
app.internalBaseURL+"/api/v1/internal/users/user-1/sessions/revoke-all",
|
||||
`{"reason_code":"logout_all","actor":{"type":"system"}}`,
|
||||
)
|
||||
assert.Equal(t, http.StatusOK, revokeResponse.StatusCode)
|
||||
|
||||
var revokeBody struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id"`
|
||||
AffectedSessionCount int `json:"affected_session_count"`
|
||||
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(revokeResponse.Body), &revokeBody))
|
||||
assert.Equal(t, "revoked", revokeBody.Outcome)
|
||||
assert.Equal(t, "user-1", revokeBody.UserID)
|
||||
assert.Equal(t, hardeningLargeSessionCount, revokeBody.AffectedSessionCount)
|
||||
require.Len(t, revokeBody.AffectedDeviceSessionIDs, hardeningLargeSessionCount)
|
||||
assert.Equal(t, "bulk-session-256", revokeBody.AffectedDeviceSessionIDs[0])
|
||||
assert.Equal(t, "bulk-session-001", revokeBody.AffectedDeviceSessionIDs[len(revokeBody.AffectedDeviceSessionIDs)-1])
|
||||
|
||||
activeCount, err := app.sessionStore.CountActiveByUserID(context.Background(), common.UserID("user-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, activeCount)
|
||||
}
|
||||
|
||||
// repeatHardeningError builds a stable FIFO error script for retry-oriented
|
||||
// publisher hardening tests.
|
||||
func repeatHardeningError(err error, count int) []error {
|
||||
script := make([]error, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
script = append(script, err)
|
||||
}
|
||||
|
||||
return script
|
||||
}
|
||||
Reference in New Issue
Block a user