1438 lines
41 KiB
Go
1438 lines
41 KiB
Go
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 := "<!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)
|
|
}
|
|
}
|
|
|
|
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(
|
|
"<!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")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|