RedFlag/server/internal/services/install_template_integrity_test.go
Fimeg 765ec4188f publish: carry the Windows product into the projection
The tree adds the installer, service and test paths admitted by this source commit. Nothing else changes about what may cross.

Source-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Policy-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Tree-Digest: 89d6fce3828a0e0de0c8eeb41ee6750f4462217e2a04b5190297134d7f4a50c3
2026-09-09 12:47:34 -04:00

328 lines
11 KiB
Go

package services
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"text/template"
)
// TemplateRenderData is the union of all fields across the three install
// template data structs. Template execution fails on unknown variables, so
// this test data exists for the sole purpose of verifying every {{.Field}}
// in the template resolves.
type TemplateRenderData struct {
AgentID string
BinaryURL string
ConfigURL string
Platform string
Architecture string
Version string
ServerURL string
RegistrationToken string
AgentUser string
AgentHome string
ConfigDir string
LogDir string
AgentConfigDir string
AgentLogDir string
ServerKeyDir string
ServerPublicKey string
}
func TestInstallTemplateRenders(t *testing.T) {
data := TemplateRenderData{
AgentID: "00000000-0000-0000-0000-000000000000",
BinaryURL: "https://example.com/redflag-agent",
ConfigURL: "https://example.com/config",
Platform: "linux",
Architecture: "amd64",
Version: "0.2.9.0",
ServerURL: "https://example.com",
RegistrationToken: "rt_test",
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag/agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
AgentConfigDir: "/etc/redflag/agent",
AgentLogDir: "/var/log/redflag/agent",
ServerKeyDir: "/etc/redflag/server",
ServerPublicKey: "abcdef0123456789",
}
scripts := []string{
"templates/install/scripts/linux.sh.tmpl",
"templates/install/scripts/windows.ps1.tmpl",
}
for _, name := range scripts {
t.Run(filepath.Base(name), func(t *testing.T) {
tmpl, err := template.ParseFS(installScriptTemplates, name)
if err != nil {
t.Fatalf("parse template %s: %v", name, err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("render template %s: %v", name, err)
}
output := buf.String()
if len(output) < 100 {
t.Fatalf("rendered template %s too short (%d bytes): likely empty or error", name, len(output))
}
})
}
}
func TestWindowsInstallerVerifiesBeforeMutation(t *testing.T) {
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/windows.ps1.tmpl")
if err != nil {
t.Fatal(err)
}
source := string(raw)
// Comments in the template quote the very patterns asserted below, so
// occurrence counts are taken against the code alone.
var codeOnly strings.Builder
for _, line := range strings.Split(source, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
codeOnly.WriteString(line)
codeOnly.WriteString("\n")
}
}
code := codeOnly.String()
// Anchors ignore leading indentation: the block below the staging try is
// indented, and control-flow order is what this test is about, not columns.
at := func(pattern string) int {
loc := regexp.MustCompile(`(?m)^[ \t]*` + pattern).FindStringIndex(source)
if loc == nil {
return -1
}
return loc[0]
}
ordered := []struct {
name string
pattern string
}{
{"verifier preflight", `Assert-Ed25519Verifier\r?$`},
{"manifest signature", `\$ok = Test-Ed25519Signature`},
{"binary signature", `\$verified = Test-Ed25519Signature`},
{"config ACL repair", `Repair-AgentConfigAccessIfNeeded -ConfigFilePath`},
{"service stop", `Stop-Service -Name`},
{"binary replacement", `Move-Item -Path \$TmpBinary`},
}
prev := -1
for _, step := range ordered {
got := at(step.pattern)
if got < 0 {
t.Fatalf("missing installer step %q (pattern %s)", step.name, step.pattern)
}
if got <= prev {
t.Fatalf("installer step %q runs too early: verification must precede config, service, and binary changes", step.name)
}
prev = got
}
// Fail-open verifier paths. The .NET Ed25519 probe used to return $null
// when no verifier existed and the installer carried on with a warning.
for _, forbidden := range []string{"return $null", "signature not checked", "best-effort (BouncyCastle)", "$null -eq $verified", "$null -eq $ok"} {
if strings.Contains(source, forbidden) {
t.Errorf("Windows installer contains fail-open verifier path: %s", forbidden)
}
}
// $ErrorActionPreference is Stop, so a bare Write-Error is terminating: it
// drops every diagnostic line after the first, skips the exit below it, and
// inside the desktop try/catch it was caught and downgraded to a skip
// warning that let the installer exit 0 on a manifest hash mismatch.
// Every refusal goes through Stop-Install instead.
if n := strings.Count(code, "Write-Error"); n != 1 {
t.Errorf("expected exactly one Write-Error (inside Stop-Install), found %d; refusals must use Stop-Install", n)
}
if !strings.Contains(code, "Write-Error $Line -ErrorAction Continue") {
t.Error("Stop-Install must report non-terminatingly so every diagnostic line reaches the operator")
}
if !regexp.MustCompile(`(?m)^\s*exit 1\s*$`).MatchString(source[strings.Index(source, "function Stop-Install"):]) {
t.Error("Stop-Install must exit deterministically")
}
// Cleanup must not replace the failure that brought us here.
if strings.Contains(code, "Remove-Item -LiteralPath $StagingDir") ||
strings.Contains(code, "Remove-Item -LiteralPath $Scratch") {
t.Error("staging cleanup must go through Remove-InstallerScratchDirectory so it cannot mask the primary failure")
}
if n := strings.Count(code, "Remove-InstallerScratchDirectory -Path"); n != 3 {
t.Errorf("expected every scratch removal to use the non-masking helper, found %d", n)
}
// Downloads live inside the staging directory the finally already removes;
// a redundant Remove-Item there can throw and mask a tampering refusal.
for _, dead := range []string{"Remove-Item $TmpBinary", "Remove-Item $TmpDesktop"} {
if strings.Contains(code, dead) {
t.Errorf("redundant cleanup %q can throw and mask the refusal it follows", dead)
}
}
}
// TestFreshInstallConfigKeys verifies the install template's default JSON
// includes the config keys the agent expects at minimum. This catches drift
// where a new config struct field gets added but the template never writes it
// for fresh installs.
//
// THIS TEST IS NOT EXHAUSTIVE. It maintains a canonical list of keys that must
// appear in the fresh-install config template. Add new keys here when they are
// added to the agent Config struct and the template should set them.
func TestFreshInstallConfigKeys(t *testing.T) {
// Read and parse the linux.sh.tmpl to extract its default JSON block.
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/linux.sh.tmpl")
if err != nil {
t.Fatalf("read template: %v", err)
}
source := string(raw)
// Extract the default JSON between the heredoc markers.
// The template writes: sudo tee "..." > /dev/null <<EOF
// Find the heredoc marker line, then the first `{` on the line after it.
heredoc := `sudo tee "${AGENT_CONFIG_DIR}/config.json" > /dev/null <<EOF`
heredocIdx := strings.Index(source, heredoc)
if heredocIdx == -1 {
t.Fatal("default JSON heredoc marker not found in install template")
}
// Skip past the <<EOF marker and the newline that follows.
afterHeredoc := heredocIdx + len(heredoc)
jsonStart := strings.Index(source[afterHeredoc:], "\n{")
if jsonStart == -1 {
t.Fatal("JSON start (\\n{) not found after heredoc marker")
}
jsonBlockStart := afterHeredoc + jsonStart + 1 // skip the newline
jsonEnd := strings.Index(source[jsonBlockStart:], "\nEOF")
if jsonEnd == -1 {
t.Fatal("JSON end (\\nEOF) not found after heredoc")
}
jsonBlock := source[jsonBlockStart : jsonBlockStart+jsonEnd]
// Parse the JSON block and collect top-level keys.
var parsed map[string]json.RawMessage
if err := json.Unmarshal([]byte(jsonBlock), &parsed); err != nil {
t.Fatalf("parse default JSON: %v\nblock:\n%s", err, jsonBlock)
}
// Canonical required keys — every Config struct field with a json tag
// that MUST appear in the fresh-install template. Keys with "omitempty"
// or that default to zero-value-safe are optional and not listed here.
required := []string{
"version",
"agent_version",
"agent_id",
"token",
"refresh_token",
"registration_token",
"check_in_interval",
"server_url",
"network",
"proxy",
"tls",
"logging",
"subsystems",
}
// "machine_id" in the template JSON is a stale key not present in the
// Config struct — the agent reads machine identity from hostname/TOFU.
// Not a required key; retained in the template for backward compat with
// config files that carry it.
for _, key := range required {
if _, exists := parsed[key]; !exists {
t.Errorf("required config key %q missing from fresh-install template JSON", key)
}
}
// Optional keys — nice-to-have in the template but not required for
// correct operation (zero values are handled by the agent).
recommended := []string{
"desktop",
"polling",
"command_signing",
"security_logging",
"security",
}
for _, key := range recommended {
if _, exists := parsed[key]; !exists {
t.Logf("[INFO] recommended config key %q not in template — fresh installs will use defaults", key)
}
}
}
// TestInstallTemplateScriptletSyntax performs a basic syntax sanity check on the
// shell script portion of the install template — enough to catch common errors
// like unterminated heredocs, unbalanced braces, or missing shell tokens.
func TestInstallTemplateScriptletSyntax(t *testing.T) {
// Render with test data first to substitute template variables.
data := TemplateRenderData{
AgentID: "00000000-0000-0000-0000-000000000000",
BinaryURL: "https://example.com/binary",
ConfigURL: "https://example.com/config",
Platform: "linux",
Architecture: "amd64",
Version: "0.2.9.0",
ServerURL: "https://example.com",
RegistrationToken: "rt_test",
AgentUser: "redflag-agent",
AgentHome: "/var/lib/redflag/agent",
ConfigDir: "/etc/redflag",
LogDir: "/var/log/redflag",
AgentConfigDir: "/etc/redflag/agent",
AgentLogDir: "/var/log/redflag/agent",
ServerKeyDir: "/etc/redflag/server",
ServerPublicKey: "abcdef0123456789",
}
tmpl, err := template.ParseFS(installScriptTemplates, "templates/install/scripts/linux.sh.tmpl")
if err != nil {
t.Fatalf("parse template: %v", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("render template: %v", err)
}
rendered := buf.String()
// Check major sections are present.
sections := []string{
"#!/bin/bash",
"set -e",
"systemctl",
"sudoers",
"config.json",
"redflag-helper",
"redflag-desktop",
}
for _, s := range sections {
if !strings.Contains(rendered, s) {
t.Errorf("rendered template missing expected section: %q", s)
}
}
// Rough heredoc balance check — count open/close markers.
heredocOpens := strings.Count(rendered, "<<EOF")
heredocCloses := strings.Count(rendered, "EOF")
if heredocCloses < heredocOpens {
t.Errorf("possible unterminated heredoc: %d <<EOF markers but only %d EOF terminators", heredocOpens, heredocCloses)
}
}
// TestMain ensures the working directory is the server package root
// so the embedded templates resolve correctly.
func TestMain(m *testing.M) {
// Allow override for test runner flexibility.
if d := os.Getenv("REDFLAG_SERVER_DIR"); d != "" {
os.Chdir(d)
}
os.Exit(m.Run())
}