270 lines
9.7 KiB
C#
270 lines
9.7 KiB
C#
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace BluejayInfraLint.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class PiSignagePlayerArtifactTests
|
|
{
|
|
private static readonly string Root = FindRepoRoot();
|
|
private static readonly string AppRoot = Path.Combine(Root, "apps", "fc-signage-pi-player");
|
|
|
|
public static TheoryData<string> RequiredArtifacts => new()
|
|
{
|
|
"README.md",
|
|
"systemd/flowercore-signage-player-pi.service",
|
|
"systemd/flowercore-signage-player-pi-hdmi.service",
|
|
"systemd/flowercore-signage-bootstrap.service",
|
|
"systemd/flowercore-signage-renew.service",
|
|
"systemd/flowercore-signage-renew.timer",
|
|
"systemd/flowercore-signage-detect-display.service",
|
|
"systemd/flowercore-signage-detect-display.timer",
|
|
"systemd/99-flowercore-signage-hdmi.rules",
|
|
"chromium-policies/flowercore-signage.json",
|
|
"scripts/flowercore-signage-launch.sh",
|
|
"scripts/flowercore-signage-prelaunch.sh",
|
|
"scripts/flowercore-signage-bootstrap.sh",
|
|
"scripts/flowercore-signage-renew-cert.sh",
|
|
"scripts/flowercore-signage-hdmi-respond.sh",
|
|
"scripts/fc-signage-detect-display",
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(RequiredArtifacts))]
|
|
public void RequiredArtifacts_ArePresent(string relativePath)
|
|
{
|
|
File.Exists(Path.Combine(AppRoot, relativePath)).Should().BeTrue(relativePath);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerService_UsesExpectedRestartAndMemoryGuards()
|
|
{
|
|
var unit = Read("systemd/flowercore-signage-player-pi.service");
|
|
|
|
unit.Should().Contain("Restart=always");
|
|
unit.Should().Contain("RestartSec=10s");
|
|
unit.Should().Contain("StartLimitBurst=5");
|
|
unit.Should().Contain("StartLimitIntervalSec=300s");
|
|
unit.Should().Contain("MemoryMax=2G");
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerService_IsGatedByNodeIdentityAndMtlsCertificate()
|
|
{
|
|
var unit = Read("systemd/flowercore-signage-player-pi.service");
|
|
|
|
unit.Should().Contain("ConditionPathExists=/etc/flowercore/signage-node.json");
|
|
unit.Should().Contain("ConditionPathExists=/etc/fc-signage-player/client.p12");
|
|
unit.Should().Contain("ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh");
|
|
}
|
|
|
|
[Fact]
|
|
public void LaunchScript_TriesEmbedThenFallsBackToBarePlayerRoute()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-launch.sh");
|
|
|
|
script.Should().Contain("/player/${NODE_ID}/embed?token=${CERT_THUMB}");
|
|
script.Should().Contain("url-divergence.log");
|
|
script.Should().Contain("/player/${NODE_ID}?token=${CERT_THUMB}");
|
|
}
|
|
|
|
[Fact]
|
|
public void LaunchScript_DisablesChromiumPromptsAndRuntimeUpdates()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-launch.sh");
|
|
|
|
script.Should().Contain("--noerrdialogs");
|
|
script.Should().Contain("--disable-infobars");
|
|
script.Should().Contain("--password-store=basic");
|
|
script.Should().Contain("--check-for-update-interval=2592000");
|
|
}
|
|
|
|
[Fact]
|
|
public void PrelaunchScript_AbortsWhenRequiredFilesAreMissing()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-prelaunch.sh");
|
|
|
|
script.Should().Contain("for f in /etc/flowercore/signage-node.json /etc/fc-signage-player/client.p12 /etc/fc-signage-player/client.p12.pass");
|
|
script.Should().Contain("exit 1");
|
|
script.Should().Contain("-checkend $((7*24*3600))");
|
|
}
|
|
|
|
[Fact]
|
|
public void BootstrapScript_IsIdempotentWhenAlreadyEnrolled()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-bootstrap.sh");
|
|
|
|
script.Should().Contain("already enrolled");
|
|
script.Should().Contain("exit 0");
|
|
script.Should().Contain(".enrolledAt");
|
|
}
|
|
|
|
[Fact]
|
|
public void BootstrapScript_GeneratesStableMachineIdFromUuid()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-bootstrap.sh");
|
|
|
|
script.Should().Contain("uuidgen");
|
|
script.Should().Contain("cut -c1-16");
|
|
script.Should().Contain("machineId");
|
|
}
|
|
|
|
[Fact]
|
|
public void BootstrapScript_RetriesRegisterOnceForFirstCallRace()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-bootstrap.sh");
|
|
|
|
script.Should().Contain("for attempt in 1 2");
|
|
script.Should().Contain("register attempt $attempt returned");
|
|
script.Should().Contain("sleep 5");
|
|
}
|
|
|
|
[Fact]
|
|
public void BootstrapScript_SupportsSetupCodeAndApprovalPollingBudget()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-bootstrap.sh");
|
|
|
|
script.Should().Contain("signage-setup-code");
|
|
script.Should().Contain("approve-via-setup-code");
|
|
script.Should().Contain("+ 1800");
|
|
script.Should().Contain("sleep 15");
|
|
}
|
|
|
|
[Fact]
|
|
public void BootstrapScript_CsrSubjectIdentifiesPiPlayer()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-bootstrap.sh");
|
|
|
|
script.Should().Contain("/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi");
|
|
}
|
|
|
|
[Fact]
|
|
public void BootstrapScript_PersistsCertificateAsP12WithRestrictivePermissions()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-bootstrap.sh");
|
|
|
|
script.Should().Contain("openssl pkcs12 -export");
|
|
script.Should().Contain("client.p12.pass");
|
|
script.Should().Contain("chmod 0600");
|
|
script.Should().Contain("chmod 0640");
|
|
}
|
|
|
|
[Fact]
|
|
public void RenewScript_OnlyRunsWhenCertHasLessThanThirtyDays()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-renew-cert.sh");
|
|
|
|
script.Should().Contain("-checkend $((30*24*3600))");
|
|
script.Should().Contain("exit 0");
|
|
script.Should().Contain("/renew");
|
|
}
|
|
|
|
[Fact]
|
|
public void RenewScript_AtomicallySwapsNewCertificateFiles()
|
|
{
|
|
var script = Read("scripts/flowercore-signage-renew-cert.sh");
|
|
|
|
script.Should().Contain("client.key.new");
|
|
script.Should().Contain("mv \"$CERT_DIR/client.key.new\" \"$CERT_DIR/client.key\"");
|
|
script.Should().Contain("mv \"$CERT_DIR/client.p12.new\" \"$CERT_DIR/client.p12\"");
|
|
}
|
|
|
|
[Fact]
|
|
public void HdmiRule_RestartsPlayerAndRunsCapabilityDetection()
|
|
{
|
|
var rule = Read("systemd/99-flowercore-signage-hdmi.rules");
|
|
var responder = Read("scripts/flowercore-signage-hdmi-respond.sh");
|
|
|
|
rule.Should().Contain("KERNEL==\"card?-HDMI-A-?\"");
|
|
rule.Should().Contain("start flowercore-signage-player-pi-hdmi.service");
|
|
responder.Should().Contain("sleep 2");
|
|
responder.Should().Contain("start flowercore-signage-detect-display.service");
|
|
responder.Should().Contain("restart flowercore-signage-player-pi.service");
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectDisplayServiceAndTimer_RunAtBootAndDaily()
|
|
{
|
|
var service = Read("systemd/flowercore-signage-detect-display.service");
|
|
var timer = Read("systemd/flowercore-signage-detect-display.timer");
|
|
|
|
service.Should().Contain("ExecStart=/usr/local/bin/fc-signage-detect-display");
|
|
timer.Should().Contain("OnBootSec=30s");
|
|
timer.Should().Contain("OnCalendar=daily");
|
|
timer.Should().Contain("RandomizedDelaySec=1h");
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectDisplayScript_EmitsDisconnectedProfileWhenNoHdmiIsPresent()
|
|
{
|
|
var script = Read("scripts/fc-signage-detect-display");
|
|
|
|
script.Should().Contain("displayConnected: false");
|
|
script.Should().Contain("No HDMI display detected");
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectDisplayScript_ParsesEdidForHdrResolutionAndAudio()
|
|
{
|
|
var script = Read("scripts/fc-signage-detect-display");
|
|
|
|
script.Should().Contain("edid-decode");
|
|
script.Should().Contain("HDR (Static|Dynamic) Metadata Block");
|
|
script.Should().Contain("maxResolution");
|
|
script.Should().Contain("hasAudioOutput");
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectDisplayScript_TriesBothForwardCompatibleCapabilityEndpoints()
|
|
{
|
|
var script = Read("scripts/fc-signage-detect-display");
|
|
|
|
script.Should().Contain("/api/v1/nodes/${NODE_ID}/capabilities");
|
|
script.Should().Contain("/api/v1/displays/${NODE_ID}/capability-profile");
|
|
script.Should().Contain("no endpoint accepted the profile");
|
|
}
|
|
|
|
[Fact]
|
|
public void ChromiumPolicy_IsValidJsonAndDisablesCredentialPrompts()
|
|
{
|
|
using var doc = JsonDocument.Parse(Read("chromium-policies/flowercore-signage.json"));
|
|
var root = doc.RootElement;
|
|
|
|
root.GetProperty("AutofillAddressEnabled").GetBoolean().Should().BeFalse();
|
|
root.GetProperty("AutofillCreditCardEnabled").GetBoolean().Should().BeFalse();
|
|
root.GetProperty("PasswordManagerEnabled").GetBoolean().Should().BeFalse();
|
|
root.GetProperty("ExtensionInstallBlocklist")[0].GetString().Should().Be("*");
|
|
}
|
|
|
|
[Fact]
|
|
public void RenewalTimer_UsesDailyCadenceWithTwoHourJitter()
|
|
{
|
|
var timer = Read("systemd/flowercore-signage-renew.timer");
|
|
|
|
timer.Should().Contain("OnCalendar=daily");
|
|
timer.Should().Contain("RandomizedDelaySec=2h");
|
|
timer.Should().Contain("Persistent=true");
|
|
}
|
|
|
|
private static string Read(string relativePath)
|
|
=> File.ReadAllText(Path.Combine(AppRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
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.");
|
|
}
|
|
}
|