728 lines
24 KiB
Go
728 lines
24 KiB
Go
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"
|
|
gatewayCompatibilityTimeZone = "Europe/Kaliningrad"
|
|
)
|
|
|
|
var gatewayCompatibilityClientPublicKey = mustGatewayCompatibilityClientPublicKeyBase64()
|
|
|
|
func gatewayCompatibilityConfirmRequest(challengeID string, code string, clientPublicKey string) map[string]string {
|
|
return map[string]string{
|
|
"challenge_id": challengeID,
|
|
"code": code,
|
|
"client_public_key": clientPublicKey,
|
|
"time_zone": gatewayCompatibilityTimeZone,
|
|
}
|
|
}
|
|
|
|
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",
|
|
gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, 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 := gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, 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",
|
|
gatewayCompatibilityConfirmRequest("challenge-1", attempts[0].Input.Code, 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","time_zone":"`+gatewayCompatibilityTimeZone+`"}`,
|
|
)
|
|
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(redisClient, challengestore.Config{
|
|
KeyPrefix: gatewayCompatibilityChallengeKeyPrefix,
|
|
OperationTimeout: 250 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sessionStore, err := sessionstore.New(redisClient, sessionstore.Config{
|
|
SessionKeyPrefix: gatewayCompatibilitySessionKeyPrefix,
|
|
UserSessionsKeyPrefix: gatewayCompatibilityUserSessionsKeyPrefix,
|
|
UserActiveSessionsKeyPrefix: gatewayCompatibilityUserActiveKeyPrefix,
|
|
OperationTimeout: 250 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
configStore, err := configprovider.New(redisClient, configprovider.Config{
|
|
SessionLimitKey: gatewayCompatibilitySessionLimitKey,
|
|
OperationTimeout: 250 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
publisher, err := projectionpublisher.New(redisClient, projectionpublisher.Config{
|
|
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.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",
|
|
gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, 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()
|
|
|
|
return gatewayCompatibilityPostJSONWithHeaders(t, url, body, nil)
|
|
}
|
|
|
|
func gatewayCompatibilityPostJSONWithHeaders(t *testing.T, url string, body string, headers map[string]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")
|
|
for key, value := range headers {
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
request.Header.Set(key, value)
|
|
}
|
|
|
|
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 gatewayCompatibilityPostJSONValueWithHeaders(t *testing.T, url string, value any, headers map[string]string) gatewayCompatibilityHTTPResponse {
|
|
t.Helper()
|
|
|
|
payload, err := json.Marshal(value)
|
|
require.NoError(t, err)
|
|
|
|
return gatewayCompatibilityPostJSONWithHeaders(t, url, string(payload), headers)
|
|
}
|
|
|
|
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
|
|
}
|