231 lines
12 KiB
Go
231 lines
12 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"
|
|
)
|
|
|
|
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 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 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)
|
|
}
|
|
}
|