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" ) 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/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 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 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) } }