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, } }