Files
lpra/internal/server/autoupdate_test.go

273 lines
9.1 KiB
Go

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)
}
}