Files
browser/platform/api/artifact_transfer_handlers_test.go
T
2026-07-11 14:56:10 +08:00

145 lines
7.6 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestArtifactTransferAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
payload := []byte("artifact payload for api upload")
openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", openRequest)
assertStatus(t, openRecorder, http.StatusOK)
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.Artifact.State != domain.ArtifactStateUploading {
t.Fatalf("unexpected open response: %+v", opened)
}
chunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
assertStatus(t, chunkRecorder, http.StatusOK)
chunk := decodeBody[dto.ArtifactChunkUploadResponse](t, chunkRecorder)
if !chunk.Accepted || chunk.NextMissingChunkIndex != 1 || len(chunk.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected chunk response: %+v", chunk)
}
duplicateRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
assertStatus(t, duplicateRecorder, http.StatusOK)
duplicate := decodeBody[dto.ArtifactChunkUploadResponse](t, duplicateRecorder)
if !duplicate.Duplicate {
t.Fatalf("expected duplicate chunk ack, got %+v", duplicate)
}
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"})
assertStatus(t, statusRecorder, http.StatusOK)
status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder)
if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected status response: %+v", status)
}
missingComplete := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertErrorResponse(t, missingComplete, http.StatusBadRequest, errorCodeValidation)
for index := 1; index < opened.TotalChunks; index++ {
partRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, index, 8))
assertStatus(t, partRecorder, http.StatusOK)
}
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertStatus(t, completeRecorder, http.StatusOK)
complete := decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder)
if !complete.Accepted || !complete.Completed || complete.Artifact.State != domain.ArtifactStateAvailable {
t.Fatalf("unexpected complete response: %+v", complete)
}
}
func TestArtifactTransferAPIErrors(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
payload := []byte("artifact payload")
openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, openRequest))
badChunk := validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8)
badChunk.Checksum = validator.BytesChecksum([]byte("different"))
badChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", badChunk)
assertErrorResponse(t, badChunkRecorder, http.StatusBadRequest, errorCodeValidation)
invalidSession := validArtifactTransferOpenRequest("stale-token", payload, 8)
invalidSession.ArtifactID = "artifact-invalid-session"
invalidSession.IdempotencyKey = "artifact-invalid-session"
invalidSessionRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidSession)
assertErrorResponse(t, invalidSessionRecorder, http.StatusBadRequest, errorCodeValidation)
invalidOwner := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
invalidOwner.OwnerKind = domain.ArtifactOwnerKindPlatform
invalidOwner.ArtifactID = "artifact-invalid-owner"
invalidOwner.IdempotencyKey = "artifact-invalid-owner"
invalidOwnerRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidOwner)
assertErrorResponse(t, invalidOwnerRecorder, http.StatusBadRequest, errorCodeValidation)
methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/artifacts/open", "")
assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func createArtifactTransferAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse {
t.Helper()
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read", "files.read")
helloRequest.CapabilityReport.Fingerprint = "cap-artifacts"
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
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.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-start"})
return hello
}
func performArtifactTransferOpen(t *testing.T, router http.Handler, request dto.ArtifactTransferOpenRequest) *httptest.ResponseRecorder {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", request)
assertStatus(t, recorder, http.StatusOK)
return recorder
}
func validArtifactTransferOpenRequest(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
return dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: "artifact-1",
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: "job-1",
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: "artifact-upload-1",
}
}
func validArtifactChunkRequest(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-1",
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
}