Heimdall complete page: run its real script under Node in tests

A committed harness (Fixtures/complete-page-harness.cjs) executes the served page's inline script against a simulated browser; tests pin that the fragment is dropped before the post, an error status never submits, and the code is read only from the fragment. Node is required on PATH; a missing Node fails, it does not skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 08:58:20 +02:00
commit cf4e5b4327
3 changed files with 172 additions and 0 deletions

View file

@ -22,6 +22,7 @@
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,64 @@
// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints
// what it did as JSON: the ordered events, and the address and form values at the moment of the submit.
// Usage: node complete-page-harness.cjs <page.html>
const fs = require("fs");
const vm = require("vm");
const html = fs.readFileSync(process.argv[2], "utf8");
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
if (scripts.length !== 1) {
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
}
const script = scripts[0][1];
function run(name, search, hash) {
const events = [];
let submit = null;
const location = {
pathname: "/auth/heimdall/complete",
search,
hash,
get href() {
return this.pathname + this.search + this.hash;
},
replace(url) {
events.push(`replace:${url}`);
},
};
const history = {
replaceState(_state, _title, url) {
events.push(`replaceState:${url}`);
const next = new URL(url, "https://bifrost.test" + location.href);
location.pathname = next.pathname;
location.search = next.search;
location.hash = next.hash;
},
};
const form = {
elements: { attempt: { value: "" }, code: { value: "" } },
submit() {
events.push("submit");
submit = {
attempt: form.elements.attempt.value,
code: form.elements.code.value,
href: location.href,
hash: location.hash,
};
},
};
const document = { getElementById: (id) => (id === "complete" ? form : null) };
vm.runInNewContext(script, { URLSearchParams, location, history, document });
return { name, events, submit };
}
const complete = "&heimdall_completion_code=CODE123";
console.log(
JSON.stringify([
run("success", "?attempt=NONCE", "#heimdall_status=success" + complete),
run("success-with-decoy-query-code", "?attempt=NONCE&heimdall_completion_code=QCODE", "#heimdall_status=success" + complete),
run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete),
run("no-code", "?attempt=NONCE", "#heimdall_status=success"),
run("no-attempt", "", "#heimdall_status=success" + complete),
run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
]),
);

View file

@ -0,0 +1,107 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
/// <summary>
/// Runs the complete page's inline script, as the server serves it, under Node against a simulated browser
/// (Fixtures/complete-page-harness.cjs). Node is required: a missing Node fails these tests, it does not skip them.
/// </summary>
public sealed class HeimdallCompletePageScriptTests
{
private const string ErrorTarget = "replace:/?auth=heimdall-error";
private sealed record Run(string Name, string[] Events, Submit? Submit);
private sealed record Submit(string Attempt, string Code, string Href, string Hash);
private static async Task<Dictionary<string, Run>> RunScenariosAsync()
{
using var host = new HeimdallHost();
using var client = host.NewClient();
var page = await client.GetStringAsync("/auth/heimdall/complete");
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-complete-{Guid.NewGuid():N}.html");
await File.WriteAllTextAsync(pagePath, page);
try
{
var harness = Path.Combine(AppContext.BaseDirectory, "Fixtures", "complete-page-harness.cjs");
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(harness);
start.ArgumentList.Add(pagePath);
Process process;
try
{
process = Process.Start(start)!;
}
catch (Win32Exception error)
{
throw new InvalidOperationException("Node must be on PATH to run the complete page's script: " + error.Message, error);
}
using (process)
{
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await errors);
var runs = JsonSerializer.Deserialize<Run[]>(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
return runs.ToDictionary(x => x.Name);
}
}
finally
{
File.Delete(pagePath);
}
}
[Fact]
public async Task The_fragment_is_dropped_from_the_address_before_the_code_is_posted()
{
var runs = await RunScenariosAsync();
foreach (var name in new[] { "success", "success-with-decoy-query-code" })
{
var run = runs[name];
Assert.Equal(2, run.Events.Length);
Assert.StartsWith("replaceState:/auth/heimdall/complete?attempt=NONCE", run.Events[0]);
Assert.DoesNotContain("#", run.Events[0]);
Assert.DoesNotContain("CODE123", run.Events[0]);
Assert.Equal("submit", run.Events[1]);
Assert.Equal("", run.Submit!.Hash);
Assert.DoesNotContain("CODE123", run.Submit.Href);
Assert.DoesNotContain("#", run.Submit.Href);
}
}
[Fact]
public async Task The_code_is_read_only_from_the_fragment()
{
var runs = await RunScenariosAsync();
Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", ""), runs["success"].Submit);
Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code);
var queryOnly = runs["code-in-query-only"];
Assert.Null(queryOnly.Submit);
Assert.DoesNotContain("submit", queryOnly.Events);
Assert.Equal(ErrorTarget, queryOnly.Events[^1]);
}
[Theory]
[InlineData("error-status-with-code")]
[InlineData("no-code")]
[InlineData("no-attempt")]
public async Task A_failed_or_incomplete_handoff_never_submits_and_leaves_through_the_error_redirect(string scenario)
{
var run = (await RunScenariosAsync())[scenario];
Assert.Null(run.Submit);
Assert.DoesNotContain("submit", run.Events);
Assert.Equal(ErrorTarget, run.Events[^1]);
Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#'));
Assert.DoesNotContain("CODE123", string.Join("\n", run.Events));
}
}