summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 07:54:39 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 07:54:39 +0800
commitbba99bea934fe397b6aed59d3f33a5315799fa28 (patch)
tree9a32e7a70c68476d6d6e8e78c7db1bede047e388 /tests
parent56800fbbd4488db20abd4f44f0d39e48599be0ab (diff)
downloadsomhairles-dream-fsharp-bba99bea934fe397b6aed59d3f33a5315799fa28.tar.gz
Harden artifact serving: root-chain link validation, deterministic stream disposal
Diffstat (limited to 'tests')
-rw-r--r--tests/SomhairlesDream.Server.Tests/ApiTests.fs201
1 files changed, 200 insertions, 1 deletions
diff --git a/tests/SomhairlesDream.Server.Tests/ApiTests.fs b/tests/SomhairlesDream.Server.Tests/ApiTests.fs
index be6a6cb..93ec849 100644
--- a/tests/SomhairlesDream.Server.Tests/ApiTests.fs
+++ b/tests/SomhairlesDream.Server.Tests/ApiTests.fs
@@ -67,9 +67,12 @@ let private requestBody runId =
let private withAppUsing (bridgeFactory: unit -> IArtifactBridge) action =
withTempDirectory (fun root ->
let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z")
+ let artifactRoot = Path.Combine(root, "artifacts")
+ Directory.CreateDirectory(artifactRoot) |> ignore
+
let coordinator =
ArtifactRunCoordinator(
- root,
+ artifactRoot,
bridgeFactory,
clock,
TimeSpan.FromSeconds 15.
@@ -287,6 +290,62 @@ let private waitForTerminal (coordinator: ArtifactRunCoordinator) runId =
status = "complete" || status = "failed"))
)
+let private fdCount () =
+ if Directory.Exists("/proc/self/fd") then
+ Directory.GetFiles("/proc/self/fd").Length
+ else
+ -1
+
+let private copyDirectory (source: string) (destination: string) =
+ Directory.CreateDirectory(destination) |> ignore
+
+ for directory in Directory.GetDirectories(source, "*", SearchOption.AllDirectories) do
+ Directory.CreateDirectory(Path.Combine(destination, directory.Substring(source.Length + 1)))
+ |> ignore
+
+ for file in Directory.GetFiles(source, "*", SearchOption.AllDirectories) do
+ let relative = file.Substring(source.Length + 1)
+ let target = Path.Combine(destination, relative)
+ Directory.CreateDirectory(Path.GetDirectoryName(target)) |> ignore
+ File.Copy(file, target)
+
+let private getManifest (client: HttpClient) runId =
+ client
+ .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}")
+ .GetAwaiter()
+ .GetResult()
+
+let private assertArtifactServed (client: HttpClient) runId =
+ let glbResponse = getArtifact client "file" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.OK, glbResponse.StatusCode)
+
+ let stepResponse = getArtifact client "steps" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.OK, stepResponse.StatusCode)
+
+ let manifestResponse = getManifest client runId
+ Assert.Equal(HttpStatusCode.OK, manifestResponse.StatusCode)
+
+let private assertArtifactRejected (client: HttpClient) runId =
+ let glbResponse = getArtifact client "file" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.BadRequest, glbResponse.StatusCode)
+
+ let stepResponse = getArtifact client "steps" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.BadRequest, stepResponse.StatusCode)
+
+ let manifestResponse = getManifest client runId
+ Assert.Equal(HttpStatusCode.BadRequest, manifestResponse.StatusCode)
+
+let private outsideDirectory (coordinator: ArtifactRunCoordinator) name =
+ Path.Combine(Directory.GetParent(coordinator.ArtifactRoot).FullName, name)
+
+let private replaceWithSymlink (source: string) (target: string) =
+ if File.Exists(source) then
+ File.Delete(source)
+ else
+ Directory.Delete(source, true)
+
+ Directory.CreateSymbolicLink(source, target) |> ignore
+
[<Fact>]
let ``file endpoint serves only published manifest members`` () =
withApp (fun client coordinator ->
@@ -532,3 +591,143 @@ let ``events endpoint streams only the selected run`` () =
.GetResult()
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode))
+
+[<Fact>]
+let ``artifact streams dispose deterministically on repeated mismatches`` () =
+ let gate = new ManualResetEventSlim(false)
+
+ try
+ withAppUsing
+ (fun () -> StepGatedBridge(gate) :> IArtifactBridge)
+ (fun client coordinator ->
+ let runId = "run-api-dispose"
+ 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 baseline = fdCount ()
+ let assertNoLeak () =
+ if baseline >= 0 then
+ Assert.True(
+ fdCount () <= baseline + 2,
+ "failed artifact opens should not leak file handles"
+ )
+
+ for _ in 1..20 do
+ match coordinator.StepArtifact(ids.ProjectId, runId, "steps/01-foundation.glb") with
+ | Error(ArtifactMismatch _) -> ()
+ | other -> failwith $"expected ArtifactMismatch, got {other}"
+
+ assertNoLeak ()
+
+ for _ in 1..10 do
+ let response = getArtifact client "steps" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode)
+
+ File.WriteAllBytes(glbPath, [| 0x01uy; 0x02uy; 0x03uy; 0x04uy; 0x05uy |])
+
+ for _ in 1..20 do
+ match coordinator.StepArtifact(ids.ProjectId, runId, "steps/01-foundation.glb") with
+ | Error(ArtifactMismatch _) -> ()
+ | other -> failwith $"expected ArtifactMismatch, got {other}"
+
+ assertNoLeak ()
+
+ for _ in 1..10 do
+ 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 ``artifact serving rejects symlinked project directories`` () =
+ withApp (fun client coordinator ->
+ let runId = "run-api-projlink"
+ 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"))
+ )
+
+ assertArtifactServed client runId
+
+ let projectDirectory = Path.Combine(coordinator.ArtifactRoot, ids.ProjectId)
+ let escapeDirectory = outsideDirectory coordinator "escape-project"
+ copyDirectory projectDirectory escapeDirectory
+ replaceWithSymlink projectDirectory escapeDirectory
+
+ assertArtifactRejected client runId)
+
+[<Fact>]
+let ``artifact serving rejects symlinked run directories`` () =
+ withApp (fun client coordinator ->
+ let runId = "run-api-runlink"
+ 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"))
+ )
+
+ assertArtifactServed client runId
+
+ let escapeDirectory = outsideDirectory coordinator "escape-run"
+ copyDirectory (runDirectory coordinator runId) escapeDirectory
+ replaceWithSymlink (runDirectory coordinator runId) escapeDirectory
+
+ assertArtifactRejected client runId)
+
+[<Fact>]
+let ``artifact serving rejects links introduced during an active run`` () =
+ let gate = new ManualResetEventSlim(false)
+
+ try
+ withAppUsing
+ (fun () -> StepGatedBridge(gate) :> IArtifactBridge)
+ (fun client coordinator ->
+ let runId = "run-api-midlink"
+ 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 escapeDirectory = outsideDirectory coordinator "escape-midrun"
+ copyDirectory (runDirectory coordinator runId) escapeDirectory
+ replaceWithSymlink (runDirectory coordinator runId) escapeDirectory
+
+ gate.Set()
+ waitForTerminal coordinator runId
+
+ assertArtifactRejected client runId)
+ finally
+ gate.Set()
+ gate.Dispose()