package server import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // updatesFixture раскладывает каталог обновлений так, как это делает // scripts/desktop-release.sh, и возвращает сервер с этим каталогом. func updatesFixture(t *testing.T) (*Server, string) { t.Helper() root := t.TempDir() dir := filepath.Join(root, "darwin", "aarch64") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("create updates dir: %v", err) } manifest := `{"version":"0.2.0","notes":"тест","pub_date":"2026-09-22T12:00:00Z",` + `"platforms":{"darwin-aarch64":{"signature":"dGVzdA==",` + `"url":"https://gl.mhspx.su/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz"}}}` files := map[string]string{ "0.2.0.json": manifest, "glchat_0.2.0_aarch64.app.tar.gz": "архив", "glchat_0.2.0_aarch64.app.tar.gz.sig": "dGVzdA==", } for name, content := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } } srv, _ := newTestServer(t) srv.updates = newUpdatesHandler(root) return srv, root } func getUpdates(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)) return rec } // baseURLForTests — адрес инстанса в ссылках манифеста (как в релизном скрипте). const baseURLForTests = "https://gl.mhspx.su" // TestUpdatesServesManifestByVersion: обёртка запрашивает версию без // расширения, сервер отдаёт соответствующий ей манифест (docs/client-tauri.md §4). func TestUpdatesServesManifestByVersion(t *testing.T) { srv, _ := updatesFixture(t) rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.2.0") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String()) } if contentType := rec.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") { t.Errorf("Content-Type = %q, want application/json", contentType) } if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "no-cache") { t.Errorf("Cache-Control = %q, want no-cache для манифеста", cache) } var manifest struct { Version string `json:"version"` Platforms map[string]struct { Signature string `json:"signature"` URL string `json:"url"` } `json:"platforms"` } if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil { t.Fatalf("decode manifest: %v", err) } if manifest.Version != "0.2.0" { t.Errorf("version = %q, want 0.2.0", manifest.Version) } if _, ok := manifest.Platforms["darwin-aarch64"]; !ok { t.Errorf("в манифесте нет платформы darwin-aarch64: %v", manifest.Platforms) } } // TestUpdatesServesArtifactAndSignature: артефакт и его подпись лежат рядом с // манифестом и отдаются как есть. func TestUpdatesServesArtifactAndSignature(t *testing.T) { srv, _ := updatesFixture(t) rec := getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz") if rec.Code != http.StatusOK || rec.Body.String() != "архив" { t.Fatalf("артефакт: status = %d, body = %q", rec.Code, rec.Body.String()) } if contentType := rec.Header().Get("Content-Type"); contentType != "application/gzip" { t.Errorf("Content-Type = %q, want application/gzip", contentType) } if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "immutable") { t.Errorf("Cache-Control = %q, want immutable для артефакта", cache) } rec = getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz.sig") if rec.Code != http.StatusOK || rec.Body.String() != "dGVzdA==" { t.Fatalf("подпись: status = %d, body = %q", rec.Code, rec.Body.String()) } if contentType := rec.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") { t.Errorf("Content-Type подписи = %q, want text/plain", contentType) } } // TestUpdatesAnswersHead: HEAD отдаёт заголовки файла без тела — по нему // удобно проверять размер и тип артефакта, не скачивая его (chi не выводит // HEAD из GET, маршрут регистрируется отдельно). func TestUpdatesAnswersHead(t *testing.T) { srv, _ := updatesFixture(t) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodHead, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if rec.Body.Len() != 0 { t.Errorf("тело ответа на HEAD = %q, want пусто", rec.Body.String()) } if rec.Header().Get("Content-Length") == "" { t.Error("HEAD без Content-Length: размер артефакта не узнать") } } // TestUpdatesMissingManifestIsNotFound: неизвестная версия — честный 404, а не // SPA-заглушка: иначе обёртка получила бы HTML вместо манифеста. func TestUpdatesMissingManifestIsNotFound(t *testing.T) { srv, _ := updatesFixture(t) for _, path := range []string{ "/updates/darwin/aarch64/0.1.0", // манифеста версии нет "/updates/windows/x86_64/0.2.0", // нет такого каталога "/updates/darwin/aarch64", // мало сегментов "/updates", // корень каталога обновлений "/updates/", // корень со слэшем } { rec := getUpdates(t, srv, path) if rec.Code != http.StatusNotFound { t.Errorf("GET %s: status = %d, want 404", path, rec.Code) } if strings.Contains(rec.Body.String(), "