Все проверки выполнены успешно
CI / test (push) Successful in 10m15s
Docker / Build and publish worker image (push) Successful in 34m59s
410 строки
14 KiB
Go
410 строки
14 KiB
Go
package installer
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestValidateURL(t *testing.T) {
|
|
for _, raw := range []string{"https://rsmon.ru", "http://localhost:7401"} {
|
|
if err := ValidateURL(raw); err != nil {
|
|
t.Fatalf("ValidateURL(%q): %v", raw, err)
|
|
}
|
|
}
|
|
for _, raw := range []string{"", "rsmon.ru", "file:///tmp/x"} {
|
|
if err := ValidateURL(raw); err == nil {
|
|
t.Fatalf("ValidateURL(%q) succeeded", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateToken(t *testing.T) {
|
|
if err := ValidateToken("token"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, token := range []string{"", " ", "token\nRSMON_URL=https://evil.test"} {
|
|
if err := ValidateToken(token); err == nil {
|
|
t.Fatalf("ValidateToken(%q) succeeded", token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateEnvironmentFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
contents string
|
|
valid bool
|
|
}{
|
|
{name: "valid", contents: "# Worker credentials\nRSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n\n", valid: true},
|
|
{name: "missing URL", contents: "RSMON_TOKEN=secret\n"},
|
|
{name: "missing token", contents: "RSMON_URL=https://rsmon.ru\n"},
|
|
{name: "invalid URL", contents: "RSMON_URL=file:///tmp/worker\nRSMON_TOKEN=secret\n"},
|
|
{name: "additional settings", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\nWORKER_HOST=0.0.0.0\nWORKER_URL=\n", valid: true},
|
|
{name: "dotenv interpolation", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=${TOKEN}\n"},
|
|
{name: "dotenv export", contents: "export RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
|
{name: "YAML assignment", contents: "RSMON_URL: https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
|
{name: "malformed key", contents: "RSMON-URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
|
{name: "missing assignment", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN\n"},
|
|
{name: "quoted value", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=\"secret\"\n"},
|
|
{name: "whitespace", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret value\n"},
|
|
{name: "duplicate URL", contents: "RSMON_URL=https://rsmon.ru\nRSMON_URL=https://evil.test\nRSMON_TOKEN=secret\n"},
|
|
{name: "duplicate empty token", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=\nRSMON_TOKEN=secret\n"},
|
|
{name: "carriage return", contents: "RSMON_URL=https://rsmon.ru\r\nRSMON_TOKEN=secret\r\n"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "worker.env")
|
|
if err := os.WriteFile(path, []byte(tt.contents), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := ValidateEnvironmentFile(path)
|
|
if tt.valid && err != nil {
|
|
t.Fatalf("ValidateEnvironmentFile() error = %v", err)
|
|
}
|
|
if !tt.valid && err == nil {
|
|
t.Fatal("ValidateEnvironmentFile() succeeded")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateEnvironmentFileInputErrors(t *testing.T) {
|
|
if err := ValidateEnvironmentFile(filepath.Join(t.TempDir(), "missing")); err == nil {
|
|
t.Fatal("missing environment file accepted")
|
|
}
|
|
if err := ValidateEnvironmentFile(t.TempDir()); err == nil {
|
|
t.Fatal("directory accepted as an environment file")
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root can read mode-000 files")
|
|
}
|
|
path := filepath.Join(t.TempDir(), "unreadable")
|
|
if err := os.WriteFile(path, []byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"), 0000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ValidateEnvironmentFile(path); err == nil {
|
|
t.Fatal("unreadable environment file accepted")
|
|
}
|
|
}
|
|
|
|
// TestResolveInstallEnvPublicURLWins verifies the installer canonicalizes
|
|
// the advertised origin: PUBLIC_URL is written and the legacy WORKER_URL
|
|
// is dropped from the resolved env when both are present.
|
|
func TestResolveInstallEnvPublicURLWins(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"PUBLIC_URL": "https://worker.example.com",
|
|
"WORKER_URL": "http://legacy.example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v["PUBLIC_URL"] != "https://worker.example.com" {
|
|
t.Fatalf("PUBLIC_URL not resolved: %+v", v)
|
|
}
|
|
if _, ok := v["WORKER_URL"]; ok {
|
|
t.Fatalf("legacy WORKER_URL must be dropped when PUBLIC_URL is set: %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestResolveInstallEnvLegacyWorkerURLPassesThrough keeps the bounded
|
|
// migration: an env file that only carries the legacy WORKER_URL still
|
|
// resolves and is written unchanged so existing installs upgrade in place.
|
|
func TestResolveInstallEnvLegacyWorkerURLPassesThrough(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"WORKER_URL": "https://legacy.example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v["WORKER_URL"] != "https://legacy.example.com" {
|
|
t.Fatalf("legacy WORKER_URL not preserved: %+v", v)
|
|
}
|
|
if v["PUBLIC_URL"] != "" {
|
|
t.Fatalf("PUBLIC_URL must stay empty: %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestResolveInstallEnvLegacyWorkerURLTolerant verifies the bounded
|
|
// migration does not newly reject legacy shapes that previously
|
|
// installed (a path-bearing WORKER_URL) while a path-bearing PUBLIC_URL
|
|
// stays strict.
|
|
func TestResolveInstallEnvLegacyWorkerURLTolerant(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"WORKER_URL": "https://legacy.example.com/web",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("legacy WORKER_URL with a path must keep installing: %v", err)
|
|
}
|
|
if v["WORKER_URL"] != "https://legacy.example.com/web" {
|
|
t.Fatalf("legacy WORKER_URL not preserved: %+v", v)
|
|
}
|
|
|
|
_, err = resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"PUBLIC_URL": "https://worker.example.com/web",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("path-bearing canonical PUBLIC_URL must be rejected")
|
|
}
|
|
}
|
|
|
|
// TestResolveInstallEnvPublicURLFlagBeatsEnv verifies the --public-url
|
|
// flag follows the installer precedence: the flag wins over the env file
|
|
// and the legacy WORKER_URL is dropped when PUBLIC_URL is present.
|
|
func TestResolveInstallEnvPublicURLFlagBeatsEnv(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{PublicURL: "https://flag.example.com"}, "", map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"PUBLIC_URL": "https://file.example.com",
|
|
"WORKER_URL": "https://legacy.example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v["PUBLIC_URL"] != "https://flag.example.com" {
|
|
t.Fatalf("--public-url flag must win: %+v", v)
|
|
}
|
|
if _, ok := v["WORKER_URL"]; ok {
|
|
t.Fatalf("legacy WORKER_URL must be dropped when PUBLIC_URL is set: %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestResolveInstallEnvRejectsMalformedPublicURL verifies the installer
|
|
// rejects an advertised origin that violates the plan's origin shape
|
|
// (path, userinfo, and non-http(s) schemes).
|
|
func TestResolveInstallEnvRejectsMalformedPublicURL(t *testing.T) {
|
|
for _, bad := range []string{
|
|
"https://worker.example.com/web",
|
|
"https://user:pass@worker.example.com",
|
|
"ftp://worker.example.com",
|
|
"worker.example.com",
|
|
} {
|
|
t.Run(bad, func(t *testing.T) {
|
|
_, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"PUBLIC_URL": bad,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("PUBLIC_URL=%q accepted", bad)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRenderEnvFileOrder includes the canonical PUBLIC_URL ordering.
|
|
func TestRenderEnvFilePublicURLEmptyOmitted(t *testing.T) {
|
|
got := string(renderEnvFile(map[string]string{
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"RSMON_TOKEN": "secret",
|
|
"PUBLIC_URL": "",
|
|
"WORKER_URL": "",
|
|
}))
|
|
if strings.Contains(got, "PUBLIC_URL=") || strings.Contains(got, "WORKER_URL=") {
|
|
t.Fatalf("empty public URL keys must be omitted: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnvironment(t *testing.T) {
|
|
got := string(Environment("https://example.test", "secret"))
|
|
for _, want := range []string{"RSMON_URL=https://example.test\n", "RSMON_TOKEN=secret\n", "WORKER_HOST=127.0.0.1\n"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("environment missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellQuote(t *testing.T) {
|
|
if got, want := shellQuote("a'b"), `'a'\''b'`; got != want {
|
|
t.Fatalf("shellQuote() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestValidateImage(t *testing.T) {
|
|
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
if err := ValidateImage(image); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`, "reg.rsxx.ru/rsmon/rsmon-worker:latest", "reg.rsxx.ru/rsmon/rsmon-worker@sha256:short", "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef"} {
|
|
if err := ValidateImage(image); err == nil {
|
|
t.Fatalf("ValidateImage(%q) succeeded", image)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSystemdUnits(t *testing.T) {
|
|
primary := systemdUnitFor(resolvePaths(""))
|
|
if !strings.Contains(primary, "Type=simple\nUser=root\n") ||
|
|
!strings.Contains(primary, "ExecStart=/usr/local/bin/rsmon-worker\n") ||
|
|
!strings.Contains(primary, "EnvironmentFile=/etc/rsmon-worker/worker.env\n") {
|
|
t.Fatal("binary systemd unit is not the simple root service at the classic paths")
|
|
}
|
|
if !strings.Contains(primary, "After=network-online.target docker.service") ||
|
|
!strings.Contains(primary, "CAP_NET_RAW") || !strings.Contains(primary, "ProtectSystem=full") {
|
|
t.Fatal("binary systemd unit must order after docker and harden for ping/compose")
|
|
}
|
|
|
|
named := systemdUnitFor(resolvePaths("edge"))
|
|
for _, want := range []string{
|
|
"ExecStart=/usr/local/bin/rsmon-worker-edge",
|
|
"EnvironmentFile=/etc/rsmon-worker-edge/worker.env",
|
|
"RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker-edge/webapp",
|
|
"(edge)",
|
|
} {
|
|
if !strings.Contains(named, want) {
|
|
t.Fatalf("named binary unit missing %q", want)
|
|
}
|
|
}
|
|
|
|
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
unit := DockerUnit(image)
|
|
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", image} {
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("Docker systemd unit missing %q", want)
|
|
}
|
|
}
|
|
namedDocker := dockerUnitFor(resolvePaths("edge"), image)
|
|
for _, want := range []string{"docker rm -f rsmon-worker-edge", "--name rsmon-worker-edge ", "-v rsmon-worker-data-edge:/var/lib/rsmon-worker"} {
|
|
if !strings.Contains(namedDocker, want) {
|
|
t.Fatalf("named Docker unit missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePaths(t *testing.T) {
|
|
primary := resolvePaths("")
|
|
if primary.binary != "/usr/local/bin/rsmon-worker" ||
|
|
primary.configDir != "/etc/rsmon-worker" ||
|
|
primary.envFile != "/etc/rsmon-worker/worker.env" ||
|
|
primary.dataDir != "/var/lib/rsmon-worker" ||
|
|
primary.unitName != "rsmon-worker.service" ||
|
|
primary.unitFile != "/etc/systemd/system/rsmon-worker.service" ||
|
|
primary.container != "rsmon-worker" || primary.volume != "rsmon-worker-data" {
|
|
t.Fatalf("primary paths wrong: %+v", primary)
|
|
}
|
|
edge := resolvePaths("edge")
|
|
if edge.binary != "/usr/local/bin/rsmon-worker-edge" ||
|
|
edge.configDir != "/etc/rsmon-worker-edge" ||
|
|
edge.envFile != "/etc/rsmon-worker-edge/worker.env" ||
|
|
edge.dataDir != "/var/lib/rsmon-worker-edge" ||
|
|
edge.unitName != "rsmon-worker-edge.service" ||
|
|
edge.unitFile != "/etc/systemd/system/rsmon-worker-edge.service" ||
|
|
edge.container != "rsmon-worker-edge" || edge.volume != "rsmon-worker-data-edge" {
|
|
t.Fatalf("named paths wrong: %+v", edge)
|
|
}
|
|
}
|
|
|
|
func TestValidateInstanceName(t *testing.T) {
|
|
for _, n := range []string{"", "dev", "edge-1", "a", "ab"} {
|
|
if err := validateInstanceName(n); err != nil {
|
|
t.Fatalf("validateInstanceName(%q): %v", n, err)
|
|
}
|
|
}
|
|
for _, n := range []string{"Dev", "dev_", "-dev", "dev-", "a.b", strings.Repeat("a", 33), "dev zone"} {
|
|
if err := validateInstanceName(n); err == nil {
|
|
t.Fatalf("validateInstanceName(%q) succeeded", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveInstallEnv(t *testing.T) {
|
|
// Force a deterministic process environment so precedence is exact.
|
|
t.Setenv("RSMON_URL", "https://proc.test")
|
|
t.Setenv("RSMON_TOKEN", "proc-token")
|
|
t.Setenv("WORKER_PORT", "29999")
|
|
t.Setenv("WORKER_LOGIN", "")
|
|
t.Setenv("WORKER_PASSWORD", "")
|
|
|
|
t.Run("flag beats process env", func(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{
|
|
URL: "https://flag.test", Token: "flag-token", Port: "28080",
|
|
}, "", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v["RSMON_URL"] != "https://flag.test" || v["RSMON_TOKEN"] != "flag-token" || v["WORKER_PORT"] != "28080" {
|
|
t.Fatalf("flag did not win: %+v", v)
|
|
}
|
|
})
|
|
|
|
t.Run("process env fills when flags empty", func(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{}, "", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v["RSMON_URL"] != "https://proc.test" || v["RSMON_TOKEN"] != "proc-token" || v["WORKER_PORT"] != "29999" {
|
|
t.Fatalf("process env not used: %+v", v)
|
|
}
|
|
})
|
|
|
|
t.Run("env file beats process env", func(t *testing.T) {
|
|
v, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
|
"RSMON_URL": "https://file.test", "RSMON_TOKEN": "file-token",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v["RSMON_URL"] != "https://file.test" || v["RSMON_TOKEN"] != "file-token" {
|
|
t.Fatalf("env file did not beat process: %+v", v)
|
|
}
|
|
})
|
|
|
|
t.Run("token required", func(t *testing.T) {
|
|
t.Setenv("RSMON_TOKEN", "")
|
|
if _, err := resolveInstallEnv(InstallOptions{}, "", nil); err == nil {
|
|
t.Fatal("missing token accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("named instance requires port", func(t *testing.T) {
|
|
t.Setenv("WORKER_PORT", "")
|
|
if _, err := resolveInstallEnv(InstallOptions{}, "edge", nil); err == nil {
|
|
t.Fatal("named instance without port accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("basic auth XOR rejected", func(t *testing.T) {
|
|
_, err := resolveInstallEnv(InstallOptions{Login: "admin"}, "", nil)
|
|
if err == nil {
|
|
t.Fatal("login-only accepted")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRenderEnvFileOrderAndOmission(t *testing.T) {
|
|
got := string(renderEnvFile(map[string]string{
|
|
"WORKER_PORT": "27402",
|
|
"RSMON_TOKEN": "secret",
|
|
"RSMON_URL": "https://rsmon.ru",
|
|
"WORKER_LOGIN": "", // omitted
|
|
"WORKER_PASSWORD": "",
|
|
}))
|
|
want := "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\nWORKER_PORT=27402\n"
|
|
if got != want {
|
|
t.Fatalf("renderEnvFile = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestValidateEnvValue(t *testing.T) {
|
|
for _, v := range []string{"secret", "abc-123", "https://rsmon.ru", "27402"} {
|
|
if err := validateEnvValue("KEY", v); err != nil {
|
|
t.Fatalf("validateEnvValue(%q): %v", v, err)
|
|
}
|
|
}
|
|
for _, v := range []string{"a b", `"q"`, `'q'`, "a$b", `a\b`, "a\nb"} {
|
|
if err := validateEnvValue("KEY", v); err == nil {
|
|
t.Fatalf("validateEnvValue(%q) succeeded", v)
|
|
}
|
|
}
|
|
}
|