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) } }