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

178 lines
11 KiB
Go

package api
import (
"bytes"
"net/http"
"net/http/httptest"
"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 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)
}
}
}