feat: Настройки › Автообновление - clients compare their version with the server's every N and update themselves; the server looks at GitLab every N and, when a newer release is out, offers it to an administrator at login (after the PIN) with «Обновить» and «Отказаться»; the words of a made picture's result are the model's only
This commit is contained in:
1 parent
916a49958b
commit
3d2f886f04
22 files changed
+1074
-7
No files matched your search
@@ -0,0 +1,272 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fakeGitLab is the place releases come from: the latest release's tag and
|
||||
// the install script on main.
|
||||
type fakeGitLab struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
tag string
|
||||
script string
|
||||
hits int
|
||||
}
|
||||
|
||||
func newFakeGitLab(t *testing.T, tag string) *fakeGitLab {
|
||||
g := &fakeGitLab{tag: tag, script: "#!/bin/sh\n# LPRA server: install or update in one command.\necho updating\n"}
|
||||
g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.hits++
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v4/projects/itzredtea%2Flpra/releases/permalink/latest":
|
||||
// The real one sends the caller on to the release itself.
|
||||
http.Redirect(w, r, "/api/v4/projects/1/releases/"+g.tag, http.StatusFound)
|
||||
case "/api/v4/projects/1/releases/" + g.tag:
|
||||
fmt.Fprintf(w, `{"tag_name":%q}`, g.tag)
|
||||
case "/itzredtea/lpra/-/raw/main/server-install.sh":
|
||||
fmt.Fprint(w, g.script)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(g.srv.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
func useFakeGitLab(s *Server, g *fakeGitLab) {
|
||||
_ = s.db.SetConfig(cfgUpdateBase, g.srv.URL)
|
||||
}
|
||||
|
||||
// A runner that only notes what it was given, in place of a real update.
|
||||
type runs struct {
|
||||
mu sync.Mutex
|
||||
scripts []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *runs) run(script []byte) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.scripts = append(r.scripts, string(script))
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *runs) n() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.scripts) }
|
||||
|
||||
// postAuto sends the form of Настройки › Автообновление.
|
||||
func postAuto(t *testing.T, s *Server, c *http.Cookie, f url.Values) {
|
||||
t.Helper()
|
||||
f.Set("csrf", "tok")
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings/autoupdate", strings.NewReader(f.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(c)
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The settings are kept in minutes whatever unit they were given in, and
|
||||
// off unless asked for.
|
||||
func TestAutoUpdateSettings(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
if on, _ := s.agentAuto(); on {
|
||||
t.Fatal("agents update by themselves with nothing set")
|
||||
}
|
||||
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"2"}, "agent_unit": {"h"}, "server_auto": {"1"}, "server_every": {"1"}, "server_unit": {"d"}})
|
||||
if on, m := s.agentAuto(); !on || m != 120 {
|
||||
t.Fatalf("agents: %v %d", on, m)
|
||||
}
|
||||
if on, m := s.serverAuto(); !on || m != 1440 {
|
||||
t.Fatalf("server: %v %d", on, m)
|
||||
}
|
||||
// Out of range or not a number: the default, or the largest.
|
||||
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"x"}, "agent_unit": {"min"}, "server_every": {"999"}, "server_unit": {"d"}})
|
||||
if _, m := s.agentAuto(); m != defaultAutoMin {
|
||||
t.Fatalf("a bad number: %d", m)
|
||||
}
|
||||
if on, m := s.serverAuto(); on || m != maxAutoMin {
|
||||
t.Fatalf("server off, a large number: %v %d", on, m)
|
||||
}
|
||||
body := get(t, s, c, "/settings?frag=1").Body.String()
|
||||
if !strings.Contains(body, `id="autoupdate"`) || !strings.Contains(body, "Автообновление клиентов") || !strings.Contains(body, "Автообновление сервера") {
|
||||
t.Fatal("the settings page has no auto-update card")
|
||||
}
|
||||
}
|
||||
|
||||
// Agents are told of it - on the config they fetch, and with their poll -
|
||||
// together with the server's own version.
|
||||
func TestAgentsToldOfAutoUpdate(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
if err := s.db.CreateAgent(Agent{ID: "a2", Name: "n", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: hashToken("tok2")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := func() http.Header {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent/config", nil)
|
||||
req.Header.Set("X-Agent-Id", "a2")
|
||||
req.Header.Set("Authorization", "Bearer tok2")
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("config: %d", w.Code)
|
||||
}
|
||||
return w.Header()
|
||||
}
|
||||
h := cfg()
|
||||
if h.Get(proto.AutoUpdateHeader) != "0" || h.Get(proto.ServerVersionHeader) != Version {
|
||||
t.Fatalf("off: %v", h)
|
||||
}
|
||||
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"90"}, "agent_unit": {"min"}})
|
||||
if h := cfg(); h.Get(proto.AutoUpdateHeader) != "90" {
|
||||
t.Fatalf("on: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// The latest release is read from GitLab, through its redirect.
|
||||
func TestLatestRelease(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
g := newFakeGitLab(t, "9.9.9")
|
||||
useFakeGitLab(s, g)
|
||||
tag, err := s.latestRelease(t.Context())
|
||||
if err != nil || tag != "9.9.9" {
|
||||
t.Fatalf("latest: %q %v", tag, err)
|
||||
}
|
||||
g.srv.Close()
|
||||
if _, err := s.latestRelease(t.Context()); err == nil {
|
||||
t.Fatal("no error with GitLab out of reach")
|
||||
}
|
||||
}
|
||||
|
||||
// With auto-update on the server looks at GitLab by itself, at the interval,
|
||||
// and only looks: updating is the administrator's to say yes to.
|
||||
func TestServerAutoUpdateTick(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
g := newFakeGitLab(t, "99.0.0")
|
||||
useFakeGitLab(s, g)
|
||||
r := &runs{}
|
||||
s.updRun = r.run
|
||||
now := time.Now()
|
||||
|
||||
s.autoUpdateTick(now)
|
||||
if g.hits != 0 {
|
||||
t.Fatalf("off, yet it looked: %d hits", g.hits)
|
||||
}
|
||||
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
|
||||
s.autoUpdateTick(now)
|
||||
if st := s.updateStatus(); st.Latest != "99.0.0" || !st.Newer {
|
||||
t.Fatalf("it did not learn of the release: %+v", st)
|
||||
}
|
||||
hits := g.hits
|
||||
s.autoUpdateTick(now.Add(10 * time.Minute))
|
||||
if g.hits != hits {
|
||||
t.Fatal("it looked again before the interval was out")
|
||||
}
|
||||
s.autoUpdateTick(now.Add(61 * time.Minute))
|
||||
if g.hits == hits {
|
||||
t.Fatal("it did not look when the interval was out")
|
||||
}
|
||||
if r.n() != 0 {
|
||||
t.Fatalf("it updated by itself: %d runs", r.n())
|
||||
}
|
||||
// GitLab out of reach: told on the page, kept until it answers.
|
||||
g.srv.Close()
|
||||
s.autoUpdateTick(now.Add(130 * time.Minute))
|
||||
if st := s.updateStatus(); st.Error == "" {
|
||||
t.Fatalf("the failure is not kept: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// What an administrator is offered at login: a newer release, with
|
||||
// auto-update on, not one they said no to - and again when a newer one comes.
|
||||
func TestUpdateOffer(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
g := newFakeGitLab(t, "99.0.0")
|
||||
useFakeGitLab(s, g)
|
||||
offer := func() string { return apiCall(t, s, c, "GET", "/api/panel/update/offer", "") }
|
||||
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||
t.Fatalf("auto-update off: %s", got)
|
||||
}
|
||||
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
|
||||
got := offer()
|
||||
if !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
|
||||
t.Fatalf("a newer release: %s", got)
|
||||
}
|
||||
apiCall(t, s, c, "POST", "/api/panel/update/decline", `{"version":"99.0.0"}`)
|
||||
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||
t.Fatalf("after saying no: %s", got)
|
||||
}
|
||||
g.tag = "99.1.0"
|
||||
s.upd.mu.Lock()
|
||||
s.upd.checked = time.Time{}
|
||||
s.upd.mu.Unlock()
|
||||
if got := offer(); !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.1.0"`) {
|
||||
t.Fatalf("a still newer release: %s", got)
|
||||
}
|
||||
g.tag = Version
|
||||
s.upd.mu.Lock()
|
||||
s.upd.checked = time.Time{}
|
||||
s.upd.mu.Unlock()
|
||||
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||
t.Fatalf("nothing newer: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Anything but the install script is not run: a page of GitLab's, say.
|
||||
func TestUpdateScriptChecked(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
g := newFakeGitLab(t, "99.0.0")
|
||||
g.script = "<html>502 Bad Gateway</html>"
|
||||
useFakeGitLab(s, g)
|
||||
r := &runs{}
|
||||
s.updRun = r.run
|
||||
if err := s.startServerUpdate(t.Context()); err == nil || r.n() != 0 {
|
||||
t.Fatalf("a page was run as a script: %v, %d runs", err, r.n())
|
||||
}
|
||||
}
|
||||
|
||||
// From the panel: check now, and update now - only to a newer release.
|
||||
func TestUpdatePanelButtons(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
g := newFakeGitLab(t, "99.0.0")
|
||||
useFakeGitLab(s, g)
|
||||
r := &runs{}
|
||||
s.updRun = r.run
|
||||
got := apiCall(t, s, c, "POST", "/api/panel/update/check", "")
|
||||
if !strings.Contains(got, `"newer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
|
||||
t.Fatalf("check: %s", got)
|
||||
}
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":true`) || r.n() != 1 {
|
||||
t.Fatalf("run: %s, %d runs", got, r.n())
|
||||
}
|
||||
g.tag = Version
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":false`) || r.n() != 1 {
|
||||
t.Fatalf("run with nothing newer: %s, %d runs", got, r.n())
|
||||
}
|
||||
}
|
||||
|
||||
// Not for everybody: only an administrator updates the server.
|
||||
func TestUpdateOnlyForAdmins(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
if a == nil {
|
||||
t.Fatal("no admin")
|
||||
}
|
||||
// The test's user is an administrator; the guard is the route's own.
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/update/check", ""); strings.Contains(got, "только для администраторов") {
|
||||
t.Fatalf("an administrator was refused: %s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user