// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/sha256" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/tentacle" ) type installTransport func(*http.Request) (*http.Response, error) func (transport installTransport) RoundTrip(request *http.Request) (*http.Response, error) { return transport(request) } func install_fixture(t *testing.T) (tentacle.Repository, []byte) { t.Helper() program, err := os.Executable() if err != nil { t.Fatal(err) } binary, err := os.ReadFile(program) if err != nil { t.Fatal(err) } marker := filepath.Join(t.TempDir(), "called") manifest := fmt.Sprintf("format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo','-test.run=^TestTentacleReaderPeer$','--','reader-peer',%q]\napis=['commands']\ncapabilities=[]\n[protocol]\nmajor=1\nminor=0\n", marker) var data bytes.Buffer compressed := gzip.NewWriter(&data) archive := tar.NewWriter(compressed) for _, item := range []struct { name string data []byte mode int64 }{{"tentacle.toml", []byte(manifest), 0644}, {"bin/echo", binary, 0755}} { if err := archive.WriteHeader(&tar.Header{Name: item.name, Size: int64(len(item.data)), Mode: item.mode}); err != nil { t.Fatal(err) } if _, err := archive.Write(item.data); err != nil { t.Fatal(err) } } if err := archive.Close(); err != nil { t.Fatal(err) } if err := compressed.Close(); err != nil { t.Fatal(err) } digest := sha256.Sum256(data.Bytes()) repository := tentacle.Repository{URL: "https://example.org/team/tentacles", ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40), Tentacles: []tentacle.Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1", Releases: []tentacle.Release{{Platform: "linux", Architecture: runtime.GOARCH, URL: "https://example.org/release.tar.gz", SHA256: fmt.Sprintf("%x", digest)}}}}} return repository, data.Bytes() } func set_install_transport(t *testing.T, transport installTransport) { t.Helper() previous := http.DefaultTransport http.DefaultTransport = transport t.Cleanup(func() { http.DefaultTransport = previous }) } func TestRepositoryPackageInstallEnableDisableRemoveAndOfflineRestart(t *testing.T) { test_repository_package_install(t, false) } func TestNativeRepositoryPackageInstall(t *testing.T) { if os.Getenv("CTHULHU_TENTACLE_MANAGED") != "1" { t.Skip("requires a systemd user manager and cgroup v2") } test_repository_package_install(t, true) } func test_repository_package_install(t *testing.T, contained bool) { repository, data := install_fixture(t) downloads := 0 set_install_transport(t, func(*http.Request) (*http.Response, error) { downloads++ return &http.Response{StatusCode: 200, ContentLength: int64(len(data)), Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil }) registryPath := filepath.Join(t.TempDir(), "tentacles.toml") owner, bindings := managed_tentacle_fixture(t, registryPath, contained) if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); contained && binary != "" { owner.guardianProgram = []string{binary, "--tentacle-guardian"} } owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) { return repository, nil } if err := owner.Start(context.Background()); err != nil { t.Fatal(err) } if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-add", URL: repository.URL}); err != nil { t.Fatal(err) } if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "install", ID: "echo", URL: repository.URL}); err != nil { t.Fatal(err) } id := tentacle.PackageID(repository, "echo") wait_managed_state(t, owner, id, "disabled") entry := owner.Snapshot().Entries[0].Entry if entry.Enabled || downloads != 1 { t.Fatal(entry, downloads) } if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "install-enable", ID: "echo", URL: repository.URL}); err == nil { t.Fatal("duplicate install accepted") } manage_tentacle(t, owner, "enable", id, nil) wait_managed_state(t, owner, id, "active") owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command) waitTentacle(t, func() bool { _, err := os.Stat(entry.Program[len(entry.Program)-1]); return err == nil }) manage_tentacle(t, owner, "disable", id, nil) wait_managed_state(t, owner, id, "disabled") if _, err := os.Stat(entry.Program[0]); err != nil { t.Fatal("disable deleted package", err) } manage_tentacle(t, owner, "enable", id, nil) wait_managed_state(t, owner, id, "active") shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := owner.Stop(shutdown); err != nil { t.Fatal(err) } restarted, _ := managed_tentacle_fixture(t, registryPath, contained) if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); contained && binary != "" { restarted.guardianProgram = []string{binary, "--tentacle-guardian"} } restarted.fetchRepository = func(context.Context, string) (tentacle.Repository, error) { t.Error("restart fetched catalog") return repository, nil } if err := restarted.Start(context.Background()); err != nil { t.Fatal(err) } wait_managed_state(t, restarted, id, "active") if downloads != 1 { t.Fatal("enable/restart downloaded update", downloads) } if err := restarted.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: repository.URL}); err != nil { t.Fatal(err) } if _, err := os.Stat(entry.Program[0]); err != nil { t.Fatal("source removal deleted package", err) } manage_tentacle(t, restarted, "remove", id, nil) waitTentacle(t, func() bool { _, err := os.Stat(entry.PackageDirectory); return os.IsNotExist(err) }) disk, err := (&tentacle.Registry{Path: registryPath}).Load() if err != nil || len(disk) != 0 { t.Fatal(disk, err) } } func TestRepositoryPackageInstallFailureCancellationAndChangedCatalog(t *testing.T) { repository, data := install_fixture(t) for _, problem := range []string{"save failure", "cancel during download", "catalog changed", "emergency disable"} { t.Run(problem, func(t *testing.T) { registryPath := filepath.Join(t.TempDir(), "tentacles.toml") registry := &tentacle.Registry{Path: registryPath} if _, err := registry.Load(); err != nil { t.Fatal(err) } if err := registry.SaveState(nil, []tentacle.Repository{repository}); err != nil { t.Fatal(err) } owner, _ := managed_tentacle_fixture(t, registryPath, false) if err := owner.Start(context.Background()); err != nil { t.Fatal(err) } waitTentacle(t, func() bool { return len(owner.Snapshot().Repositories) == 1 }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() set_install_transport(t, func(request *http.Request) (*http.Response, error) { switch problem { case "save failure": if err := os.WriteFile(registryPath, []byte("# external edit\n"), 0600); err != nil { t.Fatal(err) } case "cancel during download": cancel() case "catalog changed": if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: repository.URL}); err != nil { t.Fatal(err) } case "emergency disable": owner.Disable() } return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil }) if err := owner.Manage(ctx, tentacle.ManagementRequest{Action: "install-enable", ID: "echo", URL: repository.URL}); err == nil { t.Fatal("failed installation accepted") } if len(owner.Snapshot().Entries) != 0 { t.Fatal("failed installation registered entry") } files, err := os.ReadDir(tentacle.PackageRoot(registryPath)) if !os.IsNotExist(err) && err != nil { t.Fatal(err) } if len(files) != 0 { t.Fatal("failed installation left files", files) } }) } }