diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/SomhairlesDream.Server.Tests/ApiTests.fs | 201 |
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() |
