Files
selfpost/cmd/panel/restore_test.go
T
mixandClaude Opus 5.5 bd746c61d7 panel-redesign: paths follow the menu; Account and Server settings split
Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.

Routes (web.go)
  - /status -> /overview; /domains -> /outbound/domains with the domain's
    settings actions under .../settings/ (reports, ratelimit, export);
    /deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
    /outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
    /server/log; /backup -> /server/backup (import under it); /users ->
    /server/users; recheck and reload under /server/health/.
  - One fragment name: <page>/fragment everywhere.
  - Everything under /server/ is registered through server(), which wraps
    the handler in globalOnly: the subtree is guarded once, where a new
    route cannot forget it. Handlers keep their own requireGlobal.
  - GET / leads to /overview, /outbound/domains or /inbound/domains.
  - The /account -> /settings redirect is gone with /settings.

Account and Server > Settings
  - handlers_account.go: GET /account and three POSTs - profile (username,
    e-mail; no password asked, as designed), password (current password,
    rate-limited like a sign-in, signs out other sessions), dmarc (the
    user's default: hosted / account / custom / none, each validated).
    This replaces step 1's bridge through the old Settings form.
  - handlers_settings.go is the instance's page only: log retention and the
    read-only rate limits, global role.
  - One legacy template serves both pages by a flag until stage 2 gives each
    its own: a new legacy page cannot be added to the ratchet.

Still to come inside stage 1, and why @routes stays on the ratchet:
  - step 4: the application routes (one POST, under the domain);
  - step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.

Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).

Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 13/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:51:55 +03:00

491 lines
17 KiB
Go

