feat: authsession service
This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"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/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayCompatibilityChallengeKeyPrefix = "authsession:challenge:"
|
||||
gatewayCompatibilitySessionKeyPrefix = "authsession:session:"
|
||||
gatewayCompatibilityUserSessionsKeyPrefix = "authsession:user-sessions:"
|
||||
gatewayCompatibilityUserActiveKeyPrefix = "authsession:user-active-sessions:"
|
||||
gatewayCompatibilitySessionLimitKey = "authsession:config:active-session-limit"
|
||||
gatewayCompatibilitySessionCacheKeyPrefix = "gateway:session:"
|
||||
gatewayCompatibilitySessionEventsStream = "gateway:session_events"
|
||||
gatewayCompatibilityStreamMaxLen int64 = 128
|
||||
|
||||
gatewayCompatibilityEmail = "pilot@example.com"
|
||||
gatewayCompatibilityCode = "123456"
|
||||
)
|
||||
|
||||
var gatewayCompatibilityClientPublicKey = mustGatewayCompatibilityClientPublicKeyBase64()
|
||||
|
||||
func TestGatewayCompatibilityConfirmReturnsGatewayReadableSessionProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newGatewayCompatibilityHarness(t, gatewayCompatibilityOptions{})
|
||||
|
||||
sendResponse := gatewayCompatibilityPostJSON(t, app.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
|
||||
assert.Equal(t, http.StatusOK, sendResponse.StatusCode)
|
||||
|
||||
var sendBody struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(sendResponse.Body), &sendBody))
|
||||
assert.Equal(t, "challenge-1", sendBody.ChallengeID)
|
||||
|
||||
attempts := app.mailSender.RecordedAttempts()
|
||||
require.Len(t, attempts, 1)
|
||||
|
||||
confirmResponse := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": sendBody.ChallengeID,
|
||||
"code": attempts[0].Input.Code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, confirmResponse.StatusCode)
|
||||
|
||||
var confirmBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(confirmResponse.Body), &confirmBody))
|
||||
assert.Equal(t, "device-session-1", confirmBody.DeviceSessionID)
|
||||
|
||||
record := app.mustReadGatewayCacheRecord(t, confirmBody.DeviceSessionID)
|
||||
assert.Equal(t, gatewayCacheRecord{
|
||||
DeviceSessionID: "device-session-1",
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "active",
|
||||
}, record)
|
||||
|
||||
events := app.mustReadGatewaySessionEvents(t, confirmBody.DeviceSessionID)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, gatewaySessionEventRecord{
|
||||
DeviceSessionID: "device-session-1",
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "active",
|
||||
}, events[len(events)-1])
|
||||
}
|
||||
|
||||
func TestGatewayCompatibilityRevokePublishesRevokedGatewayProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newGatewayCompatibilityHarness(t, gatewayCompatibilityOptions{})
|
||||
|
||||
sessionID := app.createSessionThroughPublicFlow(t)
|
||||
|
||||
revokeResponse := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
app.internalBaseURL+"/api/v1/internal/sessions/"+sessionID+"/revoke",
|
||||
`{"reason_code":"admin_revoke","actor":{"type":"system"}}`,
|
||||
)
|
||||
assert.Equal(t, http.StatusOK, revokeResponse.StatusCode)
|
||||
assert.JSONEq(t, `{"outcome":"revoked","device_session_id":"`+sessionID+`","affected_session_count":1}`, revokeResponse.Body)
|
||||
|
||||
record := app.mustReadGatewayCacheRecord(t, sessionID)
|
||||
require.NotNil(t, record.RevokedAtMS)
|
||||
assert.Equal(t, gatewayCacheRecord{
|
||||
DeviceSessionID: sessionID,
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "revoked",
|
||||
RevokedAtMS: int64Pointer(app.now.UnixMilli()),
|
||||
}, record)
|
||||
|
||||
events := app.mustReadGatewaySessionEvents(t, sessionID)
|
||||
require.NotEmpty(t, events)
|
||||
last := events[len(events)-1]
|
||||
require.NotNil(t, last.RevokedAtMS)
|
||||
assert.Equal(t, gatewaySessionEventRecord{
|
||||
DeviceSessionID: sessionID,
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "revoked",
|
||||
RevokedAtMS: int64Pointer(app.now.UnixMilli()),
|
||||
}, last)
|
||||
}
|
||||
|
||||
func TestGatewayCompatibilityRepeatedConfirmReturnsSameSessionID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newGatewayCompatibilityHarness(t, gatewayCompatibilityOptions{})
|
||||
|
||||
sendResponse := gatewayCompatibilityPostJSON(t, app.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
|
||||
assert.Equal(t, http.StatusOK, sendResponse.StatusCode)
|
||||
|
||||
var sendBody struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(sendResponse.Body), &sendBody))
|
||||
|
||||
attempts := app.mailSender.RecordedAttempts()
|
||||
require.Len(t, attempts, 1)
|
||||
|
||||
requestBody := map[string]string{
|
||||
"challenge_id": sendBody.ChallengeID,
|
||||
"code": attempts[0].Input.Code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
}
|
||||
|
||||
first := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", requestBody)
|
||||
second := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", requestBody)
|
||||
assert.Equal(t, http.StatusOK, first.StatusCode)
|
||||
assert.Equal(t, http.StatusOK, second.StatusCode)
|
||||
|
||||
var firstBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
var secondBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(first.Body), &firstBody))
|
||||
require.NoError(t, json.Unmarshal([]byte(second.Body), &secondBody))
|
||||
assert.Equal(t, firstBody.DeviceSessionID, secondBody.DeviceSessionID)
|
||||
|
||||
record := app.mustReadGatewayCacheRecord(t, firstBody.DeviceSessionID)
|
||||
assert.Equal(t, gatewayCacheRecord{
|
||||
DeviceSessionID: firstBody.DeviceSessionID,
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: gatewayCompatibilityClientPublicKey,
|
||||
Status: "active",
|
||||
}, record)
|
||||
}
|
||||
|
||||
func TestGatewayCompatibilityBlockedEmailSendRemainsSuccessShaped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newGatewayCompatibilityHarness(t, gatewayCompatibilityOptions{
|
||||
SeedBlockedEmail: true,
|
||||
})
|
||||
|
||||
response := gatewayCompatibilityPostJSON(t, app.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
|
||||
assert.Equal(t, http.StatusOK, response.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal([]byte(response.Body), &body))
|
||||
assert.Equal(t, map[string]string{"challenge_id": "challenge-1"}, body)
|
||||
}
|
||||
|
||||
func TestGatewayCompatibilitySessionLimitExceededReturnsStableClientError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limit := 1
|
||||
app := newGatewayCompatibilityHarness(t, gatewayCompatibilityOptions{
|
||||
SeedExistingUser: true,
|
||||
SessionLimit: &limit,
|
||||
SeedActiveSessions: []devicesession.Session{
|
||||
gatewayCompatibilityActiveSession(
|
||||
t,
|
||||
"device-session-existing",
|
||||
"user-1",
|
||||
gatewayCompatibilityClientPublicKey,
|
||||
time.Date(2026, 4, 5, 11, 58, 0, 0, time.UTC),
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
sendResponse := gatewayCompatibilityPostJSON(t, app.publicBaseURL+"/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 := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": "challenge-1",
|
||||
"code": attempts[0].Input.Code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode)
|
||||
assert.JSONEq(t, `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, confirmResponse.Body)
|
||||
}
|
||||
|
||||
func TestGatewayCompatibilityMalformedClientPublicKeyReturnsStableError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := newGatewayCompatibilityHarness(t, gatewayCompatibilityOptions{})
|
||||
|
||||
response := gatewayCompatibilityPostJSON(
|
||||
t,
|
||||
app.publicBaseURL+"/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)
|
||||
}
|
||||
|
||||
type gatewayCompatibilityOptions struct {
|
||||
SeedBlockedEmail bool
|
||||
SeedExistingUser bool
|
||||
SessionLimit *int
|
||||
SeedActiveSessions []devicesession.Session
|
||||
}
|
||||
|
||||
// gatewayCompatibilityHarness owns one gateway-focused integration test setup
|
||||
// with real HTTP servers and real Redis-backed authsession adapters.
|
||||
type gatewayCompatibilityHarness struct {
|
||||
publicBaseURL string
|
||||
internalBaseURL string
|
||||
mailSender *mail.StubSender
|
||||
redisClient *redis.Client
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newGatewayCompatibilityHarness(t *testing.T, options gatewayCompatibilityOptions) gatewayCompatibilityHarness {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
|
||||
redisServer := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisServer.Addr(),
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, redisClient.Close())
|
||||
})
|
||||
|
||||
if options.SessionLimit != nil {
|
||||
redisServer.Set(gatewayCompatibilitySessionLimitKey, strconv.Itoa(*options.SessionLimit))
|
||||
}
|
||||
|
||||
challengeStore, err := challengestore.New(challengestore.Config{
|
||||
Addr: redisServer.Addr(),
|
||||
DB: 0,
|
||||
KeyPrefix: gatewayCompatibilityChallengeKeyPrefix,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, challengeStore.Close())
|
||||
})
|
||||
|
||||
sessionStore, err := sessionstore.New(sessionstore.Config{
|
||||
Addr: redisServer.Addr(),
|
||||
DB: 0,
|
||||
SessionKeyPrefix: gatewayCompatibilitySessionKeyPrefix,
|
||||
UserSessionsKeyPrefix: gatewayCompatibilityUserSessionsKeyPrefix,
|
||||
UserActiveSessionsKeyPrefix: gatewayCompatibilityUserActiveKeyPrefix,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, sessionStore.Close())
|
||||
})
|
||||
|
||||
configStore, err := configprovider.New(configprovider.Config{
|
||||
Addr: redisServer.Addr(),
|
||||
DB: 0,
|
||||
SessionLimitKey: gatewayCompatibilitySessionLimitKey,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, configStore.Close())
|
||||
})
|
||||
|
||||
publisher, err := projectionpublisher.New(projectionpublisher.Config{
|
||||
Addr: redisServer.Addr(),
|
||||
DB: 0,
|
||||
SessionCacheKeyPrefix: gatewayCompatibilitySessionCacheKeyPrefix,
|
||||
SessionEventsStream: gatewayCompatibilitySessionEventsStream,
|
||||
StreamMaxLen: gatewayCompatibilityStreamMaxLen,
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, publisher.Close())
|
||||
})
|
||||
|
||||
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.SeedActiveSessions {
|
||||
require.NoError(t, sessionStore.Create(context.Background(), session))
|
||||
}
|
||||
|
||||
mailSender := &mail.StubSender{}
|
||||
idGenerator := &testkit.SequenceIDGenerator{}
|
||||
codeGenerator := testkit.FixedCodeGenerator{Code: gatewayCompatibilityCode}
|
||||
codeHasher := testkit.DeterministicCodeHasher{}
|
||||
clock := testkit.FixedClock{Time: now}
|
||||
|
||||
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,
|
||||
configStore,
|
||||
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 gatewayCompatibilityHarness{
|
||||
publicBaseURL: "http://" + publicCfg.Addr,
|
||||
internalBaseURL: "http://" + internalCfg.Addr,
|
||||
mailSender: mailSender,
|
||||
redisClient: redisClient,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (h gatewayCompatibilityHarness) createSessionThroughPublicFlow(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
sendResponse := gatewayCompatibilityPostJSON(t, h.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
|
||||
assert.Equal(t, http.StatusOK, sendResponse.StatusCode)
|
||||
|
||||
var sendBody struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(sendResponse.Body), &sendBody))
|
||||
|
||||
attempts := h.mailSender.RecordedAttempts()
|
||||
require.Len(t, attempts, 1)
|
||||
|
||||
confirmResponse := gatewayCompatibilityPostJSONValue(t, h.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": sendBody.ChallengeID,
|
||||
"code": attempts[0].Input.Code,
|
||||
"client_public_key": gatewayCompatibilityClientPublicKey,
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, confirmResponse.StatusCode)
|
||||
|
||||
var confirmBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(confirmResponse.Body), &confirmBody))
|
||||
|
||||
return confirmBody.DeviceSessionID
|
||||
}
|
||||
|
||||
// gatewayCacheRecord mirrors the strict gateway Redis session-cache wire
|
||||
// contract used on the authenticated hot path.
|
||||
type gatewayCacheRecord struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ClientPublicKey string `json:"client_public_key"`
|
||||
Status string `json:"status"`
|
||||
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (h gatewayCompatibilityHarness) mustReadGatewayCacheRecord(t *testing.T, deviceSessionID string) gatewayCacheRecord {
|
||||
t.Helper()
|
||||
|
||||
payload, err := h.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.NotEmpty(t, record.DeviceSessionID)
|
||||
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
|
||||
}
|
||||
|
||||
// gatewaySessionEventRecord mirrors the strict gateway Redis Stream event
|
||||
// contract for full session snapshots.
|
||||
type gatewaySessionEventRecord struct {
|
||||
DeviceSessionID string
|
||||
UserID string
|
||||
ClientPublicKey string
|
||||
Status string
|
||||
RevokedAtMS *int64
|
||||
}
|
||||
|
||||
func (h gatewayCompatibilityHarness) mustReadGatewaySessionEvents(t *testing.T, deviceSessionID string) []gatewaySessionEventRecord {
|
||||
t.Helper()
|
||||
|
||||
entries, err := h.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
|
||||
}
|
||||
|
||||
func decodeGatewaySessionEvent(t *testing.T, values map[string]any) gatewaySessionEventRecord {
|
||||
t.Helper()
|
||||
|
||||
requiredKeys := map[string]struct{}{
|
||||
"device_session_id": {},
|
||||
"user_id": {},
|
||||
"client_public_key": {},
|
||||
"status": {},
|
||||
}
|
||||
optionalKeys := map[string]struct{}{
|
||||
"revoked_at_ms": {},
|
||||
}
|
||||
|
||||
for key := range values {
|
||||
if _, ok := requiredKeys[key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := optionalKeys[key]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "decode gateway session event: unsupported field %q", key)
|
||||
}
|
||||
|
||||
record := gatewaySessionEventRecord{
|
||||
DeviceSessionID: gatewayCompatibilityRequiredStringField(t, values, "device_session_id"),
|
||||
UserID: gatewayCompatibilityRequiredStringField(t, values, "user_id"),
|
||||
ClientPublicKey: gatewayCompatibilityRequiredStringField(t, values, "client_public_key"),
|
||||
Status: gatewayCompatibilityRequiredStringField(t, values, "status"),
|
||||
}
|
||||
require.Contains(t, []string{"active", "revoked"}, record.Status)
|
||||
|
||||
if rawRevokedAtMS, ok := values["revoked_at_ms"]; ok {
|
||||
parsed := gatewayCompatibilityParseInt64Field(t, rawRevokedAtMS, "revoked_at_ms")
|
||||
record.RevokedAtMS = &parsed
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func gatewayCompatibilityRequiredStringField(t *testing.T, values map[string]any, field string) string {
|
||||
t.Helper()
|
||||
|
||||
value, ok := values[field]
|
||||
require.Truef(t, ok, "decode gateway session event: missing %s", field)
|
||||
|
||||
stringValue := gatewayCompatibilityCoerceString(t, value, field)
|
||||
require.NotEmptyf(t, strings.TrimSpace(stringValue), "decode gateway session event: %s must not be empty", field)
|
||||
|
||||
return stringValue
|
||||
}
|
||||
|
||||
func gatewayCompatibilityParseInt64Field(t *testing.T, value any, field string) int64 {
|
||||
t.Helper()
|
||||
|
||||
stringValue := gatewayCompatibilityCoerceString(t, value, field)
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(stringValue), 10, 64)
|
||||
require.NoErrorf(t, err, "decode gateway session event: %s", field)
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
func gatewayCompatibilityCoerceString(t *testing.T, value any, field string) string {
|
||||
t.Helper()
|
||||
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case []byte:
|
||||
return string(typed)
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(typed, 10)
|
||||
default:
|
||||
require.Failf(t, "test failed", "decode gateway session event: %s: unsupported value type %T", field, value)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayCompatibilityRunServer(
|
||||
t *testing.T,
|
||||
run func(context.Context) error,
|
||||
shutdown func(context.Context) error,
|
||||
addr string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- run(context.Background())
|
||||
}()
|
||||
|
||||
gatewayCompatibilityWaitForTCP(t, addr)
|
||||
t.Cleanup(func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
assert.NoError(t, shutdown(shutdownCtx))
|
||||
assert.NoError(t, <-errCh)
|
||||
})
|
||||
}
|
||||
|
||||
func gatewayCompatibilityWaitForTCP(t *testing.T, addr string) {
|
||||
t.Helper()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
}
|
||||
|
||||
func gatewayCompatibilityFreeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, listener.Close())
|
||||
}()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
type gatewayCompatibilityHTTPResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func gatewayCompatibilityPostJSON(t *testing.T, url string, body string) gatewayCompatibilityHTTPResponse {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
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 gatewayCompatibilityPostJSONValue(t *testing.T, url string, value any) gatewayCompatibilityHTTPResponse {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return gatewayCompatibilityPostJSON(t, url, string(payload))
|
||||
}
|
||||
|
||||
func gatewayCompatibilityActiveSession(
|
||||
t *testing.T,
|
||||
deviceSessionID string,
|
||||
userID string,
|
||||
clientPublicKeyBase64 string,
|
||||
createdAt time.Time,
|
||||
) devicesession.Session {
|
||||
t.Helper()
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(clientPublicKeyBase64)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientPublicKey, err := common.NewClientPublicKey(ed25519.PublicKey(keyBytes))
|
||||
require.NoError(t, err)
|
||||
|
||||
session := devicesession.Session{
|
||||
ID: common.DeviceSessionID(deviceSessionID),
|
||||
UserID: common.UserID(userID),
|
||||
ClientPublicKey: clientPublicKey,
|
||||
Status: devicesession.StatusActive,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
require.NoError(t, session.Validate())
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func mustGatewayCompatibilityClientPublicKeyBase64() string {
|
||||
key := make([]byte, ed25519.PublicKeySize)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
func int64Pointer(value int64) *int64 {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user