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 = "502 Bad Gateway" 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) } }