200 lines
12 KiB
Go
200 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/dto"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
func TestArtifactDownloadAPIWorkflowIsPlatformMediated(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
|
|
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download API"}, adminSession)
|
|
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-api", ServerInstanceID: "server-download-api", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-api"})
|
|
|
|
payload := []byte("artifact payload for browser mediated download")
|
|
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-api", "job-download-api", payload, 9)
|
|
|
|
reference := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/artifacts/artifact-download-api/download", map[string]string{}, adminSession)
|
|
if reference.ArtifactID != "artifact-download-api" || reference.DownloadURL != "/api/v1/artifacts/artifact-download-api/content" || !reference.RangeSupported || reference.ChunkSizeBytes != validator.MaxArtifactDownloadBytes {
|
|
t.Fatalf("unexpected artifact reference: %+v", reference)
|
|
}
|
|
if reference.Checksum != validator.BytesChecksum(payload) || reference.SizeBytes != int64(len(payload)) || reference.StorageBehavior == "" {
|
|
t.Fatalf("expected integrity metadata in reference, got %+v", reference)
|
|
}
|
|
|
|
contentRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-api/content?offset=9&limit=7", "", adminSession)
|
|
assertStatus(t, contentRecorder, http.StatusPartialContent)
|
|
if got, want := contentRecorder.Body.Bytes(), payload[9:16]; !bytes.Equal(got, want) {
|
|
t.Fatalf("expected range payload %q, got %q", want, got)
|
|
}
|
|
if contentRecorder.Header().Get("Content-Range") != "bytes 9-15/46" || contentRecorder.Header().Get("X-Artifact-Checksum") != validator.BytesChecksum(payload) || contentRecorder.Header().Get("X-Artifact-Content-Checksum") != validator.BytesChecksum(payload[9:16]) {
|
|
t.Fatalf("expected safe integrity headers, got %+v", contentRecorder.Header())
|
|
}
|
|
|
|
rangeRequest := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/artifact-download-api/content", nil)
|
|
rangeRequest.Header.Set("Authorization", "Bearer "+adminSession)
|
|
rangeRequest.Header.Set("Range", "bytes=0-7")
|
|
rangeRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(rangeRecorder, rangeRequest)
|
|
assertStatus(t, rangeRecorder, http.StatusPartialContent)
|
|
if !bytes.Equal(rangeRecorder.Body.Bytes(), payload[:8]) {
|
|
t.Fatalf("expected range header payload, got %q", rangeRecorder.Body.String())
|
|
}
|
|
|
|
for _, body := range []string{mustJSON(t, reference), contentRecorder.Header().Get("Content-Disposition"), contentRecorder.Header().Get("X-Artifact-Storage")} {
|
|
assertNoArtifactForbiddenFragments(t, body)
|
|
}
|
|
}
|
|
|
|
func TestArtifactContentWithoutRangeReturnsWholePayload(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
|
|
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-full", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download Full"}, adminSession)
|
|
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-full", ServerInstanceID: "server-download-full", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-full"})
|
|
|
|
payload := bytes.Repeat([]byte("full-download-payload-"), (validator.MaxArtifactDownloadBytes/len("full-download-payload-"))+2)
|
|
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-full", "job-download-full", payload, validator.MaxArtifactChunkBytes)
|
|
|
|
contentRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-full/content", "", adminSession)
|
|
assertStatus(t, contentRecorder, http.StatusOK)
|
|
if got := contentRecorder.Body.Bytes(); !bytes.Equal(got, payload) {
|
|
t.Fatalf("expected whole payload length %d, got %d", len(payload), len(got))
|
|
}
|
|
if contentRecorder.Header().Get("Content-Range") != "" || contentRecorder.Header().Get("Content-Length") != strconv.Itoa(len(payload)) {
|
|
t.Fatalf("expected full content headers, got %+v", contentRecorder.Header())
|
|
}
|
|
}
|
|
|
|
func TestArtifactDownloadAPIDeniesUnavailableAndUnauthorizedArtifacts(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-download-other",
|
|
DisplayName: "Other Operator",
|
|
Email: "download-other@example.test",
|
|
Roles: []string{"server-admin"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "download-other@example.test", Password: "secret-password"}).SessionID
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
|
|
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-denied", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download Denied"}, adminSession)
|
|
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-denied", ServerInstanceID: "server-download-denied", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-denied"})
|
|
|
|
payload := []byte("download denied payload")
|
|
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-denied", "job-download-denied", payload, 8)
|
|
|
|
unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-download-denied/download", map[string]string{}, otherSession)
|
|
assertErrorResponse(t, unauthorized, http.StatusForbidden, errorCodeForbidden)
|
|
unauthorizedContent := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-denied/content?limit=8", "", otherSession)
|
|
assertErrorResponse(t, unauthorizedContent, http.StatusForbidden, errorCodeForbidden)
|
|
|
|
unavailable := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ID: "artifact-uploading-denied", OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-download-denied", SizeBytes: 12, Checksum: validator.BytesChecksum([]byte("not-complete!"))})
|
|
if unavailable.State != domain.ArtifactStateUploading {
|
|
t.Fatalf("expected uploading metadata, got %+v", unavailable)
|
|
}
|
|
unavailableDownload := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-uploading-denied/download", map[string]string{}, adminSession)
|
|
assertErrorResponse(t, unavailableDownload, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
for _, body := range []string{unauthorized.Body.String(), unauthorizedContent.Body.String(), unavailableDownload.Body.String()} {
|
|
assertNoArtifactForbiddenFragments(t, body)
|
|
}
|
|
}
|
|
|
|
func TestPluginBridgeArtifactOpenReturnsSafeReference(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
|
|
registration := validGamePluginManifestRegistrationRequest()
|
|
registration.Manifest.Bridge.Actions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)}
|
|
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.artifacts.read"}
|
|
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)}
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-bridge-artifact", PluginID: "game.example", RunEndpointID: "run-local", Name: "Bridge Artifact"}, adminSession)
|
|
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-bridge-artifact", ServerInstanceID: instance.ID, RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-bridge-artifact"})
|
|
|
|
payload := []byte("bridge artifact reference payload")
|
|
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-bridge-open", "job-bridge-artifact", payload, 8)
|
|
|
|
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-artifact-open",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionArtifactsOpen),
|
|
Payload: map[string]string{"artifactId": "artifact-bridge-open"},
|
|
}, adminSession)
|
|
if bridge.Status != "ok" || bridge.Result["downloadUrl"] != "/api/v1/artifacts/artifact-bridge-open/content" || bridge.Result["sizeBytes"] == "" || bridge.Result["checksum"] != validator.BytesChecksum(payload) {
|
|
t.Fatalf("expected safe artifact reference through bridge, got %+v", bridge)
|
|
}
|
|
assertNoArtifactForbiddenFragments(t, mustJSON(t, bridge))
|
|
}
|
|
|
|
func uploadCompletedArtifact(t *testing.T, router http.Handler, sessionToken string, artifactID string, jobID string, payload []byte, chunkSize int) dto.ArtifactTransferCompleteResponse {
|
|
t.Helper()
|
|
open := dto.ArtifactTransferOpenRequest{
|
|
RunEndpointID: "run-local",
|
|
SessionToken: sessionToken,
|
|
ArtifactID: artifactID,
|
|
Direction: domain.ArtifactTransferDirectionUpload,
|
|
OwnerKind: domain.ArtifactOwnerKindJob,
|
|
OwnerID: jobID,
|
|
SizeBytes: int64(len(payload)),
|
|
ChunkSizeBytes: chunkSize,
|
|
Checksum: validator.BytesChecksum(payload),
|
|
IdempotencyKey: artifactID + "-upload",
|
|
}
|
|
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, open))
|
|
for index := 0; index < opened.TotalChunks; index++ {
|
|
offset := index * chunkSize
|
|
end := offset + chunkSize
|
|
if end > len(payload) {
|
|
end = len(payload)
|
|
}
|
|
part := payload[offset:end]
|
|
chunk := dto.ArtifactChunkUploadRequest{
|
|
RunEndpointID: "run-local",
|
|
SessionToken: sessionToken,
|
|
TransferID: opened.TransferID,
|
|
ArtifactID: artifactID,
|
|
ChunkIndex: index,
|
|
Offset: int64(offset),
|
|
SizeBytes: len(part),
|
|
Checksum: validator.BytesChecksum(part),
|
|
Payload: part,
|
|
}
|
|
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", chunk), http.StatusOK)
|
|
}
|
|
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: artifactID, Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
|
|
assertStatus(t, completeRecorder, http.StatusOK)
|
|
return decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder)
|
|
}
|
|
|
|
func artifactDownloadHelloRequest() dto.RunControlHelloRequest {
|
|
request := validRunControlHelloRequest()
|
|
request.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}
|
|
request.CapabilityReport.Fingerprint = "cap-artifact-download"
|
|
return request
|
|
}
|
|
|
|
func assertNoArtifactForbiddenFragments(t *testing.T, body string) {
|
|
t.Helper()
|
|
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "storage://", "file://"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("artifact response exposed forbidden fragment %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|