This commit is contained in:
npc0-hue
2026-08-26 09:56:43 +08:00
parent 2b4974b561
commit 8e02a316fa
93 changed files with 21749 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"browser.local/run/protocol"
)
func TestPlatformClientArtifactMethodsPostJSONAndDecodeResponses(t *testing.T) {
seen := map[string]bool{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen[r.URL.Path] = true
switch r.URL.Path {
case "/api/v1/run/artifacts/open":
var request protocol.ArtifactTransferOpenRequest
decodeTestRequest(t, r, &request)
if request.ArtifactID != "artifact-1" || request.ChunkSizeBytes != 8 {
t.Fatalf("unexpected artifact open request: %+v", request)
}
writeTestJSON(t, w, validArtifactOpenResponse())
case "/api/v1/run/artifacts/chunks":
var request protocol.ArtifactChunkUploadRequest
decodeTestRequest(t, r, &request)
if request.TransferID != "transfer-1" || request.ChunkIndex != 0 || string(request.Payload) != "payload" {
t.Fatalf("unexpected artifact chunk request: %+v", request)
}
writeTestJSON(t, w, protocol.ArtifactChunkUploadResponse{Accepted: true, TransferID: "transfer-1", ArtifactID: "artifact-1", ChunkIndex: 0, ReceivedChunkIndexes: []int{0}, NextMissingChunkIndex: 1, ServerTime: fixedClientTestTime()})
case "/api/v1/run/artifacts/status":
var request protocol.ArtifactTransferStatusRequest
decodeTestRequest(t, r, &request)
if request.TransferID != "transfer-1" {
t.Fatalf("unexpected artifact status request: %+v", request)
}
writeTestJSON(t, w, protocol.ArtifactTransferStatusResponse{Accepted: true, TransferID: "transfer-1", ArtifactID: "artifact-1", Direction: "upload", TotalChunks: 2, ChunkSizeBytes: 8, ReceivedChunkIndexes: []int{0}, NextMissingChunkIndex: 1, ServerTime: fixedClientTestTime()})
case "/api/v1/run/artifacts/complete":
var request protocol.ArtifactTransferCompleteRequest
decodeTestRequest(t, r, &request)
if request.Checksum == "" || request.SizeBytes != 7 {
t.Fatalf("unexpected artifact complete request: %+v", request)
}
response := protocol.ArtifactTransferCompleteResponse{Accepted: true, TransferID: "transfer-1", Artifact: validArtifactMetadata("available"), Completed: true, ServerTime: fixedClientTestTime()}
writeTestJSON(t, w, response)
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)
}
ctx := context.Background()
open, err := client.OpenArtifactTransfer(ctx, validClientArtifactOpen())
if err != nil || !open.Accepted || open.TransferID != "transfer-1" {
t.Fatalf("open artifact response=%+v err=%v", open, err)
}
chunk, err := client.UploadArtifactChunk(ctx, validClientArtifactChunk())
if err != nil || chunk.NextMissingChunkIndex != 1 {
t.Fatalf("upload artifact chunk response=%+v err=%v", chunk, err)
}
status, err := client.QueryArtifactTransferStatus(ctx, validClientArtifactStatus())
if err != nil || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("artifact status response=%+v err=%v", status, err)
}
complete, err := client.CompleteArtifactTransfer(ctx, validClientArtifactComplete())
if err != nil || !complete.Completed || complete.Artifact.State != "available" {
t.Fatalf("complete artifact response=%+v err=%v", complete, err)
}
for _, path := range []string{"/api/v1/run/artifacts/open", "/api/v1/run/artifacts/chunks", "/api/v1/run/artifacts/status", "/api/v1/run/artifacts/complete"} {
if !seen[path] {
t.Fatalf("expected request to %s", path)
}
}
}
func TestPlatformClientArtifactMethodReturnsErrorForPlatformFailure(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.OpenArtifactTransfer(context.Background(), validClientArtifactOpen()); err == nil {
t.Fatal("expected platform error")
}
}
func TestArtifactChunkPayloadUsesJSONBase64Encoding(t *testing.T) {
encoded, err := json.Marshal(validClientArtifactChunk())
if err != nil {
t.Fatalf("marshal artifact chunk: %v", err)
}
if !json.Valid(encoded) || !containsJSONPayloadField(encoded) {
t.Fatalf("expected JSON encoded payload field, got %s", string(encoded))
}
}
func validClientArtifactOpen() protocol.ArtifactTransferOpenRequest {
return protocol.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: "session-token",
ArtifactID: "artifact-1",
Direction: "upload",
OwnerKind: "job",
OwnerID: "job-1",
SizeBytes: 7,
ChunkSizeBytes: 8,
Checksum: validSHA256Checksum(),
IdempotencyKey: "artifact-upload-1",
}
}
func validClientArtifactChunk() protocol.ArtifactChunkUploadRequest {
return protocol.ArtifactChunkUploadRequest{RunEndpointID: "run-local", SessionToken: "session-token", TransferID: "transfer-1", ArtifactID: "artifact-1", ChunkIndex: 0, Offset: 0, SizeBytes: 7, Checksum: validSHA256Checksum(), Payload: []byte("payload")}
}
func validClientArtifactStatus() protocol.ArtifactTransferStatusRequest {
return protocol.ArtifactTransferStatusRequest{RunEndpointID: "run-local", SessionToken: "session-token", TransferID: "transfer-1", ArtifactID: "artifact-1"}
}
func validClientArtifactComplete() protocol.ArtifactTransferCompleteRequest {
return protocol.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: "session-token", TransferID: "transfer-1", ArtifactID: "artifact-1", Checksum: validSHA256Checksum(), SizeBytes: 7}
}
func validArtifactOpenResponse() protocol.ArtifactTransferOpenResponse {
return protocol.ArtifactTransferOpenResponse{Accepted: true, TransferID: "transfer-1", Direction: "upload", Artifact: validArtifactMetadata("uploading"), TotalChunks: 2, ChunkSizeBytes: 8, NextMissingChunkIndex: 0, ServerTime: fixedClientTestTime()}
}
func validArtifactMetadata(state string) protocol.ArtifactMetadata {
return protocol.ArtifactMetadata{ID: "artifact-1", OwnerKind: "job", OwnerID: "job-1", SizeBytes: 7, Checksum: validSHA256Checksum(), State: state, CreatedAt: fixedClientTestTime(), UpdatedAt: fixedClientTestTime()}
}
func validSHA256Checksum() string {
return "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
func containsJSONPayloadField(encoded []byte) bool {
var body map[string]any
if err := json.Unmarshal(encoded, &body); err != nil {
return false
}
_, exists := body["payload"]
return exists
}
+263
View File
@@ -0,0 +1,263 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"browser.local/run/protocol"
)
func TestPlatformClientLightweightChannelsCompleteWhileArtifactChunkIsBlocked(t *testing.T) {
artifactStarted := make(chan struct{})
releaseArtifact := make(chan struct{})
artifactDone := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/run/artifacts/chunks":
var request protocol.ArtifactChunkUploadRequest
decodeTestRequest(t, r, &request)
if request.ChunkIndex != 0 || len(request.Payload) == 0 {
t.Fatalf("unexpected artifact payload: %+v", request)
}
close(artifactStarted)
<-releaseArtifact
writeTestJSON(t, w, protocol.ArtifactChunkUploadResponse{Accepted: true, TransferID: request.TransferID, ArtifactID: request.ArtifactID, ChunkIndex: request.ChunkIndex, ReceivedChunkIndexes: []int{0}, NextMissingChunkIndex: 1, ServerTime: fixedClientTestTime()})
close(artifactDone)
case "/api/v1/run/control/heartbeat":
var request protocol.RunHeartbeatRequest
decodeTestRequest(t, r, &request)
writeTestJSON(t, w, protocol.RunHeartbeatResponse{Accepted: true, RunEndpointID: request.RunEndpointID, NextHeartbeatSeconds: 15, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/result":
var request protocol.RunJobResultRequest
decodeTestRequest(t, r, &request)
encoded, _ := json.Marshal(request)
for _, forbidden := range []string{"payload", "entries", "/Users/", "unix://", "tcp://", "Bearer ", "sk-", "password="} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("job result carried forbidden transfer content %q: %s", forbidden, string(encoded))
}
}
job := validRunJobAssignment()
job.State = request.State
job.ResultRef = request.ResultRef
writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: job, ServerTime: fixedClientTestTime()})
case "/api/v1/run/logs/batches":
var request protocol.LogBatchIngestRequest
decodeTestRequest(t, r, &request)
if len(request.Entries) != 1 || request.FirstSeq != 1 || request.LastSeq != 1 {
t.Fatalf("unexpected log batch: %+v", request)
}
writeTestJSON(t, w, protocol.LogBatchIngestResponse{Accepted: true, LogStreamID: request.LogStreamID, AcceptedFrom: 1, AcceptedTo: 1, LatestSeq: 1, ServerTime: fixedClientTestTime()})
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)
}
errCh := make(chan error, 1)
go func() {
_, err := client.UploadArtifactChunk(context.Background(), validClientArtifactChunk())
errCh <- err
}()
select {
case <-artifactStarted:
case <-time.After(time.Second):
t.Fatal("artifact request did not start")
}
lightCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Heartbeat(lightCtx, validRunHeartbeatRequest("session-token")); err != nil {
t.Fatalf("heartbeat should not wait for artifact chunk: %v", err)
}
if _, err := client.CompleteJob(lightCtx, validRunJobResultRequest()); err != nil {
t.Fatalf("job result should not wait for artifact chunk: %v", err)
}
if _, err := client.IngestLogBatch(lightCtx, validClientLogBatch()); err != nil {
t.Fatalf("log ingest should not wait for artifact chunk: %v", err)
}
select {
case <-artifactDone:
t.Fatal("artifact chunk completed before release")
default:
}
close(releaseArtifact)
select {
case err := <-errCh:
if err != nil {
t.Fatalf("artifact chunk upload: %v", err)
}
case <-time.After(time.Second):
t.Fatal("artifact chunk did not finish after release")
}
}
func TestPlatformClientControlAndJobsCompleteWhileLogIngestIsBlocked(t *testing.T) {
logStarted := make(chan struct{})
releaseLog := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/run/logs/batches":
var request protocol.LogBatchIngestRequest
decodeTestRequest(t, r, &request)
close(logStarted)
<-releaseLog
writeTestJSON(t, w, protocol.LogBatchIngestResponse{Accepted: true, LogStreamID: request.LogStreamID, AcceptedFrom: request.FirstSeq, AcceptedTo: request.LastSeq, LatestSeq: request.LastSeq, ServerTime: fixedClientTestTime()})
case "/api/v1/run/control/heartbeat":
var request protocol.RunHeartbeatRequest
decodeTestRequest(t, r, &request)
writeTestJSON(t, w, protocol.RunHeartbeatResponse{Accepted: true, RunEndpointID: request.RunEndpointID, NextHeartbeatSeconds: 15, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/result":
var request protocol.RunJobResultRequest
decodeTestRequest(t, r, &request)
job := validRunJobAssignment()
job.State = request.State
writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: job, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/reconcile":
var request protocol.RunJobReconcileRequest
decodeTestRequest(t, r, &request)
writeTestJSON(t, w, protocol.RunJobReconcileResponse{Accepted: true, RunEndpointID: request.RunEndpointID, ConfirmedJobs: []protocol.RunJobAssignment{validRunJobAssignment()}, ServerTime: fixedClientTestTime()})
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)
}
logErr := make(chan error, 1)
go func() {
_, callErr := client.IngestLogBatch(context.Background(), validClientLogBatch())
logErr <- callErr
}()
select {
case <-logStarted:
case <-time.After(time.Second):
t.Fatal("log request did not start")
}
lightCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Heartbeat(lightCtx, validRunHeartbeatRequest("session-token")); err != nil {
t.Fatalf("heartbeat should not wait for log ingest: %v", err)
}
if _, err := client.CompleteJob(lightCtx, validRunJobResultRequest()); err != nil {
t.Fatalf("job result should not wait for log ingest: %v", err)
}
if _, err := client.ReconcileJobs(lightCtx, validRunJobReconcileRequest()); err != nil {
t.Fatalf("job reconcile should not wait for log ingest: %v", err)
}
close(releaseLog)
select {
case err := <-logErr:
if err != nil {
t.Fatalf("log ingest: %v", err)
}
case <-time.After(time.Second):
t.Fatal("log ingest did not finish after release")
}
}
func TestPlatformClientControlJobsAndLogsCompleteWhileDependencyOrUpdateInputIsBlocked(t *testing.T) {
for _, scenario := range []struct {
name string
path string
call func(context.Context, PlatformClient) error
}{
{
name: "dependency adapter input",
path: "/api/v1/run/jobs/dependency-input",
call: func(ctx context.Context, client PlatformClient) error {
_, err := client.GetDependencyExecutionInput(ctx, protocol.DependencyExecutionInputRequest{RunEndpointID: "run-test", SessionToken: "session-token", JobID: "job-dependency", LeaseToken: "lease-dependency", Attempt: 1})
return err
},
},
{
name: "self-update chunk",
path: "/api/v1/run/jobs/update-chunk",
call: func(ctx context.Context, client PlatformClient) error {
_, err := client.ReadRunUpdateChunk(ctx, protocol.RunUpdateChunkRequest{RunEndpointID: "run-test", SessionToken: "session-token", JobID: "job-update", LeaseToken: "lease-update", Attempt: 1, Offset: 0, Length: 8})
return err
},
},
} {
t.Run(scenario.name, func(t *testing.T) {
blockedStarted := make(chan struct{})
releaseBlocked := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case scenario.path:
close(blockedStarted)
<-releaseBlocked
if scenario.path == "/api/v1/run/jobs/dependency-input" {
writeTestJSON(t, w, protocol.DependencyExecutionInputResponse{JobID: "job-dependency", ServerInstanceID: "server-1", RunEndpointID: "run-test", PluginID: "game.runtime", PluginVersion: "1.0.0", ProfileKey: "local", TargetOS: "linux", TargetArch: "amd64", PlanDigest: "sha256:" + strings.Repeat("a", 64), Bindings: map[string]string{}})
return
}
writeTestJSON(t, w, protocol.RunUpdateChunkResponse{JobID: "job-update", ArtifactID: "artifact-update", Offset: 0, TotalBytes: 8, Checksum: "sha256:" + strings.Repeat("b", 64), Payload: []byte("12345678"), Complete: true})
case "/api/v1/run/control/heartbeat":
var request protocol.RunHeartbeatRequest
decodeTestRequest(t, r, &request)
writeTestJSON(t, w, protocol.RunHeartbeatResponse{Accepted: true, RunEndpointID: request.RunEndpointID, NextHeartbeatSeconds: 15, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/result":
var request protocol.RunJobResultRequest
decodeTestRequest(t, r, &request)
job := validRunJobAssignment()
job.State = request.State
writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: job, ServerTime: fixedClientTestTime()})
case "/api/v1/run/logs/batches":
var request protocol.LogBatchIngestRequest
decodeTestRequest(t, r, &request)
writeTestJSON(t, w, protocol.LogBatchIngestResponse{Accepted: true, LogStreamID: request.LogStreamID, AcceptedFrom: request.FirstSeq, AcceptedTo: request.LastSeq, LatestSeq: request.LastSeq, ServerTime: fixedClientTestTime()})
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)
}
blockedDone := make(chan error, 1)
go func() { blockedDone <- scenario.call(context.Background(), client) }()
select {
case <-blockedStarted:
case <-time.After(time.Second):
t.Fatal("blocked dependency/update request did not start")
}
lightCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Heartbeat(lightCtx, validRunHeartbeatRequest("session-token")); err != nil {
t.Fatalf("heartbeat should not wait for dependency/update input: %v", err)
}
if _, err := client.CompleteJob(lightCtx, validRunJobResultRequest()); err != nil {
t.Fatalf("job result should not wait for dependency/update input: %v", err)
}
if _, err := client.IngestLogBatch(lightCtx, validClientLogBatch()); err != nil {
t.Fatalf("log ingest should not wait for dependency/update input: %v", err)
}
close(releaseBlocked)
select {
case err := <-blockedDone:
if err != nil {
t.Fatalf("blocked request completion: %v", err)
}
case <-time.After(time.Second):
t.Fatal("blocked request did not complete after release")
}
})
}
}
+234
View File
@@ -0,0 +1,234 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"browser.local/run/protocol"
)
func TestPlatformClientJobMethodsPostJSONAndDecodeResponses(t *testing.T) {
seen := map[string]bool{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen[r.URL.Path] = true
switch r.URL.Path {
case "/api/v1/run/jobs/claim":
var request protocol.RunJobClaimRequest
decodeTestRequest(t, r, &request)
if request.RunEndpointID != "run-local" || request.SessionToken != "session-token" {
t.Fatalf("unexpected claim request: %+v", request)
}
writeTestJSON(t, w, validRunJobClaimResponse())
case "/api/v1/run/jobs/ack":
var request protocol.RunJobAckRequest
decodeTestRequest(t, r, &request)
if request.JobID != "job-1" || request.LeaseToken != "lease-1" {
t.Fatalf("unexpected ack request: %+v", request)
}
writeTestJSON(t, w, protocol.RunJobAckResponse{Accepted: true, Job: validRunJobAssignment(), ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/progress":
var request protocol.RunJobProgressRequest
decodeTestRequest(t, r, &request)
if request.Progress.Percent != 40 {
t.Fatalf("unexpected progress request: %+v", request)
}
assignment := validRunJobAssignment()
assignment.Progress.Percent = 40
writeTestJSON(t, w, protocol.RunJobProgressResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/result":
var request protocol.RunJobResultRequest
decodeTestRequest(t, r, &request)
if request.State != "succeeded" || request.ResultRef == "" {
t.Fatalf("unexpected result request: %+v", request)
}
assignment := validRunJobAssignment()
assignment.State = "succeeded"
assignment.ResultRef = request.ResultRef
writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/cancel":
var request protocol.RunJobCancelPollRequest
decodeTestRequest(t, r, &request)
if request.JobID != "job-1" {
t.Fatalf("unexpected cancel request: %+v", request)
}
writeTestJSON(t, w, protocol.RunJobCancelPollResponse{Accepted: true, RunEndpointID: "run-local", HasCancel: true, JobID: "job-1", Reason: "stop", ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/reconcile":
var request protocol.RunJobReconcileRequest
decodeTestRequest(t, r, &request)
if len(request.ActiveJobs) != 1 || request.ActiveJobs[0].JobID != "job-1" || request.ActiveJobs[0].Attempt != 1 {
t.Fatalf("unexpected reconcile request: %+v", request)
}
writeTestJSON(t, w, protocol.RunJobReconcileResponse{Accepted: true, RunEndpointID: "run-local", ConfirmedJobs: []protocol.RunJobAssignment{validRunJobAssignment()}, ServerTime: fixedClientTestTime()})
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)
}
ctx := context.Background()
claim, err := client.ClaimJob(ctx, validRunJobClaimRequest())
if err != nil || !claim.HasJob {
t.Fatalf("claim job response=%+v err=%v", claim, err)
}
if _, err := client.AckJob(ctx, validRunJobAckRequest()); err != nil {
t.Fatalf("ack job: %v", err)
}
if _, err := client.UpdateJobProgress(ctx, validRunJobProgressRequest()); err != nil {
t.Fatalf("progress job: %v", err)
}
if _, err := client.CompleteJob(ctx, validRunJobResultRequest()); err != nil {
t.Fatalf("complete job: %v", err)
}
if _, err := client.PollJobCancel(ctx, validRunJobCancelPollRequest()); err != nil {
t.Fatalf("poll cancel: %v", err)
}
if _, err := client.ReconcileJobs(ctx, validRunJobReconcileRequest()); err != nil {
t.Fatalf("reconcile jobs: %v", err)
}
for _, path := range []string{"/api/v1/run/jobs/claim", "/api/v1/run/jobs/ack", "/api/v1/run/jobs/progress", "/api/v1/run/jobs/result", "/api/v1/run/jobs/cancel", "/api/v1/run/jobs/reconcile"} {
if !seen[path] {
t.Fatalf("expected request to %s", path)
}
}
}
func TestPlatformClientJobMethodReturnsErrorForPlatformFailure(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.ClaimJob(context.Background(), validRunJobClaimRequest()); err == nil {
t.Fatal("expected platform error")
}
}
func TestPlatformClientJobLifecycleFlow(t *testing.T) {
assignment := validRunJobAssignment()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/run/jobs/claim":
assignment.State = "accepted"
writeTestJSON(t, w, protocol.RunJobClaimResponse{Accepted: true, RunEndpointID: "run-local", HasJob: true, Job: &assignment, NextPollSeconds: 2, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/ack":
assignment.State = "running"
writeTestJSON(t, w, protocol.RunJobAckResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/progress":
assignment.Progress.Percent = 70
writeTestJSON(t, w, protocol.RunJobProgressResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/result":
assignment.State = "succeeded"
assignment.Progress.Percent = 100
assignment.ResultRef = "artifact://jobs/job-1/result"
writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()})
case "/api/v1/run/jobs/reconcile":
writeTestJSON(t, w, protocol.RunJobReconcileResponse{Accepted: true, RunEndpointID: "run-local", DiscardJobIDs: []string{"local-only"}, ServerTime: fixedClientTestTime()})
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)
}
ctx := context.Background()
claim, err := client.ClaimJob(ctx, validRunJobClaimRequest())
if err != nil {
t.Fatalf("claim job: %v", err)
}
ack, err := client.AckJob(ctx, protocol.RunJobAckRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack job: %v", err)
}
progress, err := client.UpdateJobProgress(ctx, protocol.RunJobProgressRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: ack.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, Progress: protocol.RunJobProgressReport{Percent: 70}})
if err != nil {
t.Fatalf("progress job: %v", err)
}
result, err := client.CompleteJob(ctx, protocol.RunJobResultRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: progress.Job.JobID, LeaseToken: progress.Job.LeaseToken, Attempt: progress.Job.Attempt, State: "succeeded", Progress: protocol.RunJobProgressReport{Percent: 100}, ResultRef: "artifact://jobs/job-1/result"})
if err != nil {
t.Fatalf("complete job: %v", err)
}
reconcile, err := client.ReconcileJobs(ctx, protocol.RunJobReconcileRequest{RunEndpointID: "run-local", SessionToken: "session-token", ActiveJobs: []protocol.RunJobReconcileEntry{{JobID: "local-only", LeaseToken: "local-lease", Attempt: 1}}})
if err != nil {
t.Fatalf("reconcile jobs: %v", err)
}
if claim.Job.State != "accepted" || ack.Job.State != "running" || progress.Job.Progress.Percent != 70 || result.Job.State != "succeeded" || len(reconcile.DiscardJobIDs) != 1 {
t.Fatalf("unexpected lifecycle responses: claim=%+v ack=%+v progress=%+v result=%+v reconcile=%+v", claim, ack, progress, result, reconcile)
}
}
func decodeTestRequest(t *testing.T, r *http.Request, target any) {
t.Helper()
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
t.Fatalf("expected JSON content type, got %q", contentType)
}
if err := json.NewDecoder(r.Body).Decode(target); err != nil {
t.Fatalf("decode request: %v", err)
}
}
func fixedClientTestTime() time.Time {
return time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
}
func validRunJobClaimRequest() protocol.RunJobClaimRequest {
return protocol.RunJobClaimRequest{RunEndpointID: "run-local", SessionToken: "session-token", Capabilities: []string{"process.start"}, Capacity: protocol.RunCapacityReport{MaxJobs: 4}}
}
func validRunJobAckRequest() protocol.RunJobAckRequest {
return protocol.RunJobAckRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1, Message: "started"}
}
func validRunJobProgressRequest() protocol.RunJobProgressRequest {
return protocol.RunJobProgressRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1, Progress: protocol.RunJobProgressReport{Percent: 40, Message: "working"}}
}
func validRunJobResultRequest() protocol.RunJobResultRequest {
return protocol.RunJobResultRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1, State: "succeeded", Progress: protocol.RunJobProgressReport{Percent: 100}, ResultRef: "artifact://jobs/job-1/result", Message: "done"}
}
func validRunJobCancelPollRequest() protocol.RunJobCancelPollRequest {
return protocol.RunJobCancelPollRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1}
}
func validRunJobReconcileRequest() protocol.RunJobReconcileRequest {
return protocol.RunJobReconcileRequest{RunEndpointID: "run-local", SessionToken: "session-token", ActiveJobs: []protocol.RunJobReconcileEntry{{JobID: "job-1", LeaseToken: "lease-1", Attempt: 1}}}
}
func validRunJobClaimResponse() protocol.RunJobClaimResponse {
job := validRunJobAssignment()
return protocol.RunJobClaimResponse{Accepted: true, RunEndpointID: "run-local", HasJob: true, Job: &job, NextPollSeconds: 2, ServerTime: fixedClientTestTime()}
}
func validRunJobAssignment() protocol.RunJobAssignment {
return protocol.RunJobAssignment{
JobID: "job-1",
RunEndpointID: "run-local",
Capability: "process.start",
IdempotencyKey: "idem-1",
State: "accepted",
LeaseToken: "lease-1",
Attempt: 1,
CreatedAt: fixedClientTestTime(),
UpdatedAt: fixedClientTestTime(),
}
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"browser.local/run/protocol"
)
func TestPlatformClientIngestLogBatchPostsJSONAndDecodesResponse(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/logs/batches" {
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)
}
var request protocol.LogBatchIngestRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode log ingest request: %v", err)
}
if request.LogStreamID != "log-1" || request.FirstSeq != 1 || request.LastSeq != 1 || len(request.Entries) != 1 {
t.Fatalf("unexpected log ingest payload: %+v", request)
}
writeTestJSON(t, w, protocol.LogBatchIngestResponse{Accepted: true, LogStreamID: "log-1", AcceptedFrom: 1, AcceptedTo: 1, LatestSeq: 1, ServerTime: fixedClientTestTime()})
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.IngestLogBatch(context.Background(), validClientLogBatch())
if err != nil {
t.Fatalf("ingest log batch: %v", err)
}
if !response.Accepted || response.LatestSeq != 1 {
t.Fatalf("unexpected log ingest response: %+v", response)
}
}
func TestPlatformClientIngestLogBatchReturnsErrorForPlatformFailure(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.IngestLogBatch(context.Background(), validClientLogBatch()); err == nil {
t.Fatal("expected platform error")
}
}
func validClientLogBatch() protocol.LogBatchIngestRequest {
return protocol.LogBatchIngestRequest{
RunEndpointID: "run-local",
SessionToken: "session-token",
LogStreamID: "log-1",
ServerInstanceID: "server-1",
StreamKey: "stdout",
Source: "process",
FirstSeq: 1,
LastSeq: 1,
Compression: "none",
Checksum: "sha256:test",
Entries: []protocol.LogEntry{{
Seq: 1,
Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC),
Level: "info",
Line: "line",
}},
}
}
+337
View File
@@ -0,0 +1,337 @@
package api
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"browser.local/run/protocol"
)
type PlatformClient struct {
baseURL string
httpClient *http.Client
}
type PlatformRequestError struct {
Status int
Path string
Code string
Details []string
}
func (err PlatformRequestError) Error() string {
parts := []string{fmt.Sprintf("status=%d", err.Status)}
if strings.TrimSpace(err.Path) != "" {
parts = append(parts, "path="+strings.TrimSpace(err.Path))
}
if strings.TrimSpace(err.Code) != "" {
parts = append(parts, "code="+strings.TrimSpace(err.Code))
}
if len(err.Details) > 0 {
parts = append(parts, "details="+strings.Join(err.Details, "; "))
}
return "platform request failed: " + strings.Join(parts, " ")
}
func (err PlatformRequestError) HTTPStatus() int {
return err.Status
}
// SessionInvalid reports the one legacy validation response that means a Run
// session must be renewed. Component-authenticated sessions return 401 for the
// same condition; older endpoint sessions return this safe 400 response.
func (err PlatformRequestError) SessionInvalid() bool {
if err.Status == http.StatusUnauthorized {
return true
}
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
return false
}
for _, detail := range err.Details {
if strings.TrimSpace(detail) == "sessionToken is invalid" {
return true
}
}
return false
}
func (err PlatformRequestError) LogBatchSequenceGap() bool {
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
return false
}
for _, detail := range err.Details {
if strings.TrimSpace(detail) == "log batch firstSeq must follow latest acknowledged sequence" {
return true
}
}
return false
}
func (err PlatformRequestError) LogBatchAcknowledgedRangeConflict() bool {
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
return false
}
for _, detail := range err.Details {
if strings.TrimSpace(detail) == "log batch conflicts with acknowledged range" {
return true
}
}
return false
}
func (err PlatformRequestError) LogBatchLegacySessionMetadata() bool {
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
return false
}
for _, detail := range err.Details {
if strings.TrimSpace(detail) == "logSessionId and sessionStartedAt must be provided together" {
return true
}
}
return false
}
func (err PlatformRequestError) LogBatchSessionMetadataMismatch() bool {
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
return false
}
for _, detail := range err.Details {
if strings.TrimSpace(detail) == "log session metadata must match stream" {
return true
}
}
return false
}
func NewPlatformClient(rawURL string) (PlatformClient, error) {
return NewPlatformClientWithHTTPClient(rawURL, http.DefaultClient)
}
func NewPlatformClientWithHTTPClient(rawURL string, httpClient *http.Client) (PlatformClient, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return PlatformClient{}, err
}
if parsed.Scheme == "" || parsed.Host == "" {
return PlatformClient{}, fmt.Errorf("platform URL must include scheme and host")
}
if httpClient == nil {
httpClient = http.DefaultClient
}
return PlatformClient{baseURL: strings.TrimRight(parsed.String(), "/"), httpClient: httpClient}, nil
}
func (c PlatformClient) BaseURL() string {
return c.baseURL
}
func (c PlatformClient) Hello(ctx context.Context, request protocol.RunHelloRequest) (protocol.RunHelloResponse, error) {
return postPlatformJSON[protocol.RunHelloRequest, protocol.RunHelloResponse](ctx, c, "/api/v1/run/control/hello", request)
}
func (c PlatformClient) Heartbeat(ctx context.Context, request protocol.RunHeartbeatRequest) (protocol.RunHeartbeatResponse, error) {
return postPlatformJSON[protocol.RunHeartbeatRequest, protocol.RunHeartbeatResponse](ctx, c, "/api/v1/run/control/heartbeat", request)
}
func (c PlatformClient) ReportLifecycle(ctx context.Context, request protocol.RunLifecycleReportRequest) (protocol.RunLifecycleReportResponse, error) {
return postPlatformJSON[protocol.RunLifecycleReportRequest, protocol.RunLifecycleReportResponse](ctx, c, "/api/v1/run/lifecycle/report", request)
}
func (c PlatformClient) ClaimJob(ctx context.Context, request protocol.RunJobClaimRequest) (protocol.RunJobClaimResponse, error) {
return postPlatformJSON[protocol.RunJobClaimRequest, protocol.RunJobClaimResponse](ctx, c, "/api/v1/run/jobs/claim", request)
}
func (c PlatformClient) AckJob(ctx context.Context, request protocol.RunJobAckRequest) (protocol.RunJobAckResponse, error) {
return postPlatformJSON[protocol.RunJobAckRequest, protocol.RunJobAckResponse](ctx, c, "/api/v1/run/jobs/ack", request)
}
func (c PlatformClient) UpdateJobProgress(ctx context.Context, request protocol.RunJobProgressRequest) (protocol.RunJobProgressResponse, error) {
return postPlatformJSON[protocol.RunJobProgressRequest, protocol.RunJobProgressResponse](ctx, c, "/api/v1/run/jobs/progress", request)
}
func (c PlatformClient) CompleteJob(ctx context.Context, request protocol.RunJobResultRequest) (protocol.RunJobResultResponse, error) {
return postPlatformJSON[protocol.RunJobResultRequest, protocol.RunJobResultResponse](ctx, c, "/api/v1/run/jobs/result", request)
}
func (c PlatformClient) GetDistributionBuildInput(ctx context.Context, request protocol.DistributionBuildInputRequest) (protocol.DistributionBuildInputResponse, error) {
return postPlatformJSON[protocol.DistributionBuildInputRequest, protocol.DistributionBuildInputResponse](ctx, c, "/api/v1/run/jobs/build-input", request)
}
func (c PlatformClient) GetDependencyExecutionInput(ctx context.Context, request protocol.DependencyExecutionInputRequest) (protocol.DependencyExecutionInputResponse, error) {
return postPlatformJSON[protocol.DependencyExecutionInputRequest, protocol.DependencyExecutionInputResponse](ctx, c, "/api/v1/run/jobs/dependency-input", request)
}
func (c PlatformClient) GetSourceRCONExecutionInput(ctx context.Context, request protocol.SourceRCONExecutionInputRequest) (protocol.SourceRCONExecutionInputResponse, error) {
return postPlatformJSON[protocol.SourceRCONExecutionInputRequest, protocol.SourceRCONExecutionInputResponse](ctx, c, "/api/v1/run/jobs/source-rcon-input", request)
}
func (c PlatformClient) GetProtectedRequestExecutionInput(ctx context.Context, request protocol.ProtectedRequestExecutionInputRequest) (protocol.ProtectedRequestExecutionInputResponse, error) {
return postPlatformJSON[protocol.ProtectedRequestExecutionInputRequest, protocol.ProtectedRequestExecutionInputResponse](ctx, c, "/api/v1/run/jobs/protected-request-input", request)
}
func (c PlatformClient) GetRunUpdateInput(ctx context.Context, request protocol.RunUpdateInputRequest) (protocol.RunUpdateInputResponse, error) {
return postPlatformJSON[protocol.RunUpdateInputRequest, protocol.RunUpdateInputResponse](ctx, c, "/api/v1/run/jobs/update-input", request)
}
func (c PlatformClient) ReadRunUpdateChunk(ctx context.Context, request protocol.RunUpdateChunkRequest) (protocol.RunUpdateChunkResponse, error) {
return postPlatformJSON[protocol.RunUpdateChunkRequest, protocol.RunUpdateChunkResponse](ctx, c, "/api/v1/run/jobs/update-chunk", request)
}
func (c PlatformClient) ReportRunUpdateHealth(ctx context.Context, request protocol.RunUpdateHealthRequest) (protocol.RunUpdateHealthResponse, error) {
return postPlatformJSON[protocol.RunUpdateHealthRequest, protocol.RunUpdateHealthResponse](ctx, c, "/api/v1/run/jobs/update-health", request)
}
func (c PlatformClient) PollJobCancel(ctx context.Context, request protocol.RunJobCancelPollRequest) (protocol.RunJobCancelPollResponse, error) {
return postPlatformJSON[protocol.RunJobCancelPollRequest, protocol.RunJobCancelPollResponse](ctx, c, "/api/v1/run/jobs/cancel", request)
}
func (c PlatformClient) ReconcileJobs(ctx context.Context, request protocol.RunJobReconcileRequest) (protocol.RunJobReconcileResponse, error) {
return postPlatformJSON[protocol.RunJobReconcileRequest, protocol.RunJobReconcileResponse](ctx, c, "/api/v1/run/jobs/reconcile", request)
}
func (c PlatformClient) IngestLogBatch(ctx context.Context, request protocol.LogBatchIngestRequest) (protocol.LogBatchIngestResponse, error) {
return postPlatformJSON[protocol.LogBatchIngestRequest, protocol.LogBatchIngestResponse](ctx, c, "/api/v1/run/logs/batches", request)
}
func (c PlatformClient) GetRunLogStreamProgress(ctx context.Context, request protocol.RunLogStreamProgressRequest) (protocol.RunLogStreamProgressResponse, error) {
return postPlatformJSON[protocol.RunLogStreamProgressRequest, protocol.RunLogStreamProgressResponse](ctx, c, "/api/v1/run/logs/progress", request)
}
func (c PlatformClient) IngestMetricBatch(ctx context.Context, request protocol.MetricBatchIngestRequest) (protocol.MetricBatchIngestResponse, error) {
return postPlatformJSON[protocol.MetricBatchIngestRequest, protocol.MetricBatchIngestResponse](ctx, c, "/api/v1/run/metrics/batches", request)
}
func (c PlatformClient) OpenArtifactTransfer(ctx context.Context, request protocol.ArtifactTransferOpenRequest) (protocol.ArtifactTransferOpenResponse, error) {
return postPlatformJSON[protocol.ArtifactTransferOpenRequest, protocol.ArtifactTransferOpenResponse](ctx, c, "/api/v1/run/artifacts/open", request)
}
func (c PlatformClient) UploadArtifactChunk(ctx context.Context, request protocol.ArtifactChunkUploadRequest) (protocol.ArtifactChunkUploadResponse, error) {
return postPlatformJSON[protocol.ArtifactChunkUploadRequest, protocol.ArtifactChunkUploadResponse](ctx, c, "/api/v1/run/artifacts/chunks", request)
}
func (c PlatformClient) QueryArtifactTransferStatus(ctx context.Context, request protocol.ArtifactTransferStatusRequest) (protocol.ArtifactTransferStatusResponse, error) {
return postPlatformJSON[protocol.ArtifactTransferStatusRequest, protocol.ArtifactTransferStatusResponse](ctx, c, "/api/v1/run/artifacts/status", request)
}
func (c PlatformClient) CompleteArtifactTransfer(ctx context.Context, request protocol.ArtifactTransferCompleteRequest) (protocol.ArtifactTransferCompleteResponse, error) {
return postPlatformJSON[protocol.ArtifactTransferCompleteRequest, protocol.ArtifactTransferCompleteResponse](ctx, c, "/api/v1/run/artifacts/complete", request)
}
func postPlatformJSON[Request any, Response any](ctx context.Context, client PlatformClient, path string, request Request) (Response, error) {
var response Response
startedAt := time.Now()
log.Printf("RUN platform request status=starting method=POST base=%s path=%s", diagnosticLogValue(client.baseURL), path)
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(request); err != nil {
log.Printf("RUN platform request status=encode_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
return response, fmt.Errorf("encode platform request: %w", err)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+path, &body)
if err != nil {
log.Printf("RUN platform request status=build_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
return response, fmt.Errorf("build platform request: %w", err)
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "application/json")
if path != "/api/v1/run/control/hello" {
signatureSummary, err := signRunRequest(httpRequest, body.Bytes())
if err != nil {
log.Printf("RUN platform request status=sign_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
return response, err
}
log.Printf("RUN platform request status=signed method=POST base=%s path=%s endpoint=%s timestamp=%s nonce=%s bodyHash=%s signature=%s", diagnosticLogValue(client.baseURL), path, diagnosticLogValue(signatureSummary.RunEndpointID), signatureSummary.Timestamp, shortDiagnosticValue(signatureSummary.Nonce), shortDiagnosticValue(signatureSummary.BodyHash), shortDiagnosticValue(signatureSummary.Signature))
}
httpResponse, err := client.httpClient.Do(httpRequest)
if err != nil {
log.Printf("RUN platform request status=send_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
return response, fmt.Errorf("send platform request: %w", err)
}
defer httpResponse.Body.Close()
log.Printf("RUN platform request status=response method=POST base=%s path=%s httpStatus=%d durationMs=%d", diagnosticLogValue(client.baseURL), path, httpResponse.StatusCode, time.Since(startedAt).Milliseconds())
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
var failure struct {
Code string `json:"code"`
Details []string `json:"details"`
}
_ = json.NewDecoder(io.LimitReader(httpResponse.Body, 64<<10)).Decode(&failure)
return response, PlatformRequestError{Status: httpResponse.StatusCode, Path: path, Code: failure.Code, Details: failure.Details}
}
if err := json.NewDecoder(httpResponse.Body).Decode(&response); err != nil {
return response, fmt.Errorf("decode platform response: %w", err)
}
return response, nil
}
type runRequestEnvelope struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
}
type runRequestSignatureSummary struct {
RunEndpointID string
Timestamp string
Nonce string
BodyHash string
Signature string
}
func signRunRequest(request *http.Request, body []byte) (runRequestSignatureSummary, error) {
var envelope runRequestEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
return runRequestSignatureSummary{}, fmt.Errorf("decode Run signing envelope: %w", err)
}
if strings.TrimSpace(envelope.RunEndpointID) == "" || strings.TrimSpace(envelope.SessionToken) == "" {
return runRequestSignatureSummary{}, fmt.Errorf("Run signing envelope requires endpoint and session token")
}
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return runRequestSignatureSummary{}, fmt.Errorf("create Run request nonce: %w", err)
}
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
nonce := hex.EncodeToString(nonceBytes)
bodyHash := sha256.Sum256(body)
bodyHashHex := hex.EncodeToString(bodyHash[:])
canonical := strings.Join([]string{request.Method, request.URL.Path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha256.New, []byte(envelope.SessionToken))
_, _ = mac.Write([]byte(canonical))
signature := hex.EncodeToString(mac.Sum(nil))
request.Header.Set("X-Run-Endpoint", envelope.RunEndpointID)
request.Header.Set("X-Run-Timestamp", timestamp)
request.Header.Set("X-Run-Nonce", nonce)
request.Header.Set("X-Run-Signature", signature)
return runRequestSignatureSummary{RunEndpointID: envelope.RunEndpointID, Timestamp: timestamp, Nonce: nonce, BodyHash: bodyHashHex, Signature: signature}, nil
}
func shortDiagnosticValue(value string) string {
value = diagnosticLogValue(value)
if value == "" {
return "-"
}
if len(value) <= 16 {
return value
}
return value[:12] + "..." + value[len(value)-4:]
}
func diagnosticLogValue(value string) string {
value = strings.TrimSpace(value)
return strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(value)
}
+380
View File
@@ -0,0 +1,380 @@
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 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,
},
}
}