summaryrefslogtreecommitdiff
path: root/tests/SomhairlesDream.Server.Tests/ApiTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 07:37:51 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 07:37:51 +0800
commit56800fbbd4488db20abd4f44f0d39e48599be0ab (patch)
treef32c815543000daf4c2ad33e534bb806e0555c3f /tests/SomhairlesDream.Server.Tests/ApiTests.fs
parente8eebe49407de8947bf509d5cf04d9080ee4edf7 (diff)
downloadsomhairles-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.fs236
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 _ ->