package update import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "os" "path/filepath" "runtime" "strings" "testing" ) func TestAssetName(t *testing.T) { tests := []struct { name string goos string goarch string want string wantErr string }{ {name: "darwin amd64", goos: "darwin", goarch: "amd64", want: "graylog-mcp-darwin-amd64"}, {name: "darwin arm64", goos: "darwin", goarch: "arm64", want: "graylog-mcp-darwin-arm64"}, {name: "linux amd64", goos: "linux", goarch: "amd64", want: "graylog-mcp-linux-amd64"}, {name: "linux arm64", goos: "linux", goarch: "arm64", want: "graylog-mcp-linux-arm64"}, {name: "windows arm64", goos: "windows", goarch: "arm64", want: "graylog-mcp-windows-arm64.exe"}, {name: "missing binary", goos: "linux", goarch: "amd64", wantErr: "binary name must not be empty"}, {name: "missing platform", goos: "", goarch: "amd64", wantErr: "goos and goarch must not be empty"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { binaryName := "graylog-mcp" if tt.name == "missing binary" { binaryName = " " } got, err := AssetName(binaryName, tt.goos, tt.goarch) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want substring %q", err, tt.wantErr) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tt.want { t.Fatalf("got %q, want %q", got, tt.want) } }) } } func TestAssetNameWithTemplate(t *testing.T) { got, err := AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}_{os}_{arch}") if err != nil { t.Fatalf("AssetNameWithTemplate: %v", err) } if got != "graylog-mcp_linux_amd64" { t.Fatalf("got %q", got) } _, err = AssetNameWithTemplate("graylog-mcp", "linux", "amd64", "{binary}/{os}/{arch}") if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "must not contain path separators") { t.Fatalf("error = %v", err) } } func TestResolveLatestReleaseURL(t *testing.T) { got, err := ResolveLatestReleaseURL("https://custom/latest", ReleaseSource{ Driver: "gitea", Repository: "org/repo", BaseURL: "https://gitea.example.com", }) if err != nil { t.Fatalf("ResolveLatestReleaseURL explicit: %v", err) } if got != "https://custom/latest" { t.Fatalf("release url = %q", got) } got, err = ResolveLatestReleaseURL("", ReleaseSource{ LatestReleaseURL: "https://manifest/latest", }) if err != nil { t.Fatalf("ResolveLatestReleaseURL from source: %v", err) } if got != "https://manifest/latest" { t.Fatalf("release url = %q", got) } got, err = ResolveLatestReleaseURL("", ReleaseSource{ Driver: "gitea", Repository: "org/repo", BaseURL: "https://gitea.example.com", }) if err != nil { t.Fatalf("ResolveLatestReleaseURL gitea: %v", err) } if got != "https://gitea.example.com/api/v1/repos/org/repo/releases/latest" { t.Fatalf("release url = %q", got) } got, err = ResolveLatestReleaseURL("", ReleaseSource{ Driver: "gitlab", Repository: "group/sub/repo", BaseURL: "https://gitlab.example.com", }) if err != nil { t.Fatalf("ResolveLatestReleaseURL gitlab: %v", err) } if got != "https://gitlab.example.com/api/v4/projects/group%2Fsub%2Frepo/releases/permalink/latest" { t.Fatalf("release url = %q", got) } got, err = ResolveLatestReleaseURL("", ReleaseSource{ Driver: "github", Repository: "org/repo", }) if err != nil { t.Fatalf("ResolveLatestReleaseURL github: %v", err) } if got != "https://api.github.com/repos/org/repo/releases/latest" { t.Fatalf("release url = %q", got) } _, err = ResolveLatestReleaseURL("", ReleaseSource{Driver: "gitea"}) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "requires repository") { t.Fatalf("error = %v", err) } } func TestResolveUpdateTargetFollowsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink behavior differs on windows") } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") link := filepath.Join(tempDir, "graylog-mcp-link") if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } if err := os.Symlink(target, link); err != nil { t.Fatalf("Symlink: %v", err) } resolved, err := ResolveUpdateTarget(link) if err != nil { t.Fatalf("ResolveUpdateTarget: %v", err) } if resolved != target { t.Fatalf("resolved = %q, want %q", resolved, target) } } func TestReleaseAssetURLResolvesRelativeLinks(t *testing.T) { release := Release{} release.Assets.Links = []ReleaseLink{ {Name: "graylog-mcp-linux-amd64", URL: "/downloads/graylog-mcp-linux-amd64"}, } got, err := release.AssetURL("graylog-mcp-linux-amd64", "https://releases.example.com/latest") if err != nil { t.Fatalf("AssetURL: %v", err) } if got != "https://releases.example.com/downloads/graylog-mcp-linux-amd64" { t.Fatalf("got %q", got) } } func TestReleaseAssetURLErrorIncludesAvailableAssets(t *testing.T) { release := Release{} release.Assets.Links = []ReleaseLink{ {Name: "my-mcp-linux-amd64", URL: "/downloads/my-mcp-linux-amd64"}, {Name: "my-mcp-darwin-arm64", URL: "/downloads/my-mcp-darwin-arm64"}, } _, err := release.AssetURL("my-mcp-linux-arm64", "https://releases.example.com/latest") if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "available: my-mcp-darwin-arm64, my-mcp-linux-amd64") { t.Fatalf("error = %v", err) } } func TestResolveAuthPrefersExplicitToken(t *testing.T) { t.Setenv("RELEASE_TOKEN", "env-token") auth := ResolveAuth("explicit-token", ReleaseSource{ TokenHeader: "Authorization", TokenPrefix: "Bearer", TokenEnvNames: []string{ "RELEASE_TOKEN", }, }) if auth.Header != "Authorization" { t.Fatalf("header = %q, want Authorization", auth.Header) } if auth.Token != "Bearer explicit-token" { t.Fatalf("token = %q, want prefixed explicit token", auth.Token) } } func TestResolveAuthReadsEnvironment(t *testing.T) { t.Setenv("RELEASE_PRIVATE_TOKEN", "env-token") auth := ResolveAuth("", ReleaseSource{ TokenHeader: "X-Release-Token", TokenEnvNames: []string{ "RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN", }, }) if auth.Header != "X-Release-Token" { t.Fatalf("header = %q, want X-Release-Token", auth.Header) } if auth.Token != "env-token" { t.Fatalf("token = %q, want env token", auth.Token) } } func TestFetchLatestReleaseAddsConfiguredAuthHeader(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { if got := r.Header.Get("X-Release-Token"); got != "secret-token" { t.Fatalf("X-Release-Token = %q, want secret-token", got) } payload, err := json.Marshal(Release{TagName: "v1.2.3"}) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil }), } release, err := FetchLatestRelease( context.Background(), client, "https://releases.example.com/latest", Auth{Header: "X-Release-Token", Token: "secret-token"}, ReleaseSource{Name: "release endpoint", BaseURL: "https://releases.example.com"}, ) if err != nil { t.Fatalf("FetchLatestRelease: %v", err) } if release.TagName != "v1.2.3" { t.Fatalf("tag = %q, want v1.2.3", release.TagName) } } func TestFetchLatestReleaseSupportsGitHubAssets(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { body := `{ "tag_name":"v1.2.3", "assets":[ {"name":"my-mcp-linux-amd64","browser_download_url":"https://example.com/my-mcp-linux-amd64"} ] }` return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), }, nil }), } release, err := FetchLatestRelease( context.Background(), client, "https://api.github.com/repos/org/repo/releases/latest", Auth{}, ReleaseSource{Name: "GitHub releases"}, ) if err != nil { t.Fatalf("FetchLatestRelease: %v", err) } assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://api.github.com/repos/org/repo/releases/latest") if err != nil { t.Fatalf("AssetURL: %v", err) } if assetURL != "https://example.com/my-mcp-linux-amd64" { t.Fatalf("assetURL = %q", assetURL) } } func TestFetchLatestReleaseSupportsGitLabDirectAssetURL(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { body := `{ "tag_name":"v1.2.3", "assets":{ "links":[ { "name":"my-mcp-linux-amd64", "url":"https://gitlab.example.com/fallback", "direct_asset_url":"https://gitlab.example.com/direct/my-mcp-linux-amd64" } ] } }` return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), }, nil }), } release, err := FetchLatestRelease( context.Background(), client, "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest", Auth{}, ReleaseSource{Name: "GitLab releases"}, ) if err != nil { t.Fatalf("FetchLatestRelease: %v", err) } assetURL, err := release.AssetURL("my-mcp-linux-amd64", "https://gitlab.example.com/api/v4/projects/org%2Frepo/releases/permalink/latest") if err != nil { t.Fatalf("AssetURL: %v", err) } if assetURL != "https://gitlab.example.com/direct/my-mcp-linux-amd64" { t.Fatalf("assetURL = %q", assetURL) } } func TestFetchLatestReleaseHintsWhenAuthIsMissing(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"message":"404 Project Not Found"}`)), }, nil }), } _, err := FetchLatestRelease( context.Background(), client, "https://releases.example.com/latest", Auth{}, ReleaseSource{ Name: "release endpoint", BaseURL: "https://releases.example.com", TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }, ) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "RELEASE_TOKEN") { t.Fatalf("error = %v, want token hint", err) } } type roundTripperFunc func(*http.Request) (*http.Response, error) func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func TestDownloadReleaseAssetRejectsArtifactOverLimit(t *testing.T) { client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { if r.URL.String() != "https://releases.example.com/artifact" { t.Fatalf("unexpected url: %s", r.URL.String()) } body := "123456" return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), ContentLength: int64(len(body)), Body: io.NopCloser(strings.NewReader(body)), }, nil }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } _, err := DownloadReleaseAsset( context.Background(), client, "https://releases.example.com/artifact", target, Auth{}, ReleaseSource{}, 5, ) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("error = %v", err) } } func TestValidateDownloadedArtifactRejectsHTMLDocument(t *testing.T) { path := filepath.Join(t.TempDir(), "downloaded") content := "ForbiddenAccess denied" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("WriteFile: %v", err) } err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "looks like an HTML page") { t.Fatalf("error = %v", err) } } func TestValidateDownloadedArtifactAcceptsShebangScript(t *testing.T) { path := filepath.Join(t.TempDir(), "downloaded") content := "#!/usr/bin/env sh\necho ok\n" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("WriteFile: %v", err) } err := validateDownloadedArtifact(path, "graylog-mcp-linux-amd64") if err != nil { t.Fatalf("validateDownloadedArtifact: %v", err) } } func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows") } assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } const newBinary = "new-binary" client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(newBinary)), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") link := filepath.Join(tempDir, "graylog-mcp-link") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } if err := os.Symlink(target, link); err != nil { t.Fatalf("Symlink: %v", err) } var stdout strings.Builder err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: link, LatestReleaseURL: "https://releases.example.com/latest", Stdout: &stdout, BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ Name: "release endpoint", BaseURL: "https://releases.example.com", TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }, }) if err != nil { t.Fatalf("Run: %v", err) } got, err := os.ReadFile(target) if err != nil { t.Fatalf("ReadFile target: %v", err) } if string(got) != newBinary { t.Fatalf("target content = %q, want %q", string(got), newBinary) } if info, err := os.Lstat(link); err != nil { t.Fatalf("Lstat link: %v", err) } else if info.Mode()&os.ModeSymlink == 0 { t.Fatalf("link %q is no longer a symlink", link) } if !strings.Contains(stdout.String(), "v1.2.3") { t.Fatalf("stdout = %q, want release tag", stdout.String()) } } func TestRunStopsWhenArtifactLooksLikeHTML(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, marshalErr := json.Marshal(release) if marshalErr != nil { t.Fatalf("Marshal release: %v", marshalErr) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader( "Access denied", )), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } replaceCalled := false err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReplaceExecutable: func(downloadPath, targetPath string) error { replaceCalled = true return os.WriteFile(targetPath, []byte("unexpected"), 0o755) }, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "looks like an HTML page") { t.Fatalf("error = %v", err) } if replaceCalled { t.Fatal("replace hook should not have been called") } got, readErr := os.ReadFile(target) if readErr != nil { t.Fatalf("ReadFile target: %v", readErr) } if string(got) != "old-binary" { t.Fatalf("target content = %q, want unchanged binary", string(got)) } } func TestRunUsesDriverWithoutExplicitLatestReleaseURL(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } latestURL := "https://gitea.example.com/api/v1/repos/org/graylog-mcp/releases/latest" replaceCalled := false client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case latestURL: release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://gitea.example.com/downloads/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://gitea.example.com/downloads/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ Driver: "gitea", Repository: "org/graylog-mcp", BaseURL: "https://gitea.example.com", }, ReplaceExecutable: func(downloadPath, targetPath string) error { replaceCalled = true data, err := os.ReadFile(downloadPath) if err != nil { return err } return os.WriteFile(targetPath, data, 0o755) }, }) if err != nil { t.Fatalf("Run: %v", err) } if !replaceCalled { t.Fatal("custom replace hook was not called") } } func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } const newBinary = "new-binary" hash := sha256.Sum256([]byte(newBinary)) checksum := hex.EncodeToString(hash[:]) + " " + assetName + "\n" client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(newBinary)), }, nil case "https://releases.example.com/artifact.sha256": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(checksum)), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReplaceExecutable: func(downloadPath, targetPath string) error { data, err := os.ReadFile(downloadPath) if err != nil { return err } return os.WriteFile(targetPath, data, 0o755) }, }) if err != nil { t.Fatalf("Run: %v", err) } got, err := os.ReadFile(target) if err != nil { t.Fatalf("ReadFile target: %v", err) } if string(got) != newBinary { t.Fatalf("target content = %q, want %q", string(got), newBinary) } } func TestRunVerifiesEd25519SignatureWhenConfigured(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } const newBinary = "new-binary" digest := sha256.Sum256([]byte(newBinary)) signature := ed25519.Sign(privateKey, digest[:]) signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(newBinary)), }, nil case "https://releases.example.com/artifact.sig": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(signatureBody)), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ SignatureRequired: true, SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, }, ReplaceExecutable: func(downloadPath, targetPath string) error { data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err != nil { t.Fatalf("Run: %v", err) } } func TestRunFailsOnEd25519SignatureMismatch(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } signature := ed25519.Sign(privateKey, []byte("wrong-digest")) signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n" client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil case "https://releases.example.com/artifact.sig": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(signatureBody)), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey)) tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ SignatureRequired: true, SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"}, }, ReplaceExecutable: func(downloadPath, targetPath string) error { data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "signature mismatch") { t.Fatalf("error = %v", err) } } func TestRunFailsWhenSignatureRequiredAndPublicKeyMissing(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ SignatureRequired: true, }, ReplaceExecutable: func(downloadPath, targetPath string) error { data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(strings.ToLower(err.Error()), "public key") { t.Fatalf("error = %v", err) } } func TestRunFailsOnChecksumMismatch(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, {Name: assetName + ".sha256", URL: "https://releases.example.com/artifact.sha256"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil case "https://releases.example.com/artifact.sha256": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + assetName)), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReplaceExecutable: func(downloadPath, targetPath string) error { data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "checksum mismatch") { t.Fatalf("error = %v", err) } } func TestRunFailsWhenChecksumRequiredAndMissing(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ ChecksumRequired: true, }, ReplaceExecutable: func(downloadPath, targetPath string) error { data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "checksum verification") { t.Fatalf("error = %v", err) } } func TestRunInvokesValidationHook(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } validateCalled := false replaceCalled := false client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ValidateDownloaded: func(_ context.Context, input ValidationInput) error { validateCalled = true if input.AssetName != assetName { t.Fatalf("asset name = %q", input.AssetName) } data, readErr := os.ReadFile(input.DownloadPath) if readErr != nil { return readErr } if string(data) != "new-binary" { t.Fatalf("downloaded content = %q", string(data)) } return nil }, ReplaceExecutable: func(downloadPath, targetPath string) error { replaceCalled = true data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err != nil { t.Fatalf("Run: %v", err) } if !validateCalled { t.Fatal("validation hook was not called") } if !replaceCalled { t.Fatal("replace hook was not called") } } func TestRunStopsWhenValidationHookFails(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("new-binary")), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } replaceCalled := false err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.2", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", BinaryName: "graylog-mcp", ValidateDownloaded: func(context.Context, ValidationInput) error { return errors.New("signature invalid") }, ReplaceExecutable: func(downloadPath, targetPath string) error { replaceCalled = true data, readErr := os.ReadFile(downloadPath) if readErr != nil { return readErr } return os.WriteFile(targetPath, data, 0o755) }, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "validate downloaded artifact") { t.Fatalf("error = %v", err) } if replaceCalled { t.Fatal("replace hook should not have been called") } } func TestRunSkipsWhenAlreadyOnLatestRelease(t *testing.T) { assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) if err != nil { t.Skipf("unsupported test platform: %v", err) } downloaded := false client := &http.Client{ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.String() { case "https://releases.example.com/latest": release := Release{TagName: "v1.2.3"} release.Assets.Links = []ReleaseLink{ {Name: assetName, URL: "https://releases.example.com/artifact"}, } payload, err := json.Marshal(release) if err != nil { t.Fatalf("Marshal release: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(payload)), }, nil case "https://releases.example.com/artifact": downloaded = true return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("should-not-download")), }, nil default: return &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil } }), } tempDir := t.TempDir() target := filepath.Join(tempDir, "graylog-mcp") if err := os.WriteFile(target, []byte("current-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } var stdout strings.Builder err = Run(context.Background(), Options{ Client: client, CurrentVersion: "v1.2.3", ExecutablePath: target, LatestReleaseURL: "https://releases.example.com/latest", Stdout: &stdout, BinaryName: "graylog-mcp", ReleaseSource: ReleaseSource{ Name: "release endpoint", BaseURL: "https://releases.example.com", TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"}, }, }) if err != nil { t.Fatalf("Run: %v", err) } if downloaded { t.Fatal("artifact should not have been downloaded") } got, err := os.ReadFile(target) if err != nil { t.Fatalf("ReadFile target: %v", err) } if string(got) != "current-binary" { t.Fatalf("target content = %q, want unchanged binary", string(got)) } if !strings.Contains(stdout.String(), "Already up to date") { t.Fatalf("stdout = %q, want up-to-date message", stdout.String()) } } func TestRunRequiresLatestReleaseURLOrDriver(t *testing.T) { target := filepath.Join(t.TempDir(), "graylog-mcp") if err := os.WriteFile(target, []byte("current-binary"), 0o755); err != nil { t.Fatalf("WriteFile target: %v", err) } err := Run(context.Background(), Options{ BinaryName: "graylog-mcp", ExecutablePath: target, }) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "latest release URL") { t.Fatalf("error = %v", err) } }