package hostsetup import ( "bytes" "context" "os" "path/filepath" "strings" "testing" ) type fakeRunner struct { calls [][]string missing map[string]bool // name+arg0 lookups that should return error to simulate absence failures map[string]error } func newFakeRunner() *fakeRunner { return &fakeRunner{ missing: map[string]bool{}, failures: map[string]error{}, } } func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { rec := append([]string{name}, args...) f.calls = append(f.calls, rec) key := strings.Join(rec, " ") if err, ok := f.failures[key]; ok { return nil, err } // getent/id lookups: return error to simulate "not found" if marked missing if name == "getent" || name == "id" { if f.missing[key] { return nil, &exitErr{} } } return nil, nil } type exitErr struct{} func (exitErr) Error() string { return "not found" } func (f *fakeRunner) called(name string, args ...string) bool { want := append([]string{name}, args...) for _, c := range f.calls { if len(c) != len(want) { continue } match := true for i := range c { if c[i] != want[i] { match = false break } } if match { return true } } return false } func testSpec(dir string) AppSpec { return AppSpec{ Name: "iop-test", Description: "iop test", DefaultConfig: filepath.Join(dir, "etc", "iop", "test.yaml"), DefaultDataDir: filepath.Join(dir, "var", "lib", "iop"), DefaultUnit: filepath.Join(dir, "etc", "systemd", "system", "iop-test.service"), ConfigTemplate: "key: value\n", } } func baseOpts(runner CommandRunner) SetupOptions { return SetupOptions{ BinaryPath: "/usr/local/bin/iop-test", Enable: true, Start: true, Runner: runner, Getuid: func() int { return 0 }, } } func TestRunDryRunDoesNotWriteFiles(t *testing.T) { dir := t.TempDir() spec := testSpec(dir) runner := newFakeRunner() opts := baseOpts(runner) opts.DryRun = true var out bytes.Buffer if err := Run(context.Background(), spec, opts, &out); err != nil { t.Fatalf("Run: %v", err) } if _, err := os.Stat(spec.DefaultConfig); !os.IsNotExist(err) { t.Fatalf("config should not exist after dry-run: err=%v", err) } if _, err := os.Stat(spec.DefaultUnit); !os.IsNotExist(err) { t.Fatalf("unit should not exist after dry-run: err=%v", err) } if len(runner.calls) != 0 { t.Fatalf("dry-run should not invoke runner, got %v", runner.calls) } if !strings.Contains(out.String(), "unit preview") { t.Fatalf("dry-run output missing unit preview: %s", out.String()) } if !strings.Contains(out.String(), "config preview") { t.Fatalf("dry-run output missing config preview: %s", out.String()) } } func TestRunWritesMissingConfigAndUnit(t *testing.T) { dir := t.TempDir() spec := testSpec(dir) runner := newFakeRunner() // user/group do not exist yet runner.missing["getent group iop"] = true runner.missing["id -u iop"] = true opts := baseOpts(runner) var out bytes.Buffer if err := Run(context.Background(), spec, opts, &out); err != nil { t.Fatalf("Run: %v", err) } cfg, err := os.ReadFile(spec.DefaultConfig) if err != nil { t.Fatalf("read config: %v", err) } if string(cfg) != spec.ConfigTemplate { t.Fatalf("config content mismatch: %q", cfg) } info, err := os.Stat(spec.DefaultConfig) if err != nil { t.Fatalf("stat config: %v", err) } if info.Mode().Perm() != 0o600 { t.Fatalf("config mode: want 0600 got %o", info.Mode().Perm()) } unit, err := os.ReadFile(spec.DefaultUnit) if err != nil { t.Fatalf("read unit: %v", err) } if !strings.Contains(string(unit), "ExecStart=/usr/local/bin/iop-test serve --config "+spec.DefaultConfig) { t.Fatalf("unit ExecStart wrong: %s", unit) } uinfo, err := os.Stat(spec.DefaultUnit) if err != nil { t.Fatalf("stat unit: %v", err) } if uinfo.Mode().Perm() != 0o644 { t.Fatalf("unit mode: want 0644 got %o", uinfo.Mode().Perm()) } if _, err := os.Stat(spec.DefaultDataDir); err != nil { t.Fatalf("data dir missing: %v", err) } if !runner.called("groupadd", "--system", "iop") { t.Fatalf("groupadd not called: %v", runner.calls) } if !runner.called("useradd", "--system", "--gid", "iop", "--home-dir", spec.DefaultDataDir, "--shell", "/usr/sbin/nologin", "iop") { t.Fatalf("useradd not called: %v", runner.calls) } if !runner.called("chown", "-R", "iop:iop", spec.DefaultDataDir) { t.Fatalf("chown data dir not called: %v", runner.calls) } if !runner.called("chown", "iop:iop", spec.DefaultConfig) { t.Fatalf("chown config not called: %v", runner.calls) } if !runner.called("systemctl", "daemon-reload") { t.Fatalf("daemon-reload not called") } if !runner.called("systemctl", "enable", "iop-test") { t.Fatalf("enable not called") } if !runner.called("systemctl", "start", "iop-test") { t.Fatalf("start not called") } } func TestRunDoesNotOverwriteExistingConfig(t *testing.T) { dir := t.TempDir() spec := testSpec(dir) runner := newFakeRunner() opts := baseOpts(runner) if err := os.MkdirAll(filepath.Dir(spec.DefaultConfig), 0o755); err != nil { t.Fatal(err) } existing := "preserved: true\n" if err := os.WriteFile(spec.DefaultConfig, []byte(existing), 0o600); err != nil { t.Fatal(err) } var out bytes.Buffer if err := Run(context.Background(), spec, opts, &out); err != nil { t.Fatalf("Run: %v", err) } got, err := os.ReadFile(spec.DefaultConfig) if err != nil { t.Fatal(err) } if string(got) != existing { t.Fatalf("config was overwritten: %q", got) } // With OverwriteConfig=true, it should overwrite. opts.OverwriteConfig = true if err := Run(context.Background(), spec, opts, &out); err != nil { t.Fatalf("Run overwrite: %v", err) } got, err = os.ReadFile(spec.DefaultConfig) if err != nil { t.Fatal(err) } if string(got) != spec.ConfigTemplate { t.Fatalf("overwrite did not apply: %q", got) } } func TestRunCreatesTraversableParentForNestedDataDir(t *testing.T) { dir := t.TempDir() spec := testSpec(dir) // Mimic real Edge/Node layout: leaf nested under a shared parent. spec.DefaultDataDir = filepath.Join(dir, "var", "lib", "iop", "edge") runner := newFakeRunner() runner.missing["getent group iop"] = true runner.missing["id -u iop"] = true opts := baseOpts(runner) var out bytes.Buffer if err := Run(context.Background(), spec, opts, &out); err != nil { t.Fatalf("Run: %v", err) } parent := filepath.Join(dir, "var", "lib", "iop") info, err := os.Stat(parent) if err != nil { t.Fatalf("stat parent: %v", err) } // World-traversable (at least the +x bit for other) so the iop service user // can traverse the shared parent into its leaf data dir. if info.Mode().Perm()&0o001 == 0 { t.Fatalf("parent %s not traversable by others: mode=%o", parent, info.Mode().Perm()) } leaf, err := os.Stat(spec.DefaultDataDir) if err != nil { t.Fatalf("stat leaf: %v", err) } if leaf.Mode().Perm() != 0o750 { t.Fatalf("leaf data dir mode: want 0750 got %o", leaf.Mode().Perm()) } if !runner.called("chown", "-R", "iop:iop", spec.DefaultDataDir) { t.Fatalf("chown -R should target leaf data dir, calls=%v", runner.calls) } } func TestRunFixesExistingNonTraversableParent(t *testing.T) { dir := t.TempDir() spec := testSpec(dir) spec.DefaultDataDir = filepath.Join(dir, "var", "lib", "iop", "edge") parent := filepath.Join(dir, "var", "lib", "iop") if err := os.MkdirAll(parent, 0o700); err != nil { t.Fatal(err) } if err := os.Chmod(parent, 0o750); err != nil { t.Fatal(err) } runner := newFakeRunner() runner.missing["getent group iop"] = true runner.missing["id -u iop"] = true opts := baseOpts(runner) if err := Run(context.Background(), spec, opts, &bytes.Buffer{}); err != nil { t.Fatalf("Run: %v", err) } info, err := os.Stat(parent) if err != nil { t.Fatalf("stat parent: %v", err) } if info.Mode().Perm()&0o001 == 0 { t.Fatalf("parent %s still not traversable: mode=%o", parent, info.Mode().Perm()) } } func TestRunRejectsUnsafeDataDir(t *testing.T) { for _, bad := range []string{"/", "/var", "/var/lib", "/etc", "/usr", "/usr/lib", "/var/log"} { t.Run(bad, func(t *testing.T) { spec := AppSpec{ Name: "iop-test", DefaultConfig: "/tmp/does-not-matter.yaml", DefaultDataDir: bad, DefaultUnit: "/tmp/does-not-matter.service", ConfigTemplate: "", } runner := newFakeRunner() opts := baseOpts(runner) opts.DryRun = true // ensure we don't try to write to those paths anyway err := Run(context.Background(), spec, opts, &bytes.Buffer{}) if err == nil { t.Fatalf("expected error for unsafe data dir %q", bad) } if !strings.Contains(err.Error(), "not an app-specific path") { t.Fatalf("unexpected error: %v", err) } if len(runner.calls) != 0 { t.Fatalf("runner should not be invoked for unsafe data dir, got %v", runner.calls) } }) } } func TestEdgeSpecConfigTemplateUsesServiceWritableLogPath(t *testing.T) { tmpl := EdgeSpec().ConfigTemplate wants := []string{ "path: \"/var/lib/iop/edge/logs/edge.log\"", "advertise_host:", "bootstrap:", "artifact_base_url:", "control_plane:", "wire_addr:", "reconnect_interval_sec:", "refresh:", "enabled: false", "listen: \"127.0.0.1:19093\"", "metrics:", "port: 19092", } for _, w := range wants { if !strings.Contains(tmpl, w) { t.Errorf("EdgeSpec().ConfigTemplate missing %q\n---\n%s", w, tmpl) } } } func TestEdgeBundleConfigTemplateKeepsEmptyLogPath(t *testing.T) { tmpl := EdgeBundleConfigTemplate() wants := []string{ "path: \"\"", "advertise_host:", "bootstrap:", "artifact_base_url:", "control_plane:", "wire_addr:", "reconnect_interval_sec:", "refresh:", "enabled: false", "listen: \"127.0.0.1:19093\"", "metrics:", "port: 19092", } for _, w := range wants { if !strings.Contains(tmpl, w) { t.Errorf("EdgeBundleConfigTemplate missing %q\n---\n%s", w, tmpl) } } if strings.Contains(tmpl, "/var/lib/iop/edge/logs/edge.log") { t.Errorf("bundle template leaked service log path:\n%s", tmpl) } } func TestRenderUnit(t *testing.T) { spec := AppSpec{Name: "iop-edge", Description: "IOP Edge"} opts := SetupOptions{ BinaryPath: "/usr/local/bin/iop-edge", ConfigPath: "/etc/iop/edge.yaml", DataDir: "/var/lib/iop/edge", User: "iop", Group: "iop", } unit := renderUnit(spec, opts) for _, want := range []string{ "[Unit]", "Description=IOP Edge", "After=network-online.target", "[Service]", "Type=simple", "User=iop", "Group=iop", "ExecStart=/usr/local/bin/iop-edge serve --config /etc/iop/edge.yaml", "WorkingDirectory=/var/lib/iop/edge", "Restart=on-failure", "[Install]", "WantedBy=multi-user.target", } { if !strings.Contains(unit, want) { t.Fatalf("unit missing %q\n%s", want, unit) } } }