diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 07:37:51 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 07:37:51 +0800 |
| commit | 56800fbbd4488db20abd4f44f0d39e48599be0ab (patch) | |
| tree | f32c815543000daf4c2ad33e534bb806e0555c3f /tests/SomhairlesDream.Server.Tests/ApiTests.fs | |
| parent | e8eebe49407de8947bf509d5cf04d9080ee4edf7 (diff) | |
| download | somhairles-dream-fsharp-56800fbbd4488db20abd4f44f0d39e48599be0ab.tar.gz | |
Secure artifact serving: manifest/checkpoint membership, hash validation, link rejection
Diffstat (limited to 'tests/SomhairlesDream.Server.Tests/ApiTests.fs')
| -rw-r--r-- | tests/SomhairlesDream.Server.Tests/ApiTests.fs | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/tests/SomhairlesDream.Server.Tests/ApiTests.fs b/tests/SomhairlesDream.Server.Tests/ApiTests.fs index f51ca3d..be6a6cb 100644 --- a/tests/SomhairlesDream.Server.Tests/ApiTests.fs +++ b/tests/SomhairlesDream.Server.Tests/ApiTests.fs @@ -266,6 +266,242 @@ let ``step artifacts stream while a run is still active`` () = gate.Set() gate.Dispose() +let private glbBytes (index: byte) = [| 0x67uy; 0x6Cuy; 0x54uy; index |] +let private runDirectory (coordinator: ArtifactRunCoordinator) (runId: string) = + Path.Combine(coordinator.ArtifactRoot, ids.ProjectId, runId) + +let private getArtifact (client: HttpClient) endpoint runId relativePath = + let encoded = Uri.EscapeDataString(relativePath) + + client + .GetAsync($"/api/artifacts/{endpoint}?projectId={ids.ProjectId}&runId={runId}&path={encoded}") + .GetAwaiter() + .GetResult() + +let private waitForTerminal (coordinator: ArtifactRunCoordinator) runId = + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let status = store.Snapshot().Status + status = "complete" || status = "failed")) + ) + +[<Fact>] +let ``file endpoint serves only published manifest members`` () = + withApp (fun client coordinator -> + let runId = "run-api-scope" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbResponse = getArtifact client "file" runId "steps/01-foundation.glb" + + Assert.Equal(HttpStatusCode.OK, glbResponse.StatusCode) + Assert.Equal("model/gltf-binary", glbResponse.Content.Headers.ContentType.MediaType) + + let bytes = glbResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + Assert.True((glbBytes 0uy = bytes), "file endpoint should serve the published glb bytes") + + let stepResponse = getArtifact client "steps" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.OK, stepResponse.StatusCode) + + let logResponse = getArtifact client "file" runId "logs/01-foundation.log" + Assert.Equal(HttpStatusCode.NotFound, logResponse.StatusCode) + + let manifestResponse = getArtifact client "file" runId "manifest.json" + Assert.Equal(HttpStatusCode.NotFound, manifestResponse.StatusCode) + + File.WriteAllText(Path.Combine(runDirectory coordinator runId, "extra-secret.txt"), "hidden") + let extraResponse = getArtifact client "file" runId "extra-secret.txt" + Assert.Equal(HttpStatusCode.NotFound, extraResponse.StatusCode)) + +[<Fact>] +let ``file endpoint rejects tampered published artifacts`` () = + withApp (fun client coordinator -> + let runId = "run-api-tampered" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + File.WriteAllBytes(glbPath, glbBytes 0x09uy) + + let response = getArtifact client "file" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode)) + +[<Fact>] +let ``file endpoint rejects file symlink escapes`` () = + withApp (fun client coordinator -> + let runId = "run-api-filelink" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + let original = File.ReadAllBytes(glbPath) + let escapeTarget = Path.Combine(coordinator.ArtifactRoot, "escape-target.glb") + File.WriteAllBytes(escapeTarget, original) + + File.Delete(glbPath) + File.CreateSymbolicLink(glbPath, escapeTarget) |> ignore + + let response = getArtifact client "file" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)) + +[<Fact>] +let ``file endpoint rejects intermediate directory symlink escapes`` () = + withApp (fun client coordinator -> + let runId = "run-api-dirlink" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + let original = File.ReadAllBytes(glbPath) + let escapeDirectory = Path.Combine(coordinator.ArtifactRoot, "escape-steps") + Directory.CreateDirectory(escapeDirectory) |> ignore + File.WriteAllBytes(Path.Combine(escapeDirectory, "01-foundation.glb"), original) + + let stepsPath = Path.Combine(runDirectory coordinator runId, "steps") + Directory.Delete(stepsPath, true) + Directory.CreateSymbolicLink(stepsPath, escapeDirectory) |> ignore + + let response = getArtifact client "file" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)) + +[<Fact>] +let ``step endpoint rejects uncheckpointed glb files while running`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> StepGatedBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-rogue" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let snapshot = store.Snapshot() + snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1)) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + File.Copy(glbPath, Path.Combine(runDirectory coordinator runId, "steps", "09-rogue.glb")) + + let response = getArtifact client "steps" runId "steps/09-rogue.glb" + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode) + + gate.Set() + waitForTerminal coordinator runId) + finally + gate.Set() + gate.Dispose() + +[<Fact>] +let ``step endpoint rejects tampered live checkpoint artifacts`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> StepGatedBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-livetamper" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let snapshot = store.Snapshot() + snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1)) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + File.WriteAllBytes(glbPath, glbBytes 0x09uy) + + let response = getArtifact client "steps" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode) + + gate.Set() + waitForTerminal coordinator runId) + finally + gate.Set() + gate.Dispose() + +[<Fact>] +let ``step endpoint rejects symlink escapes while running`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> StepGatedBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-steplink" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> + let snapshot = store.Snapshot() + snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1)) + ) + + let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb") + let original = File.ReadAllBytes(glbPath) + let escapeTarget = Path.Combine(coordinator.ArtifactRoot, "escape-step-target.glb") + File.WriteAllBytes(escapeTarget, original) + + File.Delete(glbPath) + File.CreateSymbolicLink(glbPath, escapeTarget) |> ignore + + let response = getArtifact client "steps" runId "steps/01-foundation.glb" + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode) + + gate.Set() + waitForTerminal coordinator runId) + finally + gate.Set() + gate.Dispose() + [<Fact>] let ``events endpoint streams only the selected run`` () = withApp (fun client _ -> |
