924 lines
30 KiB
Go
924 lines
30 KiB
Go
package user
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/postgres"
|
|
"galaxy/user/internal/app"
|
|
"galaxy/user/internal/config"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/stretchr/testify/require"
|
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
)
|
|
|
|
type runtimeContractHarness struct {
|
|
baseURL string
|
|
client *http.Client
|
|
|
|
runtime *app.Runtime
|
|
cancel context.CancelFunc
|
|
runErr chan error
|
|
redisServer *miniredis.Miniredis
|
|
}
|
|
|
|
func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
|
|
t.Helper()
|
|
|
|
redisServer := miniredis.RunT(t)
|
|
redisServer.RequireAuth("integration")
|
|
|
|
pgDSN := startPostgresForContractTest(t)
|
|
|
|
cfg := config.DefaultConfig()
|
|
cfg.Redis.Conn.MasterAddr = redisServer.Addr()
|
|
cfg.Redis.Conn.Password = "integration"
|
|
cfg.Postgres.Conn.PrimaryDSN = pgDSN
|
|
cfg.InternalHTTP.Addr = freeLoopbackAddress(t)
|
|
cfg.AdminHTTP.Addr = ""
|
|
cfg.ShutdownTimeout = 10 * time.Second
|
|
cfg.Telemetry.TracesExporter = "none"
|
|
cfg.Telemetry.MetricsExporter = "none"
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
runtime, err := app.NewRuntime(context.Background(), cfg, logger)
|
|
require.NoError(t, err)
|
|
|
|
runCtx, cancel := context.WithCancel(context.Background())
|
|
runErr := make(chan error, 1)
|
|
go func() {
|
|
runErr <- runtime.Run(runCtx)
|
|
}()
|
|
|
|
client := &http.Client{
|
|
Timeout: 500 * time.Millisecond,
|
|
Transport: &http.Transport{
|
|
DisableKeepAlives: true,
|
|
},
|
|
}
|
|
|
|
harness := &runtimeContractHarness{
|
|
baseURL: "http://" + cfg.InternalHTTP.Addr,
|
|
client: client,
|
|
runtime: runtime,
|
|
cancel: cancel,
|
|
runErr: runErr,
|
|
redisServer: redisServer,
|
|
}
|
|
harness.waitUntilReady(t)
|
|
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
select {
|
|
case err := <-runErr:
|
|
require.NoError(t, err)
|
|
case <-time.After(cfg.ShutdownTimeout + 2*time.Second):
|
|
t.Fatalf("runtime did not stop in time")
|
|
}
|
|
require.NoError(t, runtime.Close())
|
|
client.CloseIdleConnections()
|
|
})
|
|
|
|
return harness
|
|
}
|
|
|
|
func (h *runtimeContractHarness) waitUntilReady(t *testing.T) {
|
|
t.Helper()
|
|
|
|
require.Eventually(t, func() bool {
|
|
request, err := http.NewRequest(http.MethodGet, h.baseURL+"/api/v1/internal/users/user-missing/exists", nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
response, err := h.client.Do(request)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer response.Body.Close()
|
|
_, _ = io.Copy(io.Discard, response.Body)
|
|
|
|
return response.StatusCode == http.StatusOK
|
|
}, 5*time.Second, 25*time.Millisecond, "user runtime did not become reachable")
|
|
}
|
|
|
|
func (h *runtimeContractHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
|
|
t.Helper()
|
|
|
|
response := h.postJSON(t, "/api/v1/internal/users/ensure-by-email", map[string]any{
|
|
"email": email,
|
|
"registration_context": map[string]string{
|
|
"preferred_language": preferredLanguage,
|
|
"time_zone": timeZone,
|
|
},
|
|
})
|
|
|
|
var body ensureByEmailResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
return body
|
|
}
|
|
|
|
func (h *runtimeContractHarness) getMyAccount(t *testing.T, userID string) accountResponse {
|
|
t.Helper()
|
|
|
|
response := h.get(t, "/api/v1/internal/users/"+userID+"/account")
|
|
var body accountResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
return body
|
|
}
|
|
|
|
func (h *runtimeContractHarness) currentEntitlementStartsAt(t *testing.T, userID string) time.Time {
|
|
t.Helper()
|
|
|
|
return h.getMyAccount(t, userID).Account.Entitlement.StartsAt
|
|
}
|
|
|
|
func (h *runtimeContractHarness) updateSettingsRaw(t *testing.T, userID string, body string) httpResponse {
|
|
t.Helper()
|
|
return h.postRawJSON(t, "/api/v1/internal/users/"+userID+"/settings", body)
|
|
}
|
|
|
|
func (h *runtimeContractHarness) getEligibility(t *testing.T, userID string) eligibilityResponse {
|
|
t.Helper()
|
|
|
|
response := h.get(t, "/api/v1/internal/users/"+userID+"/eligibility")
|
|
var body eligibilityResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
return body
|
|
}
|
|
|
|
func (h *runtimeContractHarness) syncDeclaredCountry(t *testing.T, userID string, country string) declaredCountrySyncResponse {
|
|
t.Helper()
|
|
|
|
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/declared-country/sync", map[string]string{
|
|
"declared_country": country,
|
|
})
|
|
var body declaredCountrySyncResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
return body
|
|
}
|
|
|
|
func (h *runtimeContractHarness) lookupUserByEmail(t *testing.T, email string) userLookupResponse {
|
|
t.Helper()
|
|
|
|
response := h.postJSON(t, "/api/v1/internal/user-lookups/by-email", map[string]string{
|
|
"email": email,
|
|
})
|
|
var body userLookupResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
return body
|
|
}
|
|
|
|
func (h *runtimeContractHarness) grantPaidEntitlement(t *testing.T, userID string, startsAt time.Time, endsAt time.Time) {
|
|
t.Helper()
|
|
|
|
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/entitlements/grant", map[string]any{
|
|
"plan_code": "paid_monthly",
|
|
"source": "admin",
|
|
"reason_code": "manual_grant",
|
|
"actor": map[string]string{
|
|
"type": "admin",
|
|
"id": "admin-1",
|
|
},
|
|
"starts_at": startsAt.UTC().Format(time.RFC3339Nano),
|
|
"ends_at": endsAt.UTC().Format(time.RFC3339Nano),
|
|
})
|
|
var body entitlementCommandResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
}
|
|
|
|
func (h *runtimeContractHarness) applySanction(t *testing.T, userID string, sanctionCode string, scope string, appliedAt time.Time) {
|
|
t.Helper()
|
|
|
|
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{
|
|
"sanction_code": sanctionCode,
|
|
"scope": scope,
|
|
"reason_code": "manual_block",
|
|
"actor": map[string]string{
|
|
"type": "admin",
|
|
"id": "admin-1",
|
|
},
|
|
"applied_at": appliedAt.UTC().Format(time.RFC3339Nano),
|
|
})
|
|
var body sanctionCommandResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
}
|
|
|
|
func (h *runtimeContractHarness) setLimit(t *testing.T, userID string, limitCode string, value int, appliedAt time.Time) {
|
|
t.Helper()
|
|
|
|
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/limits/set", map[string]any{
|
|
"limit_code": limitCode,
|
|
"value": value,
|
|
"reason_code": "manual_override",
|
|
"actor": map[string]string{
|
|
"type": "admin",
|
|
"id": "admin-1",
|
|
},
|
|
"applied_at": appliedAt.UTC().Format(time.RFC3339Nano),
|
|
})
|
|
var body limitCommandResponse
|
|
requireResponseJSON(t, response, http.StatusOK, &body)
|
|
}
|
|
|
|
func (h *runtimeContractHarness) deleteUser(t *testing.T, userID string, reasonCode string) httpResponse {
|
|
t.Helper()
|
|
|
|
return h.postJSON(t, "/api/v1/internal/users/"+userID+"/delete", map[string]any{
|
|
"reason_code": reasonCode,
|
|
"actor": map[string]string{
|
|
"type": "admin",
|
|
"id": "admin-1",
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *runtimeContractHarness) lifecycleStreamEntries(t *testing.T) []map[string]string {
|
|
t.Helper()
|
|
|
|
stream, err := h.redisServer.Stream("user:lifecycle_events")
|
|
require.NoError(t, err)
|
|
entries := make([]map[string]string, 0, len(stream))
|
|
for _, entry := range stream {
|
|
require.Equal(t, 0, len(entry.Values)%2, "stream entry values must come in key/value pairs")
|
|
values := make(map[string]string, len(entry.Values)/2)
|
|
for index := 0; index < len(entry.Values); index += 2 {
|
|
values[entry.Values[index]] = entry.Values[index+1]
|
|
}
|
|
entries = append(entries, values)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func (h *runtimeContractHarness) listUsers(t *testing.T, rawQuery string) httpResponse {
|
|
t.Helper()
|
|
|
|
path := "/api/v1/internal/users"
|
|
if rawQuery != "" {
|
|
path += "?" + rawQuery
|
|
}
|
|
return h.get(t, path)
|
|
}
|
|
|
|
func (h *runtimeContractHarness) get(t *testing.T, path string) httpResponse {
|
|
t.Helper()
|
|
|
|
request, err := http.NewRequest(http.MethodGet, h.baseURL+path, nil)
|
|
require.NoError(t, err)
|
|
|
|
response, err := h.client.Do(request)
|
|
require.NoError(t, err)
|
|
defer response.Body.Close()
|
|
|
|
body, err := io.ReadAll(response.Body)
|
|
require.NoError(t, err)
|
|
|
|
return httpResponse{
|
|
StatusCode: response.StatusCode,
|
|
Body: string(body),
|
|
Header: response.Header.Clone(),
|
|
}
|
|
}
|
|
|
|
func (h *runtimeContractHarness) postJSON(t *testing.T, path string, body any) httpResponse {
|
|
t.Helper()
|
|
|
|
payload, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
return h.postRawJSON(t, path, string(payload))
|
|
}
|
|
|
|
func (h *runtimeContractHarness) postRawJSON(t *testing.T, path string, body string) httpResponse {
|
|
t.Helper()
|
|
|
|
request, err := http.NewRequest(http.MethodPost, h.baseURL+path, bytes.NewBufferString(body))
|
|
require.NoError(t, err)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
response, err := h.client.Do(request)
|
|
require.NoError(t, err)
|
|
defer response.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(response.Body)
|
|
require.NoError(t, err)
|
|
|
|
return httpResponse{
|
|
StatusCode: response.StatusCode,
|
|
Body: string(responseBody),
|
|
Header: response.Header.Clone(),
|
|
}
|
|
}
|
|
|
|
func TestRuntimeContractGetMyAccountReturnsAggregateAndDeclaredCountryStaysReadOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
created := h.ensureUser(t, "pilot@example.com", "en", "Europe/Kaliningrad")
|
|
require.Equal(t, "created", created.Outcome)
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
h.grantPaidEntitlement(t, created.UserID, h.currentEntitlementStartsAt(t, created.UserID), now.Add(48*time.Hour))
|
|
h.applySanction(t, created.UserID, "login_block", "auth", now.Add(-30*time.Minute))
|
|
h.setLimit(t, created.UserID, "max_owned_private_games", 7, now.Add(-20*time.Minute))
|
|
syncResult := h.syncDeclaredCountry(t, created.UserID, "DE")
|
|
|
|
account := h.getMyAccount(t, created.UserID)
|
|
require.Equal(t, created.UserID, account.Account.UserID)
|
|
require.Equal(t, "pilot@example.com", account.Account.Email)
|
|
require.Equal(t, "en", account.Account.PreferredLanguage)
|
|
require.Equal(t, "Europe/Kaliningrad", account.Account.TimeZone)
|
|
require.Equal(t, "DE", account.Account.DeclaredCountry)
|
|
require.Equal(t, syncResult.UpdatedAt, account.Account.UpdatedAt)
|
|
require.Equal(t, "paid_monthly", account.Account.Entitlement.PlanCode)
|
|
require.True(t, account.Account.Entitlement.IsPaid)
|
|
require.Len(t, account.Account.ActiveSanctions, 1)
|
|
require.Equal(t, "login_block", account.Account.ActiveSanctions[0].SanctionCode)
|
|
require.Len(t, account.Account.ActiveLimits, 1)
|
|
require.Equal(t, "max_owned_private_games", account.Account.ActiveLimits[0].LimitCode)
|
|
require.Equal(t, 7, account.Account.ActiveLimits[0].Value)
|
|
|
|
response := h.updateSettingsRaw(t, created.UserID, `{"preferred_language":"en","time_zone":"UTC","declared_country":"FR"}`)
|
|
requireJSONBody(t, response, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body contains unknown field \"declared_country\""}}`)
|
|
}
|
|
|
|
func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
|
|
unknown := h.getEligibility(t, "user-missing")
|
|
require.False(t, unknown.Exists)
|
|
require.Equal(t, "user-missing", unknown.UserID)
|
|
require.Nil(t, unknown.Entitlement)
|
|
require.Empty(t, unknown.ActiveSanctions)
|
|
require.Empty(t, unknown.EffectiveLimits)
|
|
require.Equal(t, eligibilityMarkers{}, unknown.Markers)
|
|
|
|
freeUser := h.ensureUser(t, "free@example.com", "en", "UTC")
|
|
require.Equal(t, "created", freeUser.Outcome)
|
|
|
|
free := h.getEligibility(t, freeUser.UserID)
|
|
require.True(t, free.Exists)
|
|
require.NotNil(t, free.Entitlement)
|
|
require.Equal(t, "free", free.Entitlement.PlanCode)
|
|
require.False(t, free.Entitlement.IsPaid)
|
|
require.Equal(t, eligibilityMarkers{
|
|
CanLogin: true,
|
|
CanCreatePrivateGame: false,
|
|
CanManagePrivateGame: false,
|
|
CanJoinGame: true,
|
|
CanUpdateProfile: true,
|
|
}, free.Markers)
|
|
require.Equal(t, []effectiveLimitView{
|
|
{LimitCode: "max_pending_public_applications", Value: 3},
|
|
{LimitCode: "max_active_game_memberships", Value: 3},
|
|
{LimitCode: "max_registered_race_names", Value: 1},
|
|
}, free.EffectiveLimits)
|
|
|
|
paidUser := h.ensureUser(t, "paid@example.com", "en", "Europe/Paris")
|
|
require.Equal(t, "created", paidUser.Outcome)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
h.grantPaidEntitlement(t, paidUser.UserID, h.currentEntitlementStartsAt(t, paidUser.UserID), now.Add(72*time.Hour))
|
|
h.applySanction(t, paidUser.UserID, "private_game_manage_block", "lobby", now.Add(-30*time.Minute))
|
|
h.setLimit(t, paidUser.UserID, "max_pending_public_applications", 17, now.Add(-20*time.Minute))
|
|
|
|
paid := h.getEligibility(t, paidUser.UserID)
|
|
require.True(t, paid.Exists)
|
|
require.NotNil(t, paid.Entitlement)
|
|
require.Equal(t, "paid_monthly", paid.Entitlement.PlanCode)
|
|
require.True(t, paid.Entitlement.IsPaid)
|
|
require.Len(t, paid.ActiveSanctions, 1)
|
|
require.Equal(t, "private_game_manage_block", paid.ActiveSanctions[0].SanctionCode)
|
|
require.Equal(t, eligibilityMarkers{
|
|
CanLogin: true,
|
|
CanCreatePrivateGame: true,
|
|
CanManagePrivateGame: false,
|
|
CanJoinGame: true,
|
|
CanUpdateProfile: true,
|
|
}, paid.Markers)
|
|
require.Equal(t, []effectiveLimitView{
|
|
{LimitCode: "max_owned_private_games", Value: 3},
|
|
{LimitCode: "max_pending_public_applications", Value: 17},
|
|
{LimitCode: "max_active_game_memberships", Value: 10},
|
|
{LimitCode: "max_registered_race_names", Value: 2},
|
|
}, paid.EffectiveLimits)
|
|
}
|
|
|
|
func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
created := h.ensureUser(t, "geo@example.com", "en", "Europe/Berlin")
|
|
require.Equal(t, "created", created.Outcome)
|
|
|
|
before := h.lookupUserByEmail(t, "geo@example.com")
|
|
require.Empty(t, before.User.DeclaredCountry)
|
|
|
|
first := h.syncDeclaredCountry(t, created.UserID, "DE")
|
|
after := h.lookupUserByEmail(t, "geo@example.com")
|
|
require.Equal(t, before.User.UserID, after.User.UserID)
|
|
require.Equal(t, before.User.Email, after.User.Email)
|
|
require.Equal(t, before.User.UserName, after.User.UserName)
|
|
require.Equal(t, before.User.DisplayName, after.User.DisplayName)
|
|
require.Equal(t, before.User.PreferredLanguage, after.User.PreferredLanguage)
|
|
require.Equal(t, before.User.TimeZone, after.User.TimeZone)
|
|
require.Equal(t, before.User.Entitlement, after.User.Entitlement)
|
|
require.Equal(t, before.User.ActiveSanctions, after.User.ActiveSanctions)
|
|
require.Equal(t, before.User.ActiveLimits, after.User.ActiveLimits)
|
|
require.Equal(t, "DE", after.User.DeclaredCountry)
|
|
require.Equal(t, first.UpdatedAt, after.User.UpdatedAt)
|
|
|
|
second := h.syncDeclaredCountry(t, created.UserID, "DE")
|
|
require.Equal(t, first.UpdatedAt, second.UpdatedAt)
|
|
|
|
repeated := h.lookupUserByEmail(t, "geo@example.com")
|
|
require.Equal(t, after.User, repeated.User)
|
|
}
|
|
|
|
func TestRuntimeContractPermanentBlockCollapsesEligibilityMarkers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
created := h.ensureUser(t, "blocked@example.com", "en", "UTC")
|
|
require.Equal(t, "created", created.Outcome)
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
h.grantPaidEntitlement(t, created.UserID, h.currentEntitlementStartsAt(t, created.UserID), now.Add(72*time.Hour))
|
|
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-5*time.Minute))
|
|
|
|
eligibility := h.getEligibility(t, created.UserID)
|
|
require.True(t, eligibility.Exists)
|
|
require.Equal(t, eligibilityMarkers{}, eligibility.Markers,
|
|
"every can_* marker must be false under permanent_block")
|
|
|
|
var permanentBlockSeen bool
|
|
for _, sanction := range eligibility.ActiveSanctions {
|
|
if sanction.SanctionCode == "permanent_block" {
|
|
permanentBlockSeen = true
|
|
}
|
|
}
|
|
require.True(t, permanentBlockSeen,
|
|
"permanent_block must surface in the lobby eligibility snapshot")
|
|
}
|
|
|
|
func TestRuntimeContractPermanentBlockBlocksSelfService(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
created := h.ensureUser(t, "self-blocked@example.com", "en", "UTC")
|
|
require.Equal(t, "created", created.Outcome)
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-time.Minute))
|
|
|
|
readResponse := h.get(t, "/api/v1/internal/users/"+created.UserID+"/account")
|
|
requireJSONBody(t, readResponse, http.StatusConflict,
|
|
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
|
|
|
|
profileResponse := h.postJSON(t, "/api/v1/internal/users/"+created.UserID+"/profile", map[string]string{
|
|
"display_name": "Nova",
|
|
})
|
|
requireJSONBody(t, profileResponse, http.StatusConflict,
|
|
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
|
|
|
|
settingsResponse := h.postJSON(t, "/api/v1/internal/users/"+created.UserID+"/settings", map[string]string{
|
|
"preferred_language": "en",
|
|
"time_zone": "UTC",
|
|
})
|
|
requireJSONBody(t, settingsResponse, http.StatusConflict,
|
|
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
|
|
}
|
|
|
|
func TestRuntimeContractPermanentBlockEmitsLifecycleEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
created := h.ensureUser(t, "lifecycle-block@example.com", "en", "UTC")
|
|
require.Equal(t, "created", created.Outcome)
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-time.Minute))
|
|
|
|
entries := h.lifecycleStreamEntries(t)
|
|
require.Len(t, entries, 1)
|
|
require.Equal(t, "user.lifecycle.permanent_blocked", entries[0]["event_type"])
|
|
require.Equal(t, created.UserID, entries[0]["user_id"])
|
|
require.Equal(t, "admin_internal_api", entries[0]["source"])
|
|
require.Equal(t, "admin", entries[0]["actor_type"])
|
|
require.Equal(t, "admin-1", entries[0]["actor_id"])
|
|
require.Equal(t, "manual_block", entries[0]["reason_code"])
|
|
}
|
|
|
|
func TestRuntimeContractDeleteUserIsIdempotentAndEmitsLifecycleEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
created := h.ensureUser(t, "delete@example.com", "en", "UTC")
|
|
require.Equal(t, "created", created.Outcome)
|
|
|
|
firstResponse := h.deleteUser(t, created.UserID, "user_right_to_be_forgotten")
|
|
require.Equal(t, http.StatusOK, firstResponse.StatusCode, "response body: %s", firstResponse.Body)
|
|
|
|
var firstBody struct {
|
|
UserID string `json:"user_id"`
|
|
DeletedAt time.Time `json:"deleted_at"`
|
|
}
|
|
require.NoError(t, decodeStrictJSONPayload([]byte(firstResponse.Body), &firstBody))
|
|
require.Equal(t, created.UserID, firstBody.UserID)
|
|
require.False(t, firstBody.DeletedAt.IsZero())
|
|
|
|
entries := h.lifecycleStreamEntries(t)
|
|
require.Len(t, entries, 1)
|
|
require.Equal(t, "user.lifecycle.deleted", entries[0]["event_type"])
|
|
require.Equal(t, created.UserID, entries[0]["user_id"])
|
|
|
|
secondResponse := h.deleteUser(t, created.UserID, "user_right_to_be_forgotten")
|
|
requireJSONBody(t, secondResponse, http.StatusNotFound,
|
|
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
|
|
|
entriesAfterSecond := h.lifecycleStreamEntries(t)
|
|
require.Len(t, entriesAfterSecond, 1,
|
|
"second DeleteUser call must not re-emit a lifecycle event")
|
|
|
|
eligibility := h.getEligibility(t, created.UserID)
|
|
require.False(t, eligibility.Exists)
|
|
|
|
accountResponse := h.get(t, "/api/v1/internal/users/"+created.UserID+"/account")
|
|
requireJSONBody(t, accountResponse, http.StatusNotFound,
|
|
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
|
|
|
lookupResponse := h.lookupUserByEmailRaw(t, "delete@example.com")
|
|
requireJSONBody(t, lookupResponse, http.StatusNotFound,
|
|
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
|
}
|
|
|
|
func (h *runtimeContractHarness) lookupUserByEmailRaw(t *testing.T, email string) httpResponse {
|
|
t.Helper()
|
|
|
|
return h.postJSON(t, "/api/v1/internal/user-lookups/by-email", map[string]string{
|
|
"email": email,
|
|
})
|
|
}
|
|
|
|
func TestRuntimeContractAdminListingPreservesOrderingFiltersAndPageTokenBinding(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newRuntimeContractHarness(t)
|
|
|
|
filtered := h.ensureUser(t, "filter@example.com", "en", "UTC")
|
|
require.Equal(t, "created", filtered.Outcome)
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
latest := h.ensureUser(t, "latest@example.com", "en", "UTC")
|
|
require.Equal(t, "created", latest.Outcome)
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
h.grantPaidEntitlement(t, filtered.UserID, h.currentEntitlementStartsAt(t, filtered.UserID), now.Add(48*time.Hour))
|
|
h.syncDeclaredCountry(t, filtered.UserID, "DE")
|
|
h.applySanction(t, filtered.UserID, "login_block", "auth", now.Add(-30*time.Minute))
|
|
h.setLimit(t, filtered.UserID, "max_owned_private_games", 5, now.Add(-20*time.Minute))
|
|
|
|
firstPageResponse := h.listUsers(t, "page_size=1")
|
|
var firstPage userListResponse
|
|
requireResponseJSON(t, firstPageResponse, http.StatusOK, &firstPage)
|
|
require.Len(t, firstPage.Items, 1)
|
|
require.Equal(t, latest.UserID, firstPage.Items[0].UserID)
|
|
require.NotEmpty(t, firstPage.NextPageToken)
|
|
|
|
mismatchResponse := h.listUsers(t, "page_size=1&page_token="+firstPage.NextPageToken+"&paid_state=paid")
|
|
requireJSONBody(t, mismatchResponse, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`)
|
|
|
|
filteredResponse := h.listUsers(
|
|
t,
|
|
"paid_state=paid"+
|
|
"&paid_expires_after="+now.Add(time.Hour).Format(time.RFC3339)+
|
|
"&paid_expires_before="+now.Add(72*time.Hour).Format(time.RFC3339)+
|
|
"&declared_country=DE"+
|
|
"&sanction_code=login_block"+
|
|
"&limit_code=max_owned_private_games"+
|
|
"&can_login=false"+
|
|
"&can_create_private_game=false"+
|
|
"&can_join_game=false",
|
|
)
|
|
|
|
var filteredBody userListResponse
|
|
requireResponseJSON(t, filteredResponse, http.StatusOK, &filteredBody)
|
|
require.Len(t, filteredBody.Items, 1)
|
|
require.Equal(t, filtered.UserID, filteredBody.Items[0].UserID)
|
|
require.Equal(t, "DE", filteredBody.Items[0].DeclaredCountry)
|
|
require.Equal(t, "paid_monthly", filteredBody.Items[0].Entitlement.PlanCode)
|
|
}
|
|
|
|
type httpResponse struct {
|
|
StatusCode int
|
|
Body string
|
|
Header http.Header
|
|
}
|
|
|
|
type errorEnvelope struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
type ensureByEmailResponse struct {
|
|
Outcome string `json:"outcome"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
}
|
|
|
|
type accountResponse struct {
|
|
Account accountView `json:"account"`
|
|
}
|
|
|
|
type userLookupResponse struct {
|
|
User accountView `json:"user"`
|
|
}
|
|
|
|
type userListResponse struct {
|
|
Items []accountView `json:"items"`
|
|
NextPageToken string `json:"next_page_token,omitempty"`
|
|
}
|
|
|
|
type accountView struct {
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
UserName string `json:"user_name"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
DeclaredCountry string `json:"declared_country,omitempty"`
|
|
Entitlement entitlementSnapshotView `json:"entitlement"`
|
|
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
|
|
ActiveLimits []activeLimitView `json:"active_limits"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type entitlementSnapshotView struct {
|
|
PlanCode string `json:"plan_code"`
|
|
IsPaid bool `json:"is_paid"`
|
|
Source string `json:"source"`
|
|
Actor actorRefView `json:"actor"`
|
|
ReasonCode string `json:"reason_code"`
|
|
StartsAt time.Time `json:"starts_at"`
|
|
EndsAt *time.Time `json:"ends_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type activeSanctionView struct {
|
|
SanctionCode string `json:"sanction_code"`
|
|
Scope string `json:"scope"`
|
|
ReasonCode string `json:"reason_code"`
|
|
Actor actorRefView `json:"actor"`
|
|
AppliedAt time.Time `json:"applied_at"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type activeLimitView struct {
|
|
LimitCode string `json:"limit_code"`
|
|
Value int `json:"value"`
|
|
ReasonCode string `json:"reason_code"`
|
|
Actor actorRefView `json:"actor"`
|
|
AppliedAt time.Time `json:"applied_at"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type actorRefView struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id,omitempty"`
|
|
}
|
|
|
|
type eligibilityResponse struct {
|
|
Exists bool `json:"exists"`
|
|
UserID string `json:"user_id"`
|
|
Entitlement *entitlementSnapshotView `json:"entitlement,omitempty"`
|
|
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
|
|
EffectiveLimits []effectiveLimitView `json:"effective_limits"`
|
|
Markers eligibilityMarkers `json:"markers"`
|
|
}
|
|
|
|
type effectiveLimitView struct {
|
|
LimitCode string `json:"limit_code"`
|
|
Value int `json:"value"`
|
|
}
|
|
|
|
type eligibilityMarkers struct {
|
|
CanLogin bool `json:"can_login"`
|
|
CanCreatePrivateGame bool `json:"can_create_private_game"`
|
|
CanManagePrivateGame bool `json:"can_manage_private_game"`
|
|
CanJoinGame bool `json:"can_join_game"`
|
|
CanUpdateProfile bool `json:"can_update_profile"`
|
|
}
|
|
|
|
type declaredCountrySyncResponse struct {
|
|
UserID string `json:"user_id"`
|
|
DeclaredCountry string `json:"declared_country"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type entitlementCommandResponse struct {
|
|
UserID string `json:"user_id"`
|
|
Entitlement entitlementSnapshotView `json:"entitlement"`
|
|
}
|
|
|
|
type sanctionCommandResponse struct {
|
|
UserID string `json:"user_id"`
|
|
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
|
|
}
|
|
|
|
type limitCommandResponse struct {
|
|
UserID string `json:"user_id"`
|
|
ActiveLimits []activeLimitView `json:"active_limits"`
|
|
}
|
|
|
|
func requireResponseJSON(t *testing.T, response httpResponse, wantStatus int, target any) {
|
|
t.Helper()
|
|
|
|
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
|
|
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
|
|
}
|
|
|
|
func requireJSONBody(t *testing.T, response httpResponse, wantStatus int, wantBody string) {
|
|
t.Helper()
|
|
|
|
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
|
|
require.JSONEq(t, wantBody, response.Body)
|
|
}
|
|
|
|
func decodeStrictJSONPayload(payload []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
return errors.New("unexpected trailing JSON input")
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func freeLoopbackAddress(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
defer listener.Close()
|
|
|
|
return listener.Addr().String()
|
|
}
|
|
|
|
func (view entitlementSnapshotView) Equal(other entitlementSnapshotView) bool {
|
|
return view.PlanCode == other.PlanCode &&
|
|
view.IsPaid == other.IsPaid &&
|
|
view.Source == other.Source &&
|
|
view.ReasonCode == other.ReasonCode &&
|
|
view.StartsAt.Equal(other.StartsAt) &&
|
|
optionalTimeEqual(view.EndsAt, other.EndsAt) &&
|
|
view.UpdatedAt.Equal(other.UpdatedAt)
|
|
}
|
|
|
|
func optionalTimeEqual(left *time.Time, right *time.Time) bool {
|
|
switch {
|
|
case left == nil && right == nil:
|
|
return true
|
|
case left == nil || right == nil:
|
|
return false
|
|
default:
|
|
return left.Equal(*right)
|
|
}
|
|
}
|
|
|
|
func TestEntitlementSnapshotViewEqual(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC()
|
|
next := now.Add(time.Hour)
|
|
require.True(t, entitlementSnapshotView{
|
|
PlanCode: "free",
|
|
IsPaid: false,
|
|
Source: "auth_registration",
|
|
ReasonCode: "initial_free_entitlement",
|
|
StartsAt: now,
|
|
UpdatedAt: now,
|
|
}.Equal(entitlementSnapshotView{
|
|
PlanCode: "free",
|
|
IsPaid: false,
|
|
Source: "auth_registration",
|
|
ReasonCode: "initial_free_entitlement",
|
|
StartsAt: now,
|
|
UpdatedAt: now,
|
|
}))
|
|
require.False(t, entitlementSnapshotView{
|
|
PlanCode: "paid_monthly",
|
|
IsPaid: true,
|
|
Source: "admin",
|
|
ReasonCode: "manual_grant",
|
|
StartsAt: now,
|
|
EndsAt: &next,
|
|
UpdatedAt: now,
|
|
}.Equal(entitlementSnapshotView{
|
|
PlanCode: "paid_monthly",
|
|
IsPaid: true,
|
|
Source: "admin",
|
|
ReasonCode: "manual_grant",
|
|
StartsAt: now,
|
|
UpdatedAt: now,
|
|
}))
|
|
}
|
|
|
|
func TestEligibilityUnknownMarkersZeroValueMatchesContract(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Equal(t, eligibilityMarkers{}, eligibilityMarkers{})
|
|
require.False(t, strings.HasPrefix("", "user-"))
|
|
}
|
|
|
|
// startPostgresForContractTest boots one isolated PostgreSQL container,
|
|
// provisions the user schema with the userservice role, and returns a DSN
|
|
// pinned to search_path=user. The test is skipped (not failed) when a
|
|
// container cannot be started — typically because Docker is unavailable in
|
|
// the dev environment.
|
|
func startPostgresForContractTest(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
container, err := tcpostgres.Run(ctx,
|
|
"postgres:16-alpine",
|
|
tcpostgres.WithDatabase("galaxy_user"),
|
|
tcpostgres.WithUsername("galaxy"),
|
|
tcpostgres.WithPassword("galaxy"),
|
|
testcontainers.WithWaitStrategy(
|
|
wait.ForLog("database system is ready to accept connections").
|
|
WithOccurrence(2).
|
|
WithStartupTimeout(60*time.Second),
|
|
),
|
|
)
|
|
if err != nil {
|
|
t.Skipf("postgres container start failed (Docker likely unavailable): %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := testcontainers.TerminateContainer(container); err != nil {
|
|
t.Errorf("terminate postgres container: %v", err)
|
|
}
|
|
})
|
|
|
|
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
|
require.NoError(t, err)
|
|
|
|
cfg := postgres.DefaultConfig()
|
|
cfg.PrimaryDSN = baseDSN
|
|
cfg.OperationTimeout = 5 * time.Second
|
|
db, err := postgres.OpenPrimary(ctx, cfg)
|
|
require.NoError(t, err)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
for _, statement := range []string{
|
|
`CREATE ROLE userservice LOGIN PASSWORD 'userservice'`,
|
|
`CREATE SCHEMA IF NOT EXISTS "user" AUTHORIZATION userservice`,
|
|
`GRANT USAGE ON SCHEMA "user" TO userservice`,
|
|
} {
|
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
|
require.NoError(t, err, "provision postgres role/schema: %s", statement)
|
|
}
|
|
}
|
|
|
|
parsed, err := url.Parse(baseDSN)
|
|
require.NoError(t, err)
|
|
|
|
values := url.Values{}
|
|
values.Set("search_path", "user")
|
|
values.Set("sslmode", "disable")
|
|
scoped := url.URL{
|
|
Scheme: parsed.Scheme,
|
|
User: url.UserPassword("userservice", "userservice"),
|
|
Host: parsed.Host,
|
|
Path: parsed.Path,
|
|
RawQuery: values.Encode(),
|
|
}
|
|
return scoped.String()
|
|
}
|
|
|
|
// errSentinel is a small unused alias kept to silence imports above when
|
|
// non-default builds drop testcontainers references.
|
|
var errSentinel = fmt.Errorf("contract test sentinel")
|