Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the production router rejected that path before the signature middleware because runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM rows inside the whole metadata snapshot instead of platform tables. - /api/v1/run/ is now the signed machine channel space while /api/v1/run/endpoints keeps normal bearer/admin authorization. - MySQL gets real scum_user, scum_user_trajectory, scum_vehicle, scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized per-row repositories instead of full snapshot rewrites. Snapshot-shaped tables from the unreleased interim build are replaced, and SCUM rows still inside a metadata snapshot are migrated once. - Facts ingest verifies the target server plugin type and converges stale online users to offline after SCUMUserOfflineAfter. - The plugin page and browser read one bounded /scum/surface response instead of five list calls per refresh. Call-count budget for one server: per 5s facts batch, one SELECT plus one INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory sample or new lock row, one bounded stale-user SELECT, and a trajectory retention DELETE at most once per hour. One browser refresh issues one surface request every 15s instead of five list requests.
341 lines
18 KiB
Go
341 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/dto"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/service"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
|
|
store := repo.NewMemoryStore()
|
|
core := service.NewCoreService(store)
|
|
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
|
t.Fatalf("seed admin: %v", err)
|
|
}
|
|
for _, user := range []domain.User{
|
|
{ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
|
|
{ID: "user-other", DisplayName: "Other", Email: "other@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
|
|
} {
|
|
if _, err := core.CreateUser(user); err != nil {
|
|
t.Fatalf("create user %s: %v", user.ID, err)
|
|
}
|
|
}
|
|
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create plugin: %v", err)
|
|
}
|
|
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create endpoint: %v", err)
|
|
}
|
|
if _, err := core.CreateServerInstance(domain.ServerInstance{
|
|
ID: "server-owner", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Owner Server",
|
|
OwnerUserID: "user-owner", State: domain.ServerInstanceStateReady, ConfigVersion: 1,
|
|
}); err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
if _, err := core.CreateJob(domain.Job{
|
|
ID: "job-owner", ServerInstanceID: "server-owner", RunEndpointID: "run-local",
|
|
Capability: "process.start", IdempotencyKey: "job-owner",
|
|
}); err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
router := NewAuthorizedRouterWithCore(core)
|
|
|
|
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", ""), http.StatusUnauthorized, errorCodeUnauthorized)
|
|
ownerAuth, err := core.LoginUser(domain.UserLogin{Account: "owner@example.test", Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login owner: %v", err)
|
|
}
|
|
otherAuth, err := core.LoginUser(domain.UserLogin{Account: "other@example.test", Password: "secret-password"})
|
|
if err != nil {
|
|
t.Fatalf("login other: %v", err)
|
|
}
|
|
ownerSession := ownerAuth.SessionID
|
|
otherSession := otherAuth.SessionID
|
|
|
|
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-owner", ownerSession)
|
|
if jobs.Count != 1 || jobs.Items[0].ID != "job-owner" {
|
|
t.Fatalf("owner did not receive own jobs: %+v", jobs)
|
|
}
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/jobs/job-owner/cancel", dto.RunJobCancelRequestBody{Reason: "cross-owner"}, otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
encodedJobs, err := json.Marshal(jobs)
|
|
if err != nil {
|
|
t.Fatalf("encode safe job projection: %v", err)
|
|
}
|
|
for _, forbidden := range []string{"leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket"} {
|
|
if strings.Contains(string(encodedJobs), forbidden) {
|
|
t.Fatalf("job projection exposed forbidden field %q: %s", forbidden, encodedJobs)
|
|
}
|
|
}
|
|
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/game-plugins", validGamePluginRequest(), ownerSession), http.StatusForbidden, errorCodeForbidden)
|
|
assertErrorResponse(t, performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/server.scum/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}), http.StatusUnauthorized, errorCodeUnauthorized)
|
|
}
|
|
|
|
func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
|
store := repo.NewMemoryStore()
|
|
core := service.NewCoreService(store)
|
|
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create endpoint: %v", err)
|
|
}
|
|
token := "run-session-secret"
|
|
stamp := time.Now().UTC()
|
|
hash := sha256.Sum256([]byte(token))
|
|
if err := store.RunControlSessions().Create(domain.RunControlSession{
|
|
RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive,
|
|
Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15,
|
|
CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true,
|
|
}); err != nil {
|
|
t.Fatalf("create Run session: %v", err)
|
|
}
|
|
router := NewTestRouterWithCore(core)
|
|
request := dto.RunControlHeartbeatRequest{
|
|
RunEndpointID: "run-local", SessionToken: token, Version: "0.1.1", Status: domain.RunEndpointStatusOnline,
|
|
CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
|
}
|
|
body, err := json.Marshal(request)
|
|
if err != nil {
|
|
t.Fatalf("marshal heartbeat: %v", err)
|
|
}
|
|
unsigned := performRequest(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", bytes.NewReader(body))
|
|
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
|
|
signed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
|
|
assertStatus(t, signed, http.StatusOK)
|
|
replayed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
|
|
assertErrorResponse(t, replayed, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
|
|
if _, err := core.CreateJob(domain.Job{ID: "job-signed", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed"}); err != nil {
|
|
t.Fatalf("create signed job: %v", err)
|
|
}
|
|
claimBody, err := json.Marshal(dto.RunJobClaimRequest{
|
|
RunEndpointID: "run-local", SessionToken: token, Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal signed claim: %v", err)
|
|
}
|
|
unsignedClaim := performRequest(t, router, http.MethodPost, "/api/v1/run/jobs/claim", bytes.NewReader(claimBody))
|
|
assertErrorResponse(t, unsignedClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
signedClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", claimBody, token, "nonce-api-job-1", stamp)
|
|
assertStatus(t, signedClaim, http.StatusOK)
|
|
|
|
staleClaimBody, err := json.Marshal(dto.RunJobClaimRequest{
|
|
RunEndpointID: "run-local", SessionToken: "stale-session", Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal stale claim: %v", err)
|
|
}
|
|
staleClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", staleClaimBody, "stale-session", "nonce-api-job-2", stamp)
|
|
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
|
|
privateUpdateBodies := map[string]any{
|
|
"/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}},
|
|
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
|
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
|
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
|
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
|
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
|
}
|
|
nonce := 10
|
|
for path, request := range privateUpdateBodies {
|
|
body, err := json.Marshal(request)
|
|
if err != nil {
|
|
t.Fatalf("marshal private Run request for %s: %v", path, err)
|
|
}
|
|
unsigned := performRequest(t, router, http.MethodPost, path, bytes.NewReader(body))
|
|
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
nonce++
|
|
signed := signedRunRequest(t, router, path, body, token, fmt.Sprintf("nonce-api-private-%d", nonce), stamp)
|
|
if signed.Code == http.StatusUnauthorized {
|
|
t.Fatalf("valid signature was rejected for %s: %s", path, signed.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSignedRunArtifactChunkUploadUsesHeaderEnvelope(t *testing.T) {
|
|
store := repo.NewMemoryStore()
|
|
core := service.NewCoreService(store)
|
|
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create plugin: %v", err)
|
|
}
|
|
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create endpoint: %v", err)
|
|
}
|
|
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-signed-artifact", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Signed Artifact", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
if _, err := core.CreateJob(domain.Job{ID: "job-signed-artifact", ServerInstanceID: "server-signed-artifact", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed-artifact"}); err != nil {
|
|
t.Fatalf("create job: %v", err)
|
|
}
|
|
token := "run-artifact-session-secret"
|
|
stamp := time.Now().UTC()
|
|
hash := sha256.Sum256([]byte(token))
|
|
if err := store.RunControlSessions().Create(domain.RunControlSession{RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive, Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true}); err != nil {
|
|
t.Fatalf("create Run session: %v", err)
|
|
}
|
|
payload := []byte("signed raw artifact chunk")
|
|
opened, err := core.OpenArtifactTransfer(domain.ArtifactTransferOpen{RunEndpointID: "run-local", SessionToken: token, ArtifactID: "artifact-signed-raw", Direction: domain.ArtifactTransferDirectionUpload, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-signed-artifact", SizeBytes: int64(len(payload)), ChunkSizeBytes: len(payload), Checksum: validator.BytesChecksum(payload), IdempotencyKey: "signed-raw-artifact"})
|
|
if err != nil {
|
|
t.Fatalf("open transfer: %v", err)
|
|
}
|
|
router := NewTestRouterWithCore(core)
|
|
unsigned := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-unsigned-raw", stamp, false)
|
|
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
signed := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-signed-raw", stamp, true)
|
|
assertStatus(t, signed, http.StatusOK)
|
|
response := decodeBody[dto.ArtifactChunkUploadResponse](t, signed)
|
|
if !response.Accepted || response.TransferID != opened.TransferID || response.NextMissingChunkIndex != 1 {
|
|
t.Fatalf("unexpected signed chunk response: %+v", response)
|
|
}
|
|
}
|
|
|
|
func rawArtifactChunkRequest(t *testing.T, router http.Handler, token string, transferID string, payload []byte, nonce string, stamp time.Time, signed bool) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
path := "/api/v1/run/artifacts/chunks"
|
|
timestamp := strconv.FormatInt(stamp.Unix(), 10)
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
req.Header.Set("X-Run-Endpoint", "run-local")
|
|
req.Header.Set("X-Run-Session-Token", token)
|
|
req.Header.Set("X-Run-Timestamp", timestamp)
|
|
req.Header.Set("X-Run-Nonce", nonce)
|
|
req.Header.Set("X-Artifact-Transfer-Id", transferID)
|
|
req.Header.Set("X-Artifact-Id", "artifact-signed-raw")
|
|
req.Header.Set("X-Artifact-Chunk-Index", "0")
|
|
req.Header.Set("X-Artifact-Offset", "0")
|
|
req.Header.Set("X-Artifact-Size", strconv.Itoa(len(payload)))
|
|
req.Header.Set("X-Artifact-Checksum", validator.BytesChecksum(payload))
|
|
if signed {
|
|
bodyHash := sha256.Sum256(payload)
|
|
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
|
|
mac := hmac.New(sha256.New, []byte(token))
|
|
_, _ = mac.Write([]byte(canonical))
|
|
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) {
|
|
store := repo.NewMemoryStore()
|
|
core := service.NewCoreService(store)
|
|
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create plugin: %v", err)
|
|
}
|
|
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create endpoint: %v", err)
|
|
}
|
|
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-authorized-lifecycle", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Authorized Lifecycle", State: domain.ServerInstanceStateFailed, ConfigVersion: 1}); err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
router := NewAuthorizedRouterWithCore(core)
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
|
|
if hello.SessionToken == "" {
|
|
t.Fatalf("expected run session token")
|
|
}
|
|
body, err := json.Marshal(dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-authorized-lifecycle", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}})
|
|
if err != nil {
|
|
t.Fatalf("marshal lifecycle report: %v", err)
|
|
}
|
|
signed := signedRunRequest(t, router, "/api/v1/run/lifecycle/report", body, hello.SessionToken, "nonce-api-lifecycle-authorized", time.Now().UTC())
|
|
assertStatus(t, signed, http.StatusOK)
|
|
updated, err := core.GetServerInstance("server-authorized-lifecycle")
|
|
if err != nil {
|
|
t.Fatalf("get server: %v", err)
|
|
}
|
|
if updated.State != domain.ServerInstanceStateRunning {
|
|
t.Fatalf("expected run lifecycle report to project running, got %s", updated.State)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizedRouterAcceptsSignedRunSCUMFactsWithoutBearer(t *testing.T) {
|
|
store := repo.NewMemoryStore()
|
|
core := service.NewCoreService(store)
|
|
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create plugin: %v", err)
|
|
}
|
|
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
|
t.Fatalf("create endpoint: %v", err)
|
|
}
|
|
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-facts", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Facts", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil {
|
|
t.Fatalf("create server: %v", err)
|
|
}
|
|
router := NewAuthorizedRouterWithCore(core)
|
|
// The browser endpoint-management API under /run/ keeps the normal bearer path.
|
|
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/run/endpoints", ""), http.StatusUnauthorized, errorCodeUnauthorized)
|
|
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
|
|
observedAt := time.Now().UTC()
|
|
body, err := json.Marshal(dto.SCUMFactIngestRequest{
|
|
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-scum-facts",
|
|
Users: []dto.SCUMUserFactBody{{SteamID: "76561198000000009", DisplayName: "Signed Run", Online: true, Login: true, ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &dto.SCUMPositionBody{X: 1, Y: 2, Z: 3}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal scum facts: %v", err)
|
|
}
|
|
signed := signedRunRequest(t, router, "/api/v1/run/scum/facts", body, hello.SessionToken, "nonce-api-scum-facts", time.Now().UTC())
|
|
assertStatus(t, signed, http.StatusAccepted)
|
|
|
|
users, err := store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: "server-scum-facts"})
|
|
if err != nil {
|
|
t.Fatalf("list scum users: %v", err)
|
|
}
|
|
if len(users) != 1 || users[0].SteamID != "76561198000000009" || !users[0].Online {
|
|
t.Fatalf("signed Run SCUM facts did not reach the platform tables: %+v", users)
|
|
}
|
|
tracks, err := store.SCUMUserTrajectories().List(domain.SCUMUserTrajectoryFilter{ServerInstanceID: "server-scum-facts"})
|
|
if err != nil {
|
|
t.Fatalf("list scum trajectories: %v", err)
|
|
}
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("expected one platform trajectory for the reported position, got %+v", tracks)
|
|
}
|
|
}
|
|
|
|
func signedRunRequest(t *testing.T, router http.Handler, path string, body []byte, token string, nonce string, stamp time.Time) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
timestamp := strconv.FormatInt(stamp.Unix(), 10)
|
|
bodyHash := sha256.Sum256(body)
|
|
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
|
|
mac := hmac.New(sha256.New, []byte(token))
|
|
_, _ = mac.Write([]byte(canonical))
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Run-Endpoint", "run-local")
|
|
req.Header.Set("X-Run-Timestamp", timestamp)
|
|
req.Header.Set("X-Run-Nonce", nonce)
|
|
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func TestSessionRotationRouteRevokesPreviousBearer(t *testing.T) {
|
|
router := newTestRouter()
|
|
current := createAdminSession(t, router)
|
|
rotated := postOKJSONWithAuth[dto.AuthSessionResponse](t, router, "/api/v1/auth/rotate", map[string]string{}, current)
|
|
if rotated.SessionID == "" || rotated.SessionID == current || rotated.ExpiresAt.IsZero() {
|
|
t.Fatalf("unexpected rotated session: %+v", rotated)
|
|
}
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", current), http.StatusUnauthorized, errorCodeUnauthorized)
|
|
currentUser := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", rotated.SessionID)
|
|
if currentUser.ID != "user-admin" {
|
|
t.Fatalf("rotated session resolved unexpected user: %+v", currentUser)
|
|
}
|
|
}
|