Files
OwnCord/Server/updater/coverage_boost_test.go
J3vbandClaude Fable 5 f3a89e0e09 fix(updater): make client auto-update work end-to-end and Linux server self-update verifiable
- client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the
  server-echoed platforms key matches the updater plugin's
  {os}-{arch}-{installer} lookup (previously bare {{target}} produced a key
  the plugin never matches, so no update was ever surfaced)
- client: TOFU cert pin is scoped to the OwnCord server host via
  HostScopedVerifier; the GitHub installer download validates against web PKI
  instead of failing the pinned-fingerprint check on every install
- client: check/install share one build_updater helper so the two paths cannot
  diverge; tauri-plugin-updater minor-pinned per its configure_client guidance
- server: client-update endpoint serves target-specific artifacts (NSIS,
  per-arch AppImage) and returns 204 for targets without a published updater
  artifact (deb, darwin) instead of always serving the Windows NSIS installer
- release: server-update-manifest.json now binds both OS assets (legacy
  top-level pair kept pointing at the Windows binary so deployed servers still
  verify); VerifyReleaseManifest resolves the entry matching the downloaded
  asset, fixing Linux server self-update
- release: ARM64 staging renames installer, tar.gz and .sig consistently so
  signatures keep pairing and arch-less names cannot collide with x86_64 assets
- ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI);
  merge the two ptt tests that raced on the global PTT_VKEY atomic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:38:22 +02:00

343 lines
12 KiB
Go

