module SomhairlesDream.Server.Tests.ApiTests open System open System.IO open System.Net open System.Net.Http open System.Text open System.Text.Json open System.Threading open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.TestHost open Xunit open SomhairlesDream.Modeling open SomhairlesDream.Server open SomhairlesDream.Shared let private ids : RunIds = { ProjectId = "heritage-001" RunId = "run-api-001" BaseId = "base-000" TargetId = "target-003" } let private withTempDirectory action = let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-api-" + Guid.NewGuid().ToString("N")) Directory.CreateDirectory(path) |> ignore try action path finally if Directory.Exists(path) then Directory.Delete(path, true) type private TestBridge() = interface IArtifactBridge with member _.Export(request, _) = File.WriteAllBytes(request.GlbPath, [| 0x67uy; 0x6Cuy; 0x54uy; byte request.Step.Index |]) Directory.CreateDirectory(Path.GetDirectoryName(request.LogPath)) |> ignore File.WriteAllText(request.LogPath, $"bridge log for {request.Step.StepId}") Ok { BlenderVersion = "5.0.1" ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") ObjectIds = request.Step.ObjectIds VerifiedObjectIds = request.Step.ObjectIds GlbBytes = 4L RenderPath = None RenderedAt = None } type private BlockingBridge(gate: ManualResetEventSlim) = interface IArtifactBridge with member _.Export(request, _) = gate.Wait() (TestBridge() :> IArtifactBridge).Export(request, fun () -> ()) type private StepGatedBridge(gate: ManualResetEventSlim) = interface IArtifactBridge with member _.Export(request, _) = if request.Step.Index > 0 then gate.Wait() (TestBridge() :> IArtifactBridge).Export(request, fun () -> ()) let private requestBody runId = $"{{\"projectId\":\"{ids.ProjectId}\",\"runId\":\"{runId}\",\"baseId\":\"{ids.BaseId}\",\"targetId\":\"{ids.TargetId}\"}}" let private withAppUsing (bridgeFactory: unit -> IArtifactBridge) action = withTempDirectory (fun root -> let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z") let coordinator = ArtifactRunCoordinator( root, bridgeFactory, clock, TimeSpan.FromSeconds 15. ) let builder = WebApplication.CreateBuilder([||]) builder.WebHost.UseTestServer() |> ignore let app = builder.Build() ArtifactRunApi.register app { Coordinator = coordinator Clock = clock JsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) } app.StartAsync().GetAwaiter().GetResult() try action (app.GetTestClient()) coordinator finally app.StopAsync().GetAwaiter().GetResult() app.DisposeAsync().AsTask().GetAwaiter().GetResult()) let private withApp action = withAppUsing (fun () -> TestBridge() :> IArtifactBridge) action let private waitFor predicate = let deadline = DateTime.UtcNow.AddSeconds(5.) let mutable matched = predicate () while not matched && DateTime.UtcNow < deadline do Thread.Sleep(10) matched <- predicate () matched [] let ``start endpoint accepts a run and conflicts on its second start`` () = withApp (fun client _ -> let first = use content = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Accepted, first.StatusCode) let firstSnapshot = first.Content.ReadAsStringAsync().GetAwaiter().GetResult() |> fun body -> JsonSerializer.Deserialize(body, JsonSerializerOptions(JsonSerializerDefaults.Web)) Assert.Equal(ids.ProjectId, firstSnapshot.ProjectId) Assert.Equal(ids.RunId, firstSnapshot.RunId) use duplicateContent = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") let duplicate = client.PostAsync("/api/runs/start", duplicateContent).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode)) [] let ``start endpoint rejects invalid resource ids before creating a run`` () = withApp (fun client coordinator -> let body = requestBody "../outside" use content = new StringContent(body, Encoding.UTF8, "application/json") let response = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode) Assert.True(coordinator.TryFind(ids.ProjectId, "../outside").IsNone)) [] let ``manifest and artifact endpoints expose a completed run and reject traversal`` () = withApp (fun client coordinator -> let runId = "run-api-complete" 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) let completed = waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> store.Snapshot().Status = "complete")) Assert.True(completed) let manifest = client .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.OK, manifest.StatusCode) let manifestBody = manifest.Content.ReadAsStringAsync().GetAwaiter().GetResult() Assert.Contains("\"schemaVersion\":1", manifestBody) let glbPath = Uri.EscapeDataString("steps/01-foundation.glb") let artifact = client .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={glbPath}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.OK, artifact.StatusCode) let traversalPath = Uri.EscapeDataString("../manifest.json") let traversal = client .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={traversalPath}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.BadRequest, traversal.StatusCode)) [] let ``manifest endpoint reports active runs as conflicts`` () = let gate = new ManualResetEventSlim(false) try withAppUsing (fun () -> BlockingBridge(gate) :> IArtifactBridge) (fun client coordinator -> let runId = "run-api-active" 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) let manifest = client .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.Conflict, manifest.StatusCode) gate.Set() Assert.True( waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> store.Snapshot().Status = "complete"))) ) finally gate.Set() gate.Dispose() [] let ``step artifacts stream while a run is still active`` () = let gate = new ManualResetEventSlim(false) try withAppUsing (fun () -> StepGatedBridge(gate) :> IArtifactBridge) (fun client coordinator -> let runId = "run-api-steps" 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 = Uri.EscapeDataString("steps/01-foundation.glb") let stepsUrl = $"/api/artifacts/steps?projectId={ids.ProjectId}&runId={runId}&path={glbPath}" let stepArtifact = client.GetAsync(stepsUrl).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.OK, stepArtifact.StatusCode) Assert.Equal("model/gltf-binary", stepArtifact.Content.Headers.ContentType.MediaType) let traversalPath = Uri.EscapeDataString("../manifest.json") let traversalUrl = $"/api/artifacts/steps?projectId={ids.ProjectId}&runId={runId}&path={traversalPath}" let traversal = client.GetAsync(traversalUrl).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.BadRequest, traversal.StatusCode) let missingPath = Uri.EscapeDataString("steps/03-cabin.glb") let missingUrl = $"/api/artifacts/steps?projectId={ids.ProjectId}&runId={runId}&path={missingPath}" let missing = client.GetAsync(missingUrl).GetAwaiter().GetResult() Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode) gate.Set() Assert.True( waitFor (fun () -> coordinator.TryFind(ids.ProjectId, runId) |> Option.exists (fun store -> store.Snapshot().Status = "complete"))) ) finally 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")) ) [] 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)) [] 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)) [] 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)) [] 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)) [] 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() [] 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() [] 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() [] let ``events endpoint streams only the selected run`` () = withApp (fun client _ -> let runId = "run-api-events" 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) use response = client .GetAsync( $"/api/runs/events?projectId={ids.ProjectId}&runId={runId}", HttpCompletionOption.ResponseHeadersRead ) .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.OK, response.StatusCode) use reader = new StreamReader(response.Content.ReadAsStream()) let line = reader.ReadLine() Assert.StartsWith("data: ", line) Assert.Contains(runId, line) let unknown = client .GetAsync($"/api/runs/events?projectId={ids.ProjectId}&runId=run-api-missing") .GetAwaiter() .GetResult() Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode))