Files
bluejay-infra/tests/bluejay-infra-lint/AuthentikOidcClientRegistrationTests.cs

193 lines
7.1 KiB
C#

using FluentAssertions;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Xunit;
using YamlDotNet.RepresentationModel;
namespace BluejayInfraLint.Tests;
[Trait("Category", "AuthentikOidc")]
public sealed class AuthentikOidcClientRegistrationTests
{
private static readonly IReadOnlyList<ServiceClientExpectation> ExpectedClients =
[
new("library", "fc-library"),
new("retail", "fc-retail"),
new("telephony", "telephony"),
new("knowledge", "knowledge"),
new("llmbridge", "fc-llm-bridge"),
new("mysql", "fc-mysql"),
new("php", "fc-php"),
new("signage", "fc-signage"),
new("media", "fc-media"),
new("dms", "fc-dms"),
new("pimanager", "fc-pimanager"),
new("distribution", "fc-distribution"),
new("dns", "fc-dns"),
new("print", "fc-print"),
new("aistation", "fc-aistation"),
new("irc", "irc"),
new("ttsreader", "fc-ttsreader"),
new("chat", "fc-chat"),
new("intranet", "intranet"),
new("remotedesktop", "fc-desktop"),
new("provisioning", "fc-provisioning"),
new("scoreboards", "fc-scoreboard"),
new("mndot", "fc-mndot"),
new("kiosk", "fc-system"),
new("mike-bundle", "fc-mike-bundle"),
new("messageboard", "fc-messageboard"),
new("menuboard", "fc-menuboard"),
new("presentations", "fc-presentations"),
new("segmentdisplay", "fc-segmentdisplay"),
new("signalcontrol", "fc-signalcontrol"),
new("worldbuilder", "fc-worldbuilder"),
new("audit", "fc-audit"),
new("licensing", "fc-licensing"),
];
public static TheoryData<string, string> ExpectedClientRows()
{
var data = new TheoryData<string, string>();
foreach (var client in ExpectedClients)
{
data.Add(client.Slug, client.Namespace);
}
return data;
}
[Theory]
[MemberData(nameof(ExpectedClientRows))]
public void OidcClientManifest_MatchesOnePasswordOperatorContract(string slug, string targetNamespace)
{
var manifest = LoadClientManifest(slug);
manifest.Scalar("apiVersion").Should().Be("onepassword.com/v1");
manifest.Scalar("kind").Should().Be("OnePasswordItem");
manifest.Scalar("metadata", "name").Should().Be($"{slug}-oidc-client");
manifest.Scalar("metadata", "namespace").Should().Be(targetNamespace);
manifest.Scalar("metadata", "labels", "app.kubernetes.io/component")
.Should().Be("authentik-oidc-client");
manifest.Scalar("metadata", "labels", "flowercore.io/authentik-client-slug")
.Should().Be(slug);
manifest.Scalar("metadata", "annotations", "flowercore.io/expected-fields")
.Should().Be("client_id,client_secret,issuer_url");
manifest.Scalar("spec", "itemPath")
.Should().Be($"vaults/IAmWorkin/items/{slug}-oidc-client");
}
[Fact]
public void AuthentikKustomization_ReferencesEveryClientManifest()
{
var kustomizationPath = Path.Combine(BluejayRoot(), "apps", "authentik", "kustomization.yaml");
var text = File.ReadAllText(kustomizationPath);
foreach (var client in ExpectedClients)
{
text.Should().Contain($"clients/{client.Slug}-oidc-client.yaml");
}
Regex.Matches(text, @"clients/[-a-z0-9]+-oidc-client\.yaml")
.Select(match => match.Value)
.Distinct(StringComparer.Ordinal)
.Should()
.HaveCount(ExpectedClients.Count);
}
[Fact]
public void BulkClientScript_HasDryRunDefaultAndRequiredClaimPayloads()
{
var scriptPath = Path.Combine(BluejayRoot(), "scripts", "authentik-bulk-client-create.py");
var script = File.ReadAllText(scriptPath);
script.Should().Contain("--apply");
script.Should().Contain("Dry-run only");
script.Should().Contain("AUTHENTIK_TOKEN");
script.Should().Contain("client_secrets_json");
script.Should().Contain("scope-offline_access");
script.Should().Contain("authorization_code");
script.Should().Contain("refresh_token");
foreach (var claim in new[] { "fc:roles", "fc:tenant", "fc:svc", "fc:scope", "fc:mfa", "flowercore_actor_id" })
{
script.Should().Contain(claim);
}
}
[Fact]
public void BulkClientScript_DryRunGeneratesAllServicesWithoutSecrets()
{
var scriptPath = Path.Combine(BluejayRoot(), "scripts", "authentik-bulk-client-create.py");
var startInfo = new ProcessStartInfo
{
FileName = "python",
WorkingDirectory = BluejayRoot(),
RedirectStandardOutput = true,
RedirectStandardError = true,
};
startInfo.ArgumentList.Add(scriptPath);
startInfo.ArgumentList.Add("--print-json");
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start python dry-run.");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit(15000).Should().BeTrue(stderr);
process.ExitCode.Should().Be(0, stderr);
foreach (var client in ExpectedClients)
{
stdout.Should().Contain($"\"service\": \"{client.Slug}\"");
stdout.Should().Contain($"\"namespace\": \"{client.Namespace}\"");
}
stdout.Should().Contain("\"client_secret\": \"<redacted>\"");
stdout.Should().NotMatchRegex("\"client_secret\"\\s*:\\s*\"(?!<redacted>)[^\"]+\"");
}
[Fact]
public void ClientManifests_DoNotContainInlineSecretMaterial()
{
foreach (var client in ExpectedClients)
{
var path = ClientManifestPath(client.Slug);
var text = File.ReadAllText(path);
text.Should().NotContain("client_secret:");
text.Should().NotContain("password:");
text.Should().NotContain("secret:");
text.Should().Contain($"IAmWorkin/items/{client.Slug}-oidc-client");
}
}
private static YamlMappingNode LoadClientManifest(string slug)
{
using var reader = File.OpenText(ClientManifestPath(slug));
var stream = new YamlStream();
stream.Load(reader);
return stream.Documents[0].RootNode.Should().BeOfType<YamlMappingNode>().Subject;
}
private static string ClientManifestPath(string slug) =>
Path.Combine(BluejayRoot(), "apps", "authentik", "clients", $"{slug}-oidc-client.yaml");
private static string BluejayRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, "apps"))
&& File.Exists(Path.Combine(current.FullName, "README.md")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not find bluejay-infra root.");
}
private sealed record ServiceClientExpectation(string Slug, string Namespace);
}