Pulse/internal/api/config_handlers_setup_script_integration_test.go
2026-03-25 11:09:19 +00:00

354 lines
11 KiB
Go

package api
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
const integrationSetupAuthToken = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
func TestSetupScriptTokenLifecycleIntegration_PVE(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires bash-compatible environment")
}
script := generateSetupScriptForIntegration(t, "pve")
section := extractSetupScriptSection(t, script, "SETUP_AUTH_TOKEN=\"", "# Set up permissions")
harness := fmt.Sprintf(`#!/usr/bin/env bash
set -eo pipefail
PULSE_URL="http://SENTINEL_URL:7656"
SERVER_HOST="http://SENTINEL_HOST:8006"
TOKEN_NAME="pulse-sentinel-url"
PULSE_TOKEN_ID="pulse-monitor@pam!${TOKEN_NAME}"
PULSE_API_TOKEN=""
%s
attempt_auto_registration
echo "STATE AUTO_REG_SUCCESS=${AUTO_REG_SUCCESS} TOKEN_ROTATION_SKIPPED=${TOKEN_ROTATION_SKIPPED} TOKEN_VALUE=${TOKEN_VALUE}"
`, section)
mocks := pveHarnessMockBinaries()
t.Run("preserves_existing_token_by_default", func(t *testing.T) {
output, trace := runSetupHarness(t, harness, mocks, map[string]string{
"MOCK_TOKEN_EXISTS": "1",
})
assertContains(t, output, "Keeping it unchanged to avoid breaking existing Pulse credentials")
assertContains(t, output, "Auto-registration skipped: existing token preserved to avoid credential drift")
assertContains(t, output, "STATE AUTO_REG_SUCCESS=false TOKEN_ROTATION_SKIPPED=true TOKEN_VALUE=")
assertContains(t, trace, "pveum user token list pulse-monitor@pam")
assertNotContains(t, trace, "pveum user token remove pulse-monitor@pam pulse-sentinel-url")
assertNotContains(t, trace, "pveum user token add pulse-monitor@pam pulse-sentinel-url --privsep 0")
assertNotContains(t, trace, "curl -s -X POST")
})
t.Run("force_rotate_rotates_and_auto_registers", func(t *testing.T) {
output, trace := runSetupHarness(t, harness, mocks, map[string]string{
"MOCK_TOKEN_EXISTS": "1",
"PULSE_FORCE_TOKEN_ROTATE": "1",
})
assertContains(t, output, "API token rotated successfully")
assertContains(t, output, "Node registered successfully")
assertContains(t, output, "STATE AUTO_REG_SUCCESS=true TOKEN_ROTATION_SKIPPED=false TOKEN_VALUE=mocked-pve-secret")
assertContains(t, trace, "pveum user token remove pulse-monitor@pam pulse-sentinel-url")
assertContains(t, trace, "pveum user token add pulse-monitor@pam pulse-sentinel-url --privsep 0")
assertContains(t, trace, "curl -s -X POST")
})
t.Run("json_output_auto_registers_without_legacy_fallback", func(t *testing.T) {
output, trace := runSetupHarness(t, harness, mocks, map[string]string{
"MOCK_PVE_TOKEN_FORMAT": "json",
})
assertContains(t, output, "API token generated successfully")
assertContains(t, output, "Node registered successfully")
assertContains(t, output, "STATE AUTO_REG_SUCCESS=true TOKEN_ROTATION_SKIPPED=false TOKEN_VALUE=mocked-pve-secret")
assertContains(t, trace, "pveum user token add pulse-monitor@pam pulse-sentinel-url --privsep 0 --output-format json")
assertNotContains(t, trace, "pveum user token add pulse-monitor@pam pulse-sentinel-url --privsep 0\n")
})
t.Run("legacy_output_falls_back_when_json_format_is_unsupported", func(t *testing.T) {
output, trace := runSetupHarness(t, harness, mocks, map[string]string{
"MOCK_PVE_JSON_UNSUPPORTED": "1",
})
assertContains(t, output, "API token generated successfully")
assertContains(t, output, "Node registered successfully")
assertContains(t, output, "STATE AUTO_REG_SUCCESS=true TOKEN_ROTATION_SKIPPED=false TOKEN_VALUE=mocked-pve-secret")
assertContains(t, trace, "pveum user token add pulse-monitor@pam pulse-sentinel-url --privsep 0 --output-format json")
assertContains(t, trace, "pveum user token add pulse-monitor@pam pulse-sentinel-url --privsep 0")
assertContains(t, trace, "curl -s -X POST")
})
}
func TestSetupScriptTokenLifecycleIntegration_PBS(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires bash-compatible environment")
}
script := generateSetupScriptForIntegration(t, "pbs")
section := extractSetupScriptSection(t, script, "# Generate API token", "# Set up permissions")
harness := fmt.Sprintf(`#!/usr/bin/env bash
set -eo pipefail
TOKEN_NAME="pulse-sentinel-url"
PULSE_TOKEN_ID="pulse-monitor@pbs!${TOKEN_NAME}"
PULSE_API_TOKEN=""
%s
echo "STATE AUTO_REG_SUCCESS=${AUTO_REG_SUCCESS} TOKEN_ROTATION_SKIPPED=${TOKEN_ROTATION_SKIPPED} TOKEN_VALUE=${TOKEN_VALUE}"
`, section)
mocks := pbsHarnessMockBinaries()
t.Run("preserves_existing_token_by_default", func(t *testing.T) {
output, trace := runSetupHarness(t, harness, mocks, map[string]string{
"MOCK_TOKEN_EXISTS": "1",
})
assertContains(t, output, "Keeping it unchanged to avoid breaking existing Pulse credentials")
assertContains(t, output, "STATE AUTO_REG_SUCCESS=false TOKEN_ROTATION_SKIPPED=true TOKEN_VALUE=")
assertContains(t, trace, "proxmox-backup-manager user list-tokens pulse-monitor@pbs")
assertNotContains(t, trace, "proxmox-backup-manager user delete-token pulse-monitor@pbs pulse-sentinel-url")
assertNotContains(t, trace, "proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-sentinel-url")
assertNotContains(t, trace, "curl -s -X POST")
})
t.Run("force_rotate_rotates_and_auto_registers", func(t *testing.T) {
output, trace := runSetupHarness(t, harness, mocks, map[string]string{
"MOCK_TOKEN_EXISTS": "1",
"PULSE_FORCE_TOKEN_ROTATE": "1",
})
assertContains(t, output, "Token rotated for Pulse monitoring")
assertContains(t, output, "Successfully registered with Pulse")
assertContains(t, output, "STATE AUTO_REG_SUCCESS=true TOKEN_ROTATION_SKIPPED=false TOKEN_VALUE=mocked-pbs-secret")
assertContains(t, trace, "proxmox-backup-manager user delete-token pulse-monitor@pbs pulse-sentinel-url")
assertContains(t, trace, "proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-sentinel-url")
assertContains(t, trace, "curl -s -X POST")
})
}
func generateSetupScriptForIntegration(t *testing.T, nodeType string) string {
t.Helper()
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
handlers := newTestConfigHandlers(t, cfg)
var reqPath string
switch nodeType {
case "pve":
reqPath = fmt.Sprintf("/api/setup-script?type=pve&host=http://SENTINEL_HOST:8006&pulse_url=http://SENTINEL_URL:7656&auth_token=%s", integrationSetupAuthToken)
case "pbs":
reqPath = fmt.Sprintf("/api/setup-script?type=pbs&host=https://192.168.0.10:8007&pulse_url=http://SENTINEL_URL:7656&auth_token=%s", integrationSetupAuthToken)
default:
t.Fatalf("unsupported node type: %s", nodeType)
}
req := httptest.NewRequest(http.MethodGet, reqPath, nil)
rr := httptest.NewRecorder()
handlers.HandleSetupScript(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d (%s)", rr.Code, rr.Body.String())
}
return rr.Body.String()
}
func extractSetupScriptSection(t *testing.T, script, startMarker, endMarker string) string {
t.Helper()
start := strings.Index(script, startMarker)
if start == -1 {
t.Fatalf("start marker not found: %q", startMarker)
}
remainder := script[start:]
end := strings.Index(remainder, endMarker)
if end == -1 {
t.Fatalf("end marker not found: %q", endMarker)
}
return remainder[:end]
}
func runSetupHarness(t *testing.T, harness string, mockBinaries map[string]string, extraEnv map[string]string) (output string, trace string) {
t.Helper()
root := t.TempDir()
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin dir: %v", err)
}
for name, content := range mockBinaries {
path := filepath.Join(binDir, name)
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("write mock %s: %v", name, err)
}
}
harnessPath := filepath.Join(root, "harness.sh")
if err := os.WriteFile(harnessPath, []byte(harness), 0o755); err != nil {
t.Fatalf("write harness: %v", err)
}
tracePath := filepath.Join(root, "trace.log")
cmd := exec.Command("bash", harnessPath)
env := append(os.Environ(),
"PATH="+binDir+":/usr/bin:/bin",
"TRACE_FILE="+tracePath,
)
for k, v := range extraEnv {
env = append(env, k+"="+v)
}
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("harness execution failed: %v\noutput:\n%s", err, string(out))
}
traceBytes, err := os.ReadFile(tracePath)
if err != nil {
if os.IsNotExist(err) {
traceBytes = []byte{}
} else {
t.Fatalf("read trace: %v", err)
}
}
return string(out), string(traceBytes)
}
func pveHarnessMockBinaries() map[string]string {
return map[string]string{
"pveum": `#!/usr/bin/env bash
set -e
printf 'pveum %s\n' "$*" >> "$TRACE_FILE"
case "$*" in
"user token list pulse-monitor@pam")
if [ "${MOCK_TOKEN_EXISTS:-0}" = "1" ]; then
echo "pulse-sentinel-url"
fi
;;
"user token remove pulse-monitor@pam pulse-sentinel-url")
;;
"user token add pulse-monitor@pam pulse-sentinel-url --privsep 0 --output-format json")
if [ "${MOCK_PVE_JSON_UNSUPPORTED:-0}" = "1" ]; then
echo "unknown option: output-format" >&2
exit 1
fi
if [ "${MOCK_PVE_TOKEN_FORMAT:-table}" = "json" ]; then
echo '{"tokenid":"pulse-monitor@pam!pulse-sentinel-url","value":"mocked-pve-secret"}'
exit 0
fi
# Some versions support the flag but still keep older text rendering in wrappers.
printf '\342\224\202 value \342\224\202 mocked-pve-secret \342\224\202\n'
;;
"user token add pulse-monitor@pam pulse-sentinel-url --privsep 0")
# Emit the box-drawing format parsed by the setup script (│ value │ secret │)
printf '\342\224\202 value \342\224\202 mocked-pve-secret \342\224\202\n'
;;
*)
echo "unexpected pveum args: $*" >&2
exit 1
;;
esac
`,
"hostname": `#!/usr/bin/env bash
set -e
if [ "${1:-}" = "-s" ]; then
echo "pve-node"
exit 0
fi
if [ "${1:-}" = "-I" ]; then
echo "192.0.2.50"
exit 0
fi
echo "pve-node"
`,
"curl": `#!/usr/bin/env bash
set -e
printf 'curl %s\n' "$*" >> "$TRACE_FILE"
echo '{"success":true}'
`,
}
}
func pbsHarnessMockBinaries() map[string]string {
return map[string]string{
"proxmox-backup-manager": `#!/usr/bin/env bash
set -e
printf 'proxmox-backup-manager %s\n' "$*" >> "$TRACE_FILE"
case "$*" in
"user list-tokens pulse-monitor@pbs")
if [ "${MOCK_TOKEN_EXISTS:-0}" = "1" ]; then
echo "pulse-sentinel-url"
fi
;;
"user delete-token pulse-monitor@pbs pulse-sentinel-url")
;;
"user generate-token pulse-monitor@pbs pulse-sentinel-url")
echo '{"tokenid":"pulse-monitor@pbs!pulse-sentinel-url","value":"mocked-pbs-secret"}'
;;
*)
echo "unexpected proxmox-backup-manager args: $*" >&2
exit 1
;;
esac
`,
"hostname": `#!/usr/bin/env bash
set -e
if [ "${1:-}" = "-s" ]; then
echo "pbs-node"
exit 0
fi
if [ "${1:-}" = "-I" ]; then
echo "192.0.2.60"
exit 0
fi
echo "pbs-node"
`,
"curl": `#!/usr/bin/env bash
set -e
printf 'curl %s\n' "$*" >> "$TRACE_FILE"
echo '{"success":true}'
`,
}
}
func assertContains(t *testing.T, value, needle string) {
t.Helper()
if !strings.Contains(value, needle) {
t.Fatalf("expected to find %q\nvalue:\n%s", needle, value)
}
}
func assertNotContains(t *testing.T, value, needle string) {
t.Helper()
if strings.Contains(value, needle) {
t.Fatalf("did not expect to find %q\nvalue:\n%s", needle, value)
}
}