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 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 ExpectedClientRows() { var data = new TheoryData(); 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\": \"\""); stdout.Should().NotMatchRegex("\"client_secret\"\\s*:\\s*\"(?!)[^\"]+\""); } [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().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); }