package updater
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// ─── FindClientAssets ───────────────────────────────────────────────────────
func TestFindClientAssets_NilCache(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
ca := u.FindClientAssets("windows-x86_64-nsis")
if ca.InstallerURL != "" || ca.SignatureURL != "" {
t.Error("expected empty ClientAssets when no cache")
}
}
func TestFindClientAssets_ByTarget(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.mu.Lock()
u.cache = &UpdateInfo{
Assets: []Asset{
{Name: "OwnCord_1.0.0_x64-setup.nsis.zip", DownloadURL: "https://example.com/installer.zip"},
{Name: "OwnCord_1.0.0_x64-setup.nsis.zip.sig", DownloadURL: "https://example.com/installer.zip.sig"},
{Name: "OwnCord_1.0.0_amd64.AppImage.tar.gz", DownloadURL: "https://example.com/amd64.AppImage.tar.gz"},
{Name: "OwnCord_1.0.0_amd64.AppImage.tar.gz.sig", DownloadURL: "https://example.com/amd64.AppImage.tar.gz.sig"},
{Name: "OwnCord_1.0.0_aarch64.AppImage.tar.gz", DownloadURL: "https://example.com/aarch64.AppImage.tar.gz"},
{Name: "OwnCord_1.0.0_aarch64.AppImage.tar.gz.sig", DownloadURL: "https://example.com/aarch64.AppImage.tar.gz.sig"},
{Name: "chatserver.exe", DownloadURL: "https://example.com/chatserver.exe"},
},
}
u.mu.Unlock()
cases := []struct {
target string
wantInstaller string
wantSig string
}{
{"windows-x86_64-nsis", "https://example.com/installer.zip", "https://example.com/installer.zip.sig"},
{"linux-x86_64-appimage", "https://example.com/amd64.AppImage.tar.gz", "https://example.com/amd64.AppImage.tar.gz.sig"},
{"linux-aarch64-appimage", "https://example.com/aarch64.AppImage.tar.gz", "https://example.com/aarch64.AppImage.tar.gz.sig"},
// No deb updater artifact is published — a deb client must get
// nothing rather than an AppImage archive its installer rejects.
{"linux-x86_64-deb", "", ""},
{"darwin-aarch64-app", "", ""},
{"windows-x86_64-unknown", "", ""},
{"", "", ""},
}
for _, tc := range cases {
ca := u.FindClientAssets(tc.target)
if ca.InstallerURL != tc.wantInstaller || ca.SignatureURL != tc.wantSig {
t.Errorf("FindClientAssets(%q) = {%q, %q}, want {%q, %q}",
tc.target, ca.InstallerURL, ca.SignatureURL, tc.wantInstaller, tc.wantSig)
}
}
}
func TestFindClientAssets_NoMatchingAssets(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.mu.Lock()
u.cache = &UpdateInfo{
Assets: []Asset{
{Name: "chatserver.exe", DownloadURL: "https://example.com/chatserver.exe"},
{Name: "checksums.sha256", DownloadURL: "https://example.com/checksums.sha256"},
},
}
u.mu.Unlock()
ca := u.FindClientAssets("windows-x86_64-nsis")
if ca.InstallerURL != "" || ca.SignatureURL != "" {
t.Error("expected empty ClientAssets when no NSIS assets")
}
}
// ─── FetchTextAsset ─────────────────────────────────────────────────────────
func TestFetchTextAsset_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ="))
}))
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
text, err := u.FetchTextAsset(context.Background(), srv.URL+"/sig.txt")
if err != nil {
t.Fatalf("FetchTextAsset: %v", err)
}
if text != "dW50cnVzdGVkIGNvbW1lbnQ=" {
t.Errorf("text = %q, want 'dW50cnVzdGVkIGNvbW1lbnQ='", text)
}
}
func TestFetchTextAsset_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
_, err := u.FetchTextAsset(context.Background(), srv.URL+"/missing.sig")
if err == nil {
t.Error("expected error for 404 response")
}
}
// ─── shouldSendToken ────────────────────────────────────────────────────────
func TestShouldSendToken_GitHubHost(t *testing.T) {
u := NewUpdater("1.0.0", "tok", "J3vb", "OwnCord")
tests := []struct {
url string
want bool
}{
{"https://api.github.com/repos/foo/bar", true},
{"https://github.com/releases/download/v1", true},
{"https://objects.githubusercontent.com/asset", true},
{"https://evil.com/malicious", false},
{"https://notgithub.example.com/foo", false},
}
for _, tc := range tests {
got := u.shouldSendToken(tc.url)
if got != tc.want {
t.Errorf("shouldSendToken(%q) = %v, want %v", tc.url, got, tc.want)
}
}
}
func TestShouldSendToken_CustomBaseURL(t *testing.T) {
u := NewUpdater("1.0.0", "tok", "J3vb", "OwnCord")
u.baseURL = "http://localhost:9090"
if !u.shouldSendToken("http://localhost:9090/repos/foo/bar") {
t.Error("expected true for URL matching baseURL")
}
if u.shouldSendToken("http://localhost:8080/different") {
t.Error("expected false for URL not matching baseURL")
}
}
// ─── isGitHubHost ───────────────────────────────────────────────────────────
func TestIsGitHubHost(t *testing.T) {
tests := []struct {
url string
want bool
}{
{"https://api.github.com/repos", true},
{"https://github.com/J3vb/OwnCord", true},
{"https://objects.githubusercontent.com/asset", true},
{"https://raw.githubusercontent.com/file", true},
{"https://evil.com", false},
{"not a valid url \x00", false},
{"https://fakegithub.com", false},
}
for _, tc := range tests {
got := isGitHubHost(tc.url)
if got != tc.want {
t.Errorf("isGitHubHost(%q) = %v, want %v", tc.url, got, tc.want)
}
}
}
// ─── ensureVPrefix ──────────────────────────────────────────────────────────
func TestEnsureVPrefix(t *testing.T) {
tests := []struct {
input string
want string
}{
{"1.0.0", "v1.0.0"},
{"v1.0.0", "v1.0.0"},
{"0.0.1", "v0.0.1"},
{"v0.0.1", "v0.0.1"},
}
for _, tc := range tests {
got := ensureVPrefix(tc.input)
if got != tc.want {
t.Errorf("ensureVPrefix(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
// ─── CheckForUpdate error caching ───────────────────────────────────────────
func TestCheckForUpdate_ErrorCaching(t *testing.T) {
var hitCount int
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
hitCount++
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(w, `{"message":"error"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
// First call should error and cache.
_, err := u.CheckForUpdate(context.Background())
if err == nil {
t.Fatal("expected error")
}
// Second call should use cached error.
_, err = u.CheckForUpdate(context.Background())
if err == nil {
t.Fatal("expected cached error")
}
if hitCount != 1 {
t.Errorf("expected 1 API hit (error cached), got %d", hitCount)
}
}
// ─── CheckForUpdate with assets list ────────────────────────────────────────
func TestCheckForUpdate_IncludesAssetsList(t *testing.T) {
release := ghRelease{
TagName: "v2.0.0",
Body: "notes",
HTMLURL: "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
Assets: []ghAsset{
{Name: "chatserver.exe", BrowserDownloadURL: "https://example.com/chatserver.exe"},
{Name: "checksums.sha256", BrowserDownloadURL: "https://example.com/checksums.sha256"},
{Name: "OwnCord_2.0.0_x64-setup.nsis.zip", BrowserDownloadURL: "https://example.com/installer.zip"},
},
}
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(release)
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
info, err := u.CheckForUpdate(context.Background())
if err != nil {
t.Fatalf("CheckForUpdate: %v", err)
}
if len(info.Assets) != 3 {
t.Errorf("expected 3 assets, got %d", len(info.Assets))
}
}
// ─── downloadFile with auth token ───────────────────────────────────────────
func TestDownloadFile_SendsTokenToGitHub(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data"))
}))
defer srv.Close()
u := NewUpdater("1.0.0", "my-secret-token", "J3vb", "OwnCord")
u.baseURL = srv.URL
dest := t.TempDir() + "/download.bin"
err := u.downloadFile(context.Background(), srv.URL+"/file", dest)
if err != nil {
t.Fatalf("downloadFile: %v", err)
}
if gotAuth != "token my-secret-token" {
t.Errorf("Authorization = %q, want 'token my-secret-token'", gotAuth)
}
}
func TestDownloadFile_NoTokenToExternalHost(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data"))
}))
defer srv.Close()
u := NewUpdater("1.0.0", "my-secret-token", "J3vb", "OwnCord")
// baseURL is NOT set to srv.URL, so shouldSendToken returns false.
dest := t.TempDir() + "/download2.bin"
err := u.downloadFile(context.Background(), srv.URL+"/file", dest)
if err != nil {
t.Fatalf("downloadFile: %v", err)
}
if gotAuth != "" {
t.Errorf("expected no Authorization header for external host, got %q", gotAuth)
}
}
// ─── ParseChecksumFile edge cases ───────────────────────────────────────────
func TestParseChecksumFile_SingleSpace(t *testing.T) {
// Some tools output single-space instead of double-space.
data := []byte("abc123 chatserver.exe\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
hash, err := u.ParseChecksumFile(data, "chatserver.exe")
if err != nil {
t.Fatalf("ParseChecksumFile single space: %v", err)
}
if hash != "abc123" {
t.Errorf("hash = %q, want 'abc123'", hash)
}
}
func TestParseChecksumFile_EmptyLines(t *testing.T) {
data := []byte("\n\nabc123 chatserver.exe\n\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
hash, err := u.ParseChecksumFile(data, "chatserver.exe")
if err != nil {
t.Fatalf("ParseChecksumFile with empty lines: %v", err)
}
if hash != "abc123" {
t.Errorf("hash = %q, want 'abc123'", hash)
}
}
func TestParseChecksumFile_EmptyData(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
_, err := u.ParseChecksumFile([]byte(""), "chatserver.exe")
if err == nil {
t.Error("expected error for empty checksum data")
}
}
// ─── VerifyChecksum file not found ──────────────────────────────────────────
func TestVerifyChecksum_FileNotFound(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
err := u.VerifyChecksum("/nonexistent/path/to/file.exe", "abc123")
if err == nil {
t.Error("expected error for non-existent file")
}
}