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:
parent
d695674359
commit
cf4e5b4327
3 changed files with 172 additions and 0 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
64
tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs
Normal file
64
tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs
Normal 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", ""),
|
||||||
|
]),
|
||||||
|
);
|
||||||
107
tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs
Normal file
107
tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue