8814fe7cf4
Версия — в одном месте: файл VERSION в корне, из него её берут Makefile, deploy/install.sh (IMAGE_TAG) и desktop-обёртка (tauri.conf.json, Cargo.toml). Публикация обновлений (scripts/desktop-release.sh): - манифесты прошлых релизов удаляются: оставшийся <старая версия>.json перекрывал новый релиз — обёртка спрашивает манифест своей версии и видела «обновлений нет»; - mktemp-каталог раскладки получает 755, каталоги на инстансе нормализуются: rsync -a переносил режим 0700 на корень каталога обновлений, и приложение не могло читать манифесты (404 при живых файлах); - --remote работает без TTY и без sudo, если каталог обновлений записываем; опции ssh — в GLCHAT_SSH_OPTS; - предупреждение при публикации версии старше уже опубликованной. Сервер: HEAD /updates/... (chi не выводит его из GET) — размер и тип артефакта без скачивания; тест TestUpdatesAnswersHead. Приёмка на живом канале: 0.1.0 → 0.1.1 на стенде (скачивание, проверка подписи, установка, перезапуск), /Applications/glchat.app = 0.1.1.
248 lines
11 KiB
Go
248 lines
11 KiB
Go
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(), "<!doctype html") {
|
|
t.Errorf("GET %s: отдан HTML-шелл вместо 404", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpdatesRejectsTraversal: выход за каталог обновлений невозможен —
|
|
// сегменты проверяются по символам, а «..» отсекается.
|
|
func TestUpdatesRejectsTraversal(t *testing.T) {
|
|
srv, root := updatesFixture(t)
|
|
secret := filepath.Join(filepath.Dir(root), "secret.json")
|
|
if err := os.WriteFile(secret, []byte("секрет"), 0o644); err != nil {
|
|
t.Fatalf("write secret: %v", err)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/updates/darwin/aarch64/..%2f..%2fsecret",
|
|
"/updates/darwin/aarch64/%2e%2e%2fsecret",
|
|
"/updates/..%2fdarwin/aarch64/0.2.0",
|
|
"/updates/darwin/aarch64/....//secret",
|
|
"/updates/darwin/aarch64/0.2.0.json%00",
|
|
} {
|
|
rec := getUpdates(t, srv, path)
|
|
if rec.Code == http.StatusOK {
|
|
t.Errorf("GET %s: status = 200, запрос должен быть отклонён (body %q)", path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpdatesRejectsPost: метод кроме GET|HEAD не поддержан.
|
|
func TestUpdatesRejectsPost(t *testing.T) {
|
|
srv, _ := updatesFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/updates/darwin/aarch64/0.2.0", nil))
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status = %d, want 405", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestUpdatesFallsBackToLatest: обёртка приходит со своей установленной
|
|
// версией, поэтому запрос версии без собственного манифеста должен получить
|
|
// манифест последнего релиза — иначе обновление не пришло бы никому, кроме
|
|
// тех, у кого уже стоит последняя версия.
|
|
func TestUpdatesFallsBackToLatest(t *testing.T) {
|
|
srv, root := updatesFixture(t)
|
|
latest := `{"version":"0.3.0","notes":"свежий релиз","pub_date":"2026-09-23T10:00:00Z",` +
|
|
`"platforms":{"darwin-aarch64":{"signature":"bmV3","url":"` + baseURLForTests +
|
|
`/updates/darwin/aarch64/glchat_0.3.0_aarch64.app.tar.gz"}}}`
|
|
if err := os.WriteFile(filepath.Join(root, "darwin", "aarch64", "latest.json"), []byte(latest), 0o644); err != nil {
|
|
t.Fatalf("write latest.json: %v", err)
|
|
}
|
|
|
|
// Манифеста версии 0.1.0 нет — приходит последний релиз.
|
|
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.1.0")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"0.3.0"`) {
|
|
t.Errorf("в ответе нет версии 0.3.0: %q", rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
|
|
t.Errorf("Cache-Control = %q, want no-cache", rec.Header().Get("Cache-Control"))
|
|
}
|
|
|
|
// Собственный манифест версии важнее latest.json.
|
|
rec = getUpdates(t, srv, "/updates/darwin/aarch64/0.2.0")
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"0.2.0"`) {
|
|
t.Fatalf("манифест версии 0.2.0 не отдан: status = %d, body = %q", rec.Code, rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Body.String(), "0.3.0") {
|
|
t.Errorf("для версии 0.2.0 отдан latest.json: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdatesArtifactWithoutLatestFallback: отсутствующий артефакт — это 404, а
|
|
// не манифест: иначе клиент скачал бы JSON вместо архива и подпись не сошлась бы.
|
|
func TestUpdatesArtifactWithoutLatestFallback(t *testing.T) {
|
|
srv, root := updatesFixture(t)
|
|
latest := `{"version":"0.3.0","platforms":{"darwin-aarch64":{"signature":"bmV3","url":"` +
|
|
baseURLForTests + `/updates/darwin/aarch64/glchat_0.3.0_aarch64.app.tar.gz"}}}`
|
|
if err := os.WriteFile(filepath.Join(root, "darwin", "aarch64", "latest.json"), []byte(latest), 0o644); err != nil {
|
|
t.Fatalf("write latest.json: %v", err)
|
|
}
|
|
|
|
rec := getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.9.9_aarch64.app.tar.gz")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 (body %q)", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdatesDirectoryAbsent: инстанс без каталога обновлений (свежая
|
|
// установка до первого релиза) отвечает 404, а не падает.
|
|
func TestUpdatesDirectoryAbsent(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
srv.updates = newUpdatesHandler(filepath.Join(t.TempDir(), "нет-каталога"))
|
|
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.1.0")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 при отсутствующем каталоге", rec.Code)
|
|
}
|
|
}
|