package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into a project directory and starts the image, and the
// panel is expected to come up on it (architecture.md § Persistence). Nothing
// below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
// is started on the result through the same startup sequence run() uses:
// CheckRestore, store.Open, newPanel, Start.
const (
restorePassword = "correct-horse-battery"
restoreDomain = "bs.example.ru"
restoreSubject = "Order confirmation"
)
// restored is the outcome of a full backup-and-restore round trip.
type restored struct {
panel http.Handler // panel booted on the restored data directory
deployRoot string // the restored project directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
}
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
// has been in use, sign in, download a backup from it, extract that archive
// into an empty directory and boot a second panel there. A non-empty password
// takes the encrypted download and decrypts it on the way in, which is what an
// operator does with a .spbk file.
func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper()
live := seedPanelProject(t)
panel := bootPanel(t, live)
session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password)
target := t.TempDir()
extract(t, archive, target)
return restored{
panel: bootPanel(t, target),
deployRoot: target,
dataDir: filepath.Join(target, "data"),
session: session,
}
}
// The panel has to come up on the restored directory and show the state that
// was in the archive, without the operator touching anything else: the domain
// and its journal are in the database the archive carried, and the credentials
// that worked before the restore still work after it.
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "")
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
}
}
// The archive carries the daemon files, and the first boot after restore
// re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} {
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil {
t.Errorf("the restored data directory has no %s: %v", path, err)
continue
}
if string(got) != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
t.Errorf("the restored project directory has no %s: %v", name, err)
}
}
}
// The one-time setup link is closed by the presence of a panel user, and the
// restored database has one. A restore that reopened it would publish a link
// that creates a second global administrator on a server holding live mail
// credentials (security.md).
func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
r := restoreFromOwnBackup(t, "")
if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) {
t.Errorf("the restored panel wrote a setup token (stat err = %v)", err)
}
body := getPage(t, r.panel, "/login", nil)
if strings.Contains(body, "No administrator has been created yet") {
t.Errorf("the restored panel offers first-run setup:\n%s", body)
}
}
// Sessions live in the database, so they travel in the archive: a cookie that
// was valid when the backup was taken is valid again on the restored panel.
// That is the documented consequence of restoring an older backup (guide §
// Backup and restore) — stated here so it cannot change by accident.
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "")
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
}
}
// An encrypted download is the same archive inside an envelope, so it restores
// the same way once the password is supplied. The archive is never written to
// disk in the clear by the panel, so this is the only place the two paths can
// be shown to agree.
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password")
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
}
}
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
cfg := panelConfig(t, deployRoot)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
t.Fatalf("write stale map: %v", err)
}
manifest, err := json.Marshal(backup.Manifest{
Format: backup.FormatFull,
Version: buildinfo.Version,
CreatedAt: "2026-08-14T00:00:00Z",
})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("CheckRestore: %v", err)
}
if !restored {
t.Fatal("CheckRestore did not report a restore")
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
got, err := os.ReadFile(mapPath)
if err != nil {
t.Fatalf("read sender map: %v", err)
}
want := "@" + restoreDomain + " shop\n"
if string(got) != want {
t.Errorf("sender map = %q, want %q", got, want)
}
}
// The version guard is what stops a restore from being silently corrupted by
// schema skew, and it runs before anything opens the database. The manifest
// stays put on a mismatch: the operator's next move is to start the image the
// backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: "9.9.9",
DeployRoot: deployRoot,
}); err != nil {
t.Fatalf("create backup: %v", err)
}
target := t.TempDir()
extract(t, archive.Bytes(), target)
cfg := panelConfig(t, target)
_, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err == nil {
t.Fatal("the panel booted on a data directory left by another version")
}
for _, want := range []string{"9.9.9", buildinfo.Version} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err)
}
}
if _, statErr := os.Stat(cfg.manifestPath); statErr != nil {
t.Errorf("the manifest was consumed by a refused restore: %v", statErr)
}
}
// seedPanelProject builds an operator project tree: data/ with a panel that has
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
func seedPanelProject(t *testing.T) string {
t.Helper()
deployRoot := t.TempDir()
dataDir := filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
dom, err := st.AddDomain(restoreDomain, "selfpost")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add application: %v", err)
}
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop",
From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject,
}); err != nil {
t.Fatalf("insert send-log row: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} {
full := filepath.Join(dataDir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", full, err)
}
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", full, err)
}
}
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
return deployRoot
}
func writeDeployFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// panelConfig resolves the panel's own configuration for a project directory, so
// the test finds the files where the running binary would look for them rather
// than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, deployRoot string) config {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data
// directory; without this the panel would read the host's /data.
t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log"))
return loadConfig()
}
// bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler.
func bootPanel(t *testing.T, deployRoot string) http.Handler {
t.Helper()
cfg := panelConfig(t, deployRoot)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
}
if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil {
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
}
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open the restored database: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if restored {
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
}
panel, err := newPanel(cfg, st)
if err != nil {
t.Fatalf("build the panel: %v", err)
}
if err := panel.Start(); err != nil {
t.Fatalf("start the panel: %v", err)
}
return panel.Handler()
}
// signIn signs in as the seeded administrator and returns the session cookie.
func signIn(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
form := url.Values{"username": {"admin"}, "password": {restorePassword}}
rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil)
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("sign in issued no session cookie")
}
return cookies[0]
}
// downloadBackup takes a backup through the panel's own /backup route, the way
// the operator does. An empty password downloads the plain archive; otherwise
// the response is a .spbk envelope, which is decrypted here.
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
t.Helper()
form := url.Values{}
if password != "" {
form.Set("encrypt", "1")
form.Set("password", password)
form.Set("password_confirm", password)
}
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got)
}
body := rec.Body.Bytes()
if password == "" {
if secretfile.HasMagic(body) {
t.Fatal("an unencrypted download came back as an envelope")
}
return body
}
if !secretfile.HasMagic(body) {
t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear")
}
r, err := secretfile.NewReader(bytes.NewReader(body), password)
if err != nil {
t.Fatalf("open the encrypted backup: %v", err)
}
plain, err := io.ReadAll(r)
if err != nil {
t.Fatalf("decrypt the backup: %v", err)
}
return plain
}
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// directory before the image is started.
func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("the download is not a gzip stream: %v", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read the archive: %v", err)
}
name := filepath.Clean(filepath.FromSlash(hdr.Name))
if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
}
path := filepath.Join(dir, name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
t.Fatalf("write %s: %v", path, err)
}
if err := f.Close(); err != nil {
t.Fatalf("close %s: %v", path, err)
}
}
}
}
// getPage performs a GET and returns the body, failing on any non-200.
func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string {
t.Helper()
rec := request(t, h, http.MethodGet, target, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// request drives the panel's real handler chain, including the origin check,
// with the headers a browser on the panel's own page would send.
func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, "http://mail.example.ru"+target, body)
req.Host = "mail.example.ru"
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Sec-Fetch-Site", "same-origin")
}
if session != nil {
req.AddCookie(session)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}