Files
run/api/platform_client_test.go
T

420 lines
17 KiB
Go

package api
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"browser.local/run/protocol"
)
func TestNewPlatformClientNormalizesBaseURL(t *testing.T) {
client, err := NewPlatformClient("http://platform.test/")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client.BaseURL() != "http://platform.test" {
t.Fatalf("expected normalized base URL, got %q", client.BaseURL())
}
}
func TestNewPlatformClientRequiresAbsoluteURL(t *testing.T) {
if _, err := NewPlatformClient("platform.local"); err == nil {
t.Fatal("expected error for URL without scheme and host")
}
}
func TestPlatformClientHelloPostsJSONAndDecodesResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/hello" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
t.Fatalf("expected JSON content type, got %q", contentType)
}
if r.Header.Get("X-Run-Signature") != "" {
t.Fatal("hello must not be signed with a session that does not exist yet")
}
var request protocol.RunHelloRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode hello request: %v", err)
}
if request.RunEndpointID != "run-local" || request.CapabilityReport.Fingerprint != "cap-v1" {
t.Fatalf("unexpected hello payload: %+v", request)
}
writeTestJSON(t, w, protocol.RunHelloResponse{
Accepted: true,
RunEndpointID: "run-local",
SessionToken: "session-token",
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
HeartbeatIntervalSeconds: 15,
})
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.Hello(context.Background(), validRunHelloRequest())
if err != nil {
t.Fatalf("hello: %v", err)
}
if !response.Accepted || response.SessionToken != "session-token" || response.HeartbeatIntervalSeconds != 15 {
t.Fatalf("unexpected hello response: %+v", response)
}
}
func TestPlatformRequestErrorRecognizesLegacyInvalidSession(t *testing.T) {
legacy := PlatformRequestError{Status: http.StatusBadRequest, Path: "/api/v1/run/control/heartbeat", Code: "validation_failed", Details: []string{"sessionToken is invalid"}}
if !legacy.SessionInvalid() {
t.Fatal("expected legacy invalid session response to be recognized")
}
if message := legacy.Error(); !strings.Contains(message, "status=400") || !strings.Contains(message, "path=/api/v1/run/control/heartbeat") || !strings.Contains(message, "code=validation_failed") || !strings.Contains(message, "sessionToken is invalid") {
t.Fatalf("expected request error to expose status, path, code, and details, got %q", message)
}
if (PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"another validation failure"}}).SessionInvalid() {
t.Fatal("unexpected validation response must not trigger re-registration")
}
}
func TestPlatformRequestErrorRecognizesLegacyLogSessionMetadata(t *testing.T) {
err := PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"logSessionId and sessionStartedAt must be provided together"}}
if !err.LogBatchLegacySessionMetadata() {
t.Fatal("expected legacy log session metadata response to be recognized")
}
if (PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"other validation failure"}}).LogBatchLegacySessionMetadata() {
t.Fatal("unexpected validation response must not be recognized")
}
}
func TestPlatformRequestErrorRecognizesLogSessionMetadataMismatch(t *testing.T) {
err := PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"log session metadata must match stream"}}
if !err.LogBatchSessionMetadataMismatch() {
t.Fatal("expected log session metadata mismatch response to be recognized")
}
if (PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"other validation failure"}}).LogBatchSessionMetadataMismatch() {
t.Fatal("unexpected validation response must not be recognized")
}
}
func TestPlatformClientSignsRunChannelRequestsWithUniqueNonce(t *testing.T) {
nonces := map[string]struct{}{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read signed body: %v", err)
}
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
nonce := r.Header.Get("X-Run-Nonce")
if _, exists := nonces[nonce]; exists {
t.Fatalf("reused Run request nonce %q", nonce)
}
nonces[nonce] = struct{}{}
writeTestJSON(t, w, protocol.RunHeartbeatResponse{Accepted: true, RunEndpointID: "run-local", NextHeartbeatSeconds: 15, ServerTime: time.Now().UTC()})
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
for range 2 {
if _, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest("session-token")); err != nil {
t.Fatalf("signed heartbeat: %v", err)
}
}
}
func TestPlatformClientStreamsSignedControlEvents(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/events" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read stream body: %v", err)
}
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
var request protocol.RunControlStreamRequest
if err := json.Unmarshal(body, &request); err != nil {
t.Fatalf("decode stream request: %v", err)
}
if request.LastEventSeq != 7 {
t.Fatalf("unexpected stream cursor: %+v", request)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: job.changed\nid: 8\ndata: {\"runEndpointId\":\"run-local\",\"sequence\":8,\"type\":\"job.changed\",\"serverTime\":\"2026-07-03T12:00:00Z\"}\n\n"))
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
events := []protocol.RunControlEvent{}
err = client.StreamControlEvents(context.Background(), protocol.RunControlStreamRequest{RunEndpointID: "run-local", SessionToken: "session-token", LastEventSeq: 7}, func(event protocol.RunControlEvent) error {
events = append(events, event)
return nil
})
if err != nil {
t.Fatalf("stream events: %v", err)
}
if len(events) != 1 || events[0].Type != protocol.RunControlEventTypeJobChanged || events[0].Sequence != 8 {
t.Fatalf("unexpected control events: %+v", events)
}
}
func verifyRunRequestSignature(t *testing.T, request *http.Request, body []byte, endpoint string, token string) {
t.Helper()
if request.Header.Get("X-Run-Endpoint") != endpoint {
t.Fatalf("unexpected signed endpoint %q", request.Header.Get("X-Run-Endpoint"))
}
timestamp := request.Header.Get("X-Run-Timestamp")
if _, err := strconv.ParseInt(timestamp, 10, 64); err != nil {
t.Fatalf("invalid signed timestamp %q", timestamp)
}
nonce := request.Header.Get("X-Run-Nonce")
if decoded, err := hex.DecodeString(nonce); err != nil || len(decoded) != 16 {
t.Fatalf("invalid signed nonce %q", nonce)
}
bodyHash := sha256.Sum256(body)
canonical := strings.Join([]string{request.Method, request.URL.Path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha256.New, []byte(token))
_, _ = mac.Write([]byte(canonical))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(request.Header.Get("X-Run-Signature"))) {
t.Fatalf("invalid Run request signature")
}
}
func TestPlatformClientHeartbeatPostsJSONAndDecodesResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/heartbeat" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
var request protocol.RunHeartbeatRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode heartbeat request: %v", err)
}
if request.SessionToken != "session-token" || request.Capacity.RunningJobs != 1 {
t.Fatalf("unexpected heartbeat payload: %+v", request)
}
writeTestJSON(t, w, protocol.RunHeartbeatResponse{
Accepted: true,
RunEndpointID: "run-local",
NextHeartbeatSeconds: 15,
RefreshCapabilities: true,
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
})
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest("session-token"))
if err != nil {
t.Fatalf("heartbeat: %v", err)
}
if !response.Accepted || !response.RefreshCapabilities || response.NextHeartbeatSeconds != 15 {
t.Fatalf("unexpected heartbeat response: %+v", response)
}
}
func TestPlatformClientGetsOneTimeSourceRCONInputOverSignedRoute(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/jobs/source-rcon-input" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read Source RCON input request: %v", err)
}
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
if strings.Contains(string(body), "command") || strings.Contains(string(body), "password") {
t.Fatalf("Source RCON input request exposed command material: %s", body)
}
var request protocol.SourceRCONExecutionInputRequest
if err := json.Unmarshal(body, &request); err != nil {
t.Fatalf("decode Source RCON input request: %v", err)
}
if request.JobID != "job-source-rcon" || request.LeaseToken != "lease-source-rcon" || request.Attempt != 1 {
t.Fatalf("unexpected Source RCON input request: %+v", request)
}
writeTestJSON(t, w, protocol.SourceRCONExecutionInputResponse{JobID: request.JobID, ServerInstanceID: "server-1", RunEndpointID: request.RunEndpointID, Command: "rcon.status"})
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.GetSourceRCONExecutionInput(context.Background(), protocol.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-source-rcon", LeaseToken: "lease-source-rcon", Attempt: 1})
if err != nil || response.Command != "rcon.status" || response.RunEndpointID != "run-local" {
t.Fatalf("unexpected Source RCON input response=%+v err=%v", response, err)
}
}
func TestPlatformClientGetsFencedProtectedRequestOverSignedRoute(t *testing.T) {
expiresAt := time.Now().UTC().Add(time.Minute)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/jobs/protected-request-input" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read protected input request: %v", err)
}
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
if strings.Contains(string(body), "SELECT") || strings.Contains(string(body), "dsn") || strings.Contains(string(body), "password") {
t.Fatalf("protected input request exposed execution material: %s", body)
}
var request protocol.ProtectedRequestExecutionInputRequest
if err := json.Unmarshal(body, &request); err != nil {
t.Fatalf("decode protected input request: %v", err)
}
if request.JobID != "job-protected" || request.LeaseToken != "lease-protected" || request.FencingToken != 9 || request.Attempt != 1 {
t.Fatalf("unexpected protected input request: %+v", request)
}
writeTestJSON(t, w, protocol.ProtectedRequestExecutionInputResponse{JobID: request.JobID, ServerInstanceID: "server-1", RunEndpointID: request.RunEndpointID, FencingToken: request.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: expiresAt, Kind: "sql", TransportKey: "scum-database", TargetKey: "scum-database", RequestText: "SELECT player_id FROM players"})
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.GetProtectedRequestExecutionInput(context.Background(), protocol.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-protected", LeaseToken: "lease-protected", Attempt: 1, FencingToken: 9})
if err != nil || response.RequestText == "" || response.FencingToken != 9 || !response.Authorized || response.ApprovalState != "approved" || response.QueueState != "claimed" {
t.Fatalf("unexpected protected input response=%+v err=%v", response, err)
}
}
func TestPlatformClientReturnsErrorForPlatformFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"validation_failed"}`))
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
if _, err := client.Hello(context.Background(), validRunHelloRequest()); err == nil {
t.Fatal("expected platform error")
} else {
var requestError PlatformRequestError
if !errors.As(err, &requestError) || requestError.Status != http.StatusBadRequest || !strings.Contains(err.Error(), "code=validation_failed") {
t.Fatalf("expected typed platform error with diagnostic code, got %v", err)
}
}
}
func TestPlatformClientHelloThenHeartbeatFlow(t *testing.T) {
var activeSessionToken string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/run/control/hello":
var request protocol.RunHelloRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode hello request: %v", err)
}
if request.RegistrationToken == "" || request.RunEndpointID != "run-local" {
t.Fatalf("unexpected hello request: %+v", request)
}
activeSessionToken = "session-token"
writeTestJSON(t, w, protocol.RunHelloResponse{
Accepted: true,
RunEndpointID: request.RunEndpointID,
SessionToken: activeSessionToken,
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
HeartbeatIntervalSeconds: 15,
})
case "/api/v1/run/control/heartbeat":
var request protocol.RunHeartbeatRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode heartbeat request: %v", err)
}
if request.SessionToken != activeSessionToken {
t.Fatalf("heartbeat did not use active session token: %+v", request)
}
writeTestJSON(t, w, protocol.RunHeartbeatResponse{
Accepted: true,
RunEndpointID: request.RunEndpointID,
NextHeartbeatSeconds: 15,
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
})
default:
t.Fatalf("unexpected request path %s", r.URL.Path)
}
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
hello, err := client.Hello(context.Background(), validRunHelloRequest())
if err != nil {
t.Fatalf("hello: %v", err)
}
heartbeat, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest(hello.SessionToken))
if err != nil {
t.Fatalf("heartbeat: %v", err)
}
if !hello.Accepted || !heartbeat.Accepted {
t.Fatalf("expected accepted hello and heartbeat, got %+v %+v", hello, heartbeat)
}
}
func writeTestJSON(t *testing.T, w http.ResponseWriter, value any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
t.Fatalf("encode response: %v", err)
}
}
func validRunHelloRequest() protocol.RunHelloRequest {
return protocol.RunHelloRequest{
RegistrationToken: "registration-token",
RunEndpointID: "run-local",
DisplayName: "Local Run",
Version: "0.1.0",
Status: "online",
Platform: "darwin/arm64",
CapabilityReport: protocol.RunCapabilityReport{
Capabilities: []string{"control.hello", "control.heartbeat"},
Fingerprint: "cap-v1",
},
Capacity: protocol.RunCapacityReport{MaxJobs: 4},
}
}
func validRunHeartbeatRequest(sessionToken string) protocol.RunHeartbeatRequest {
return protocol.RunHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
Version: "0.1.0",
Status: "online",
CapabilityFingerprint: "cap-v1",
Capacity: protocol.RunCapacityReport{
MaxJobs: 4,
RunningJobs: 1,
},
}
}