mcp-framework/update/update_test.go

1439 lines
41 KiB
Go
Raw Permalink Normal View History

2026-04-13 13:33:48 +00:00
package update
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
2026-04-13 13:33:48 +00:00
"encoding/json"
"errors"
2026-04-13 13:33:48 +00:00
"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"},
2026-04-13 13:33:48 +00:00
}
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)
2026-04-13 13:33:48 +00:00
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)
}
}
2026-04-13 13:33:48 +00:00
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")
2026-04-13 13:33:48 +00:00
if err != nil {
t.Fatalf("AssetURL: %v", err)
}
if got != "https://releases.example.com/downloads/graylog-mcp-linux-amd64" {
2026-04-13 13:33:48 +00:00
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")
2026-04-13 13:33:48 +00:00
auth := ResolveAuth("explicit-token", ReleaseSource{
TokenHeader: "Authorization",
TokenPrefix: "Bearer",
TokenEnvNames: []string{
"RELEASE_TOKEN",
},
2026-04-13 13:33:48 +00:00
})
if auth.Header != "Authorization" {
t.Fatalf("header = %q, want Authorization", auth.Header)
2026-04-13 13:33:48 +00:00
}
if auth.Token != "Bearer explicit-token" {
t.Fatalf("token = %q, want prefixed explicit token", auth.Token)
2026-04-13 13:33:48 +00:00
}
}
func TestResolveAuthReadsEnvironment(t *testing.T) {
t.Setenv("RELEASE_PRIVATE_TOKEN", "env-token")
2026-04-13 13:33:48 +00:00
auth := ResolveAuth("", ReleaseSource{
TokenHeader: "X-Release-Token",
TokenEnvNames: []string{
"RELEASE_TOKEN",
"RELEASE_PRIVATE_TOKEN",
},
2026-04-13 13:33:48 +00:00
})
if auth.Header != "X-Release-Token" {
t.Fatalf("header = %q, want X-Release-Token", auth.Header)
2026-04-13 13:33:48 +00:00
}
if auth.Token != "env-token" {
t.Fatalf("token = %q, want env token", auth.Token)
}
}
func TestFetchLatestReleaseAddsConfiguredAuthHeader(t *testing.T) {
2026-04-13 13:33:48 +00:00
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)
2026-04-13 13:33:48 +00:00
}
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"},
2026-04-13 13:33:48 +00:00
)
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) {
2026-04-13 13:33:48 +00:00
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",
2026-04-13 13:33:48 +00:00
Auth{},
ReleaseSource{
Name: "release endpoint",
BaseURL: "https://releases.example.com",
TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"},
2026-04-13 13:33:48 +00:00
},
)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "RELEASE_TOKEN") {
2026-04-13 13:33:48 +00:00
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 := "<!DOCTYPE html><html><head><title>Forbidden</title></head><body>Access denied</body></html>"
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)
}
}
2026-04-13 13:33:48 +00:00
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":
2026-04-13 13:33:48 +00:00
release := Release{TagName: "v1.2.3"}
release.Assets.Links = []ReleaseLink{
{Name: assetName, URL: "https://releases.example.com/artifact"},
2026-04-13 13:33:48 +00:00
}
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":
2026-04-13 13:33:48 +00:00
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",
2026-04-13 13:33:48 +00:00
Stdout: &stdout,
BinaryName: "graylog-mcp",
ReleaseSource: ReleaseSource{
Name: "release endpoint",
BaseURL: "https://releases.example.com",
TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"},
2026-04-13 13:33:48 +00:00
},
})
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(
"<!DOCTYPE html><html><body>Access denied</body></html>",
)),
}, 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")
}
}
2026-04-13 13:33:48 +00:00
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":
2026-04-13 13:33:48 +00:00
release := Release{TagName: "v1.2.3"}
release.Assets.Links = []ReleaseLink{
{Name: assetName, URL: "https://releases.example.com/artifact"},
2026-04-13 13:33:48 +00:00
}
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":
2026-04-13 13:33:48 +00:00
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",
2026-04-13 13:33:48 +00:00
Stdout: &stdout,
BinaryName: "graylog-mcp",
ReleaseSource: ReleaseSource{
Name: "release endpoint",
BaseURL: "https://releases.example.com",
TokenEnvNames: []string{"RELEASE_TOKEN", "RELEASE_PRIVATE_TOKEN"},
2026-04-13 13:33:48 +00:00
},
})
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)
}
}