164 lines
8.4 KiB
Go
164 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"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 := performArtifactChunkUpload(t, router, 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 := performArtifactChunkUpload(t, router, 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 := performArtifactChunkUpload(t, router, 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 := performArtifactChunkUpload(t, router, 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 performArtifactChunkUpload(t *testing.T, router http.Handler, request dto.ArtifactChunkUploadRequest) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/run/artifacts/chunks", bytes.NewReader(request.Payload))
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
req.Header.Set("X-Run-Endpoint", request.RunEndpointID)
|
|
req.Header.Set("X-Run-Session-Token", request.SessionToken)
|
|
req.Header.Set("X-Artifact-Transfer-Id", request.TransferID)
|
|
req.Header.Set("X-Artifact-Id", request.ArtifactID)
|
|
req.Header.Set("X-Artifact-Chunk-Index", strconv.Itoa(request.ChunkIndex))
|
|
req.Header.Set("X-Artifact-Offset", strconv.FormatInt(request.Offset, 10))
|
|
req.Header.Set("X-Artifact-Size", strconv.Itoa(request.SizeBytes))
|
|
req.Header.Set("X-Artifact-Checksum", request.Checksum)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
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,
|
|
}
|
|
}
|