first commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func TestRunChannelAPIInterleavedRequestsMutateIndependentState(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createArtifactTransferAPIFixtures(t, router)
|
||||
createOnlyLogStreamForChannelIsolation(t, router)
|
||||
|
||||
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Capabilities: []string{"process.start"},
|
||||
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
||||
})
|
||||
assertStatus(t, claimRecorder, http.StatusOK)
|
||||
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
|
||||
if !claim.HasJob || claim.Job.JobID != "job-1" {
|
||||
t.Fatalf("expected claimed job, got %+v", claim)
|
||||
}
|
||||
|
||||
payload := []byte("interleaved artifact payload for api isolation")
|
||||
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", artifactOpenForChannelIsolation(hello.SessionToken, payload, 8))
|
||||
assertStatus(t, openRecorder, http.StatusOK)
|
||||
open := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
|
||||
firstChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", artifactChunkForChannelIsolation(hello.SessionToken, open.TransferID, payload, 0, 8))
|
||||
assertStatus(t, firstChunkRecorder, http.StatusOK)
|
||||
firstChunk := decodeBody[dto.ArtifactChunkUploadResponse](t, firstChunkRecorder)
|
||||
if !firstChunk.Accepted || firstChunk.NextMissingChunkIndex != 1 {
|
||||
t.Fatalf("expected first artifact chunk ack, got %+v", firstChunk)
|
||||
}
|
||||
|
||||
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Version: "0.1.1",
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
CapabilityFingerprint: "cap-artifacts",
|
||||
Capacity: dto.RunCapacityResponse{MaxJobs: 4, RunningJobs: 1},
|
||||
})
|
||||
assertStatus(t, heartbeatRecorder, http.StatusOK)
|
||||
heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder)
|
||||
if !heartbeat.Accepted {
|
||||
t.Fatalf("expected heartbeat accepted, got %+v", heartbeat)
|
||||
}
|
||||
|
||||
ack := postRunJobAck(t, router, dto.RunJobAckRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
Message: "job accepted while artifact transfer is active",
|
||||
})
|
||||
if ack.Job.State != domain.JobStateRunning {
|
||||
t.Fatalf("expected running job after ack, got %+v", ack)
|
||||
}
|
||||
|
||||
logBatch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
|
||||
logBatch.LogStreamID = "log-channel-isolation"
|
||||
logRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", logBatch)
|
||||
assertStatus(t, logRecorder, http.StatusOK)
|
||||
logAck := decodeBody[dto.LogBatchIngestResponse](t, logRecorder)
|
||||
if !logAck.Accepted || logAck.LatestSeq != 1 {
|
||||
t.Fatalf("expected log ack independent from artifact transfer, got %+v", logAck)
|
||||
}
|
||||
|
||||
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: domain.JobStateSucceeded,
|
||||
Progress: dto.JobProgressBody{Percent: 100, Message: "done"},
|
||||
ResultRef: "artifact://jobs/job-1/result",
|
||||
Message: "done",
|
||||
})
|
||||
assertStatus(t, resultRecorder, http.StatusOK)
|
||||
result := decodeBody[dto.RunJobResultResponse](t, resultRecorder)
|
||||
if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef == "" {
|
||||
t.Fatalf("expected terminal result independent from transfer, got %+v", result)
|
||||
}
|
||||
|
||||
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
TransferID: open.TransferID,
|
||||
ArtifactID: "artifact-channel-isolation",
|
||||
})
|
||||
assertStatus(t, statusRecorder, http.StatusOK)
|
||||
status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder)
|
||||
if status.Completed || status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
|
||||
t.Fatalf("artifact state should remain independent after heartbeat/job/log calls, got %+v", status)
|
||||
}
|
||||
stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-channel-isolation")
|
||||
if stream.LatestSeq != 1 {
|
||||
t.Fatalf("expected log stream latest seq updated independently, got %+v", stream)
|
||||
}
|
||||
job := getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1")
|
||||
if job.State != domain.JobStateSucceeded {
|
||||
t.Fatalf("expected job terminal state preserved, got %+v", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightweightRunRoutesRejectHeavyChannelPayloads(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createArtifactTransferAPIFixtures(t, router)
|
||||
createOnlyLogStreamForChannelIsolation(t, router)
|
||||
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
||||
ID: "job-heavy-payload",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: "process.start",
|
||||
IdempotencyKey: "heavy-payload-job",
|
||||
})
|
||||
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Capabilities: []string{"process.start"},
|
||||
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
||||
})
|
||||
assertStatus(t, claimRecorder, http.StatusOK)
|
||||
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "heartbeat rejects artifact chunk fields",
|
||||
path: "/api/v1/run/control/heartbeat",
|
||||
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4},"payload":"AAAA","transferId":"transfer-1","hostPath":"/Users/tasia/server"}`, hello.SessionToken),
|
||||
},
|
||||
{
|
||||
name: "job result rejects inline logs and sockets",
|
||||
path: "/api/v1/run/jobs/result",
|
||||
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"jobId":%q,"leaseToken":%q,"attempt":%d,"state":"succeeded","progress":{"percent":100},"resultRef":"artifact://jobs/job-heavy-payload/result","entries":[{"seq":1,"line":"log"}],"directSocket":"unix:///tmp/run.sock"}`, hello.SessionToken, claim.Job.JobID, claim.Job.LeaseToken, claim.Job.Attempt),
|
||||
},
|
||||
{
|
||||
name: "log ingest rejects artifact transfer payload",
|
||||
path: "/api/v1/run/logs/batches",
|
||||
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"logStreamId":"log-channel-isolation","serverInstanceId":"server-1","streamKey":"stdout","source":"process","firstSeq":1,"lastSeq":1,"compression":"none","checksum":"sha256:bad","entries":[{"seq":1,"timestamp":"2026-07-03T12:00:01Z","line":"line"}],"payload":"AAAA","transferId":"transfer-1"}`, hello.SessionToken),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := performRaw(t, router, http.MethodPost, tc.path, tc.body)
|
||||
assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createLogStreamForChannelIsolation(t *testing.T, router http.Handler) {
|
||||
t.Helper()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-1",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM #1",
|
||||
}, adminSession)
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-channel-isolation",
|
||||
ServerInstanceID: "server-1",
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
StreamKey: "stdout",
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||
RetentionPolicy: "default",
|
||||
})
|
||||
}
|
||||
|
||||
func createOnlyLogStreamForChannelIsolation(t *testing.T, router http.Handler) {
|
||||
t.Helper()
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-channel-isolation",
|
||||
ServerInstanceID: "server-1",
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
StreamKey: "stdout",
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||
RetentionPolicy: "default",
|
||||
})
|
||||
}
|
||||
|
||||
func artifactOpenForChannelIsolation(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
|
||||
return dto.ArtifactTransferOpenRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
ArtifactID: "artifact-channel-isolation",
|
||||
Direction: domain.ArtifactTransferDirectionUpload,
|
||||
OwnerKind: domain.ArtifactOwnerKindJob,
|
||||
OwnerID: "job-1",
|
||||
SizeBytes: int64(len(payload)),
|
||||
ChunkSizeBytes: chunkSize,
|
||||
Checksum: validator.BytesChecksum(payload),
|
||||
IdempotencyKey: "artifact-channel-isolation",
|
||||
}
|
||||
}
|
||||
|
||||
func artifactChunkForChannelIsolation(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest {
|
||||
offset := index * chunkSize
|
||||
end := offset + chunkSize
|
||||
if end > len(payload) {
|
||||
end = len(payload)
|
||||
}
|
||||
part := payload[offset:end]
|
||||
return dto.ArtifactChunkUploadRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
TransferID: transferID,
|
||||
ArtifactID: "artifact-channel-isolation",
|
||||
ChunkIndex: index,
|
||||
Offset: int64(offset),
|
||||
SizeBytes: len(part),
|
||||
Checksum: validator.BytesChecksum(part),
|
||||
Payload: part,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChannelAPIHeavyPayloadRejectionsDoNotMutateState(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
|
||||
|
||||
recorder := performRaw(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4,"runningJobs":1},"payload":"AAAA"}`, hello.SessionToken))
|
||||
assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest)
|
||||
|
||||
endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local")
|
||||
if endpoint.Capacity.RunningJobs != 0 || strings.Contains(endpoint.Capacity.Summary, "AAAA") {
|
||||
t.Fatalf("rejected heartbeat must not mutate endpoint capacity or store heavy payload, got %+v", endpoint)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user