Files
selfpost/internal/web/handlers/handlers_backup_test.go
T
mixandClaude Opus 5.5 49c872f836 backup-reauth: the account password before a backup or an export
Agreed with the owner on 2026-10-13, taken up on 2026-10-16 ("Да"); the
mockup is 1888dce. Both secret downloads - the full backup on Server ›
Backup and a domain's export on its settings - ask for "Your account
password": reauth (handlers_backup.go) checks the signed-in user's own
password with bcrypt, as a password change does, under the same limiter
(auth.AllowPasswordChange: ten in fifteen minutes per user, shared with the
Account form). A wrong or missing one is 401 on the same page with "Your
account password is incorrect.", logged; nothing is sent. The import and
the selfpost-backup CLI are not asked.

Also, the owner's phone screenshot of 2026-10-16 ("кнопка выбора файла не
такая, как в макете. Это косяк?"): the import's file field was a bare
<input type="file"> drawn by the browser ("Browse…"), where the mockup has
Bulma's .file - "Choose a file…" and the name chosen. The template now
follows the mockup; panel.js writes the name into .file-name on change and
at load (testdata/filename_test.js, run by TestPanelJS). No other file
field exists.

Tests: TestSecretDownloadsAskForTheAccountPassword (missing, wrong, right,
the limiter), the export refusals of the settings page with the password,
cmd/panel's restore tests and e2e's backups with it, e2e's refusal of a
backup without and with a wrong password. go vet + go test (26 packages)
and make e2e on the test server for this tree.

Docs: security.md § Backup and domain export, guide § Full backup and
restore, help (word for word with the mockup); CHANGELOG [Unreleased];
roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:39:13 +03:00

175 lines
6.8 KiB
Go

package handlers
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
)
// postForm builds the kind of request the backup and export forms submit.
func postForm(values url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/server/backup", strings.NewReader(values.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
// The encryption password is only ever typed once into a file nobody can
// recover without it, so every way of getting it wrong has to be caught before
// the archive is sealed — and leaving the box unticked has to keep producing
// the plain archive earlier versions produced.
func TestSecretFilePassword(t *testing.T) {
long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
tests := []struct {
name string
form url.Values
wantPass string
wantErr bool
}{
{
name: "unticked box means no encryption",
form: url.Values{"password": {long}, "password_confirm": {long}},
wantPass: "",
},
{
name: "ticked with a matching password",
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long}},
wantPass: long,
},
{
name: "mistyped confirmation",
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long + "!"}},
wantErr: true,
},
{
name: "too short",
form: url.Values{"encrypt": {"1"}, "password": {short}, "password_confirm": {short}},
wantErr: true,
},
{
name: "ticked but empty",
form: url.Values{"encrypt": {"1"}},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pass, errMsg := secretFilePassword(postForm(tt.form))
if tt.wantErr {
if errMsg == "" {
t.Fatalf("password %q accepted, want a rejection", pass)
}
if pass != "" {
t.Errorf("a rejected form still yielded password %q", pass)
}
return
}
if errMsg != "" {
t.Fatalf("unexpected rejection: %s", errMsg)
}
if pass != tt.wantPass {
t.Errorf("password = %q, want %q", pass, tt.wantPass)
}
})
}
}
// The messages the import form shows must distinguish the operator's likely
// mistakes; a wrong password and a tampered file stay deliberately merged.
func TestDecryptErrorMessage(t *testing.T) {
tests := []struct {
err error
want string
}{
{secretfile.ErrWrongPassword, "Wrong password"},
{fmt.Errorf("read: %w", secretfile.ErrCorrupt), "damaged"},
{secretfile.ErrNotEncrypted, "not a SelfPost export"},
{errors.New("something else"), "Could not decrypt"},
}
for _, tt := range tests {
if got := decryptErrorMessage(tt.err); !strings.Contains(got, tt.want) {
t.Errorf("decryptErrorMessage(%v) = %q, want it to mention %q", tt.err, got, tt.want)
}
}
}
// The encryption controls are shared markup pulled into two pages; a page that
// forgets to include the partial (or the data it needs) loses the option
// silently, since the plain download still works.
func TestBackupPageOffersEncryption(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
rec := httptest.NewRecorder()
h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/server/backup", nil),
http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String()
for _, want := range []string{
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
"data-import-password-fields",
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
"The two passwords do not match.",
} {
if !strings.Contains(body, want) {
t.Errorf("backup page is missing %q", want)
}
}
}
// An empty certs/ is not "could not read": the page says what is missing and
// where it goes (review of audit stage 3).
func TestEmptyCertsMessage(t *testing.T) {
msg := unreadableBackupErr(fmt.Errorf("check: %w", backup.ErrNoCerts), "/selfpost-deploy")
if !strings.Contains(msg, "certs/ holds no certificate") || !strings.Contains(msg, "fullchain.pem") {
t.Errorf("message = %q", msg)
}
}
// A session is not enough for a secret download (backup-reauth): the full
// backup and a domain's export ask for the signed-in user's own password.
// Wrong or missing, nothing is sent and the page says so; right, the request
// goes on to its own checks. Guesses share the password change's limiter.
func TestSecretDownloadsAskForTheAccountPassword(t *testing.T) {
s := newAppStand(t)
exportPath := "/outbound/domains/" + idStr(s.d.ID) + "/settings/export"
for _, pw := range []string{"", "wrong-password-entirely"} {
rec := postFormAs(s.h.HandleExportDomain, globalPrincipal, exportPath, s.paths, url.Values{"account_password": {pw}})
if rec.Code != http.StatusUnauthorized || rec.Header().Get("Content-Disposition") != "" ||
!strings.Contains(rec.Body.String(), "Your account password is incorrect.") {
t.Fatalf("export with account password %q = %d, %q:\n%.600s", pw, rec.Code, rec.Header().Get("Content-Disposition"), rec.Body.String())
}
rec = postFormAs(s.h.HandleBackup, globalPrincipal, "/server/backup", nil, url.Values{"account_password": {pw}})
if rec.Code != http.StatusUnauthorized || rec.Header().Get("Content-Disposition") != "" ||
!strings.Contains(rec.Body.String(), "Your account password is incorrect.") ||
!strings.Contains(rec.Body.String(), `name="account_password" type="password" autocomplete="current-password" required`) {
t.Fatalf("backup with account password %q = %d:\n%.600s", pw, rec.Code, rec.Body.String())
}
}
// The right password lets the export through: a plain JSON download.
rec := postFormAs(s.h.HandleExportDomain, globalPrincipal, exportPath, s.paths, url.Values{"account_password": {"correct-password-here!"}})
if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Disposition"), "selfpost-domain-example.org.json") {
t.Fatalf("export with the right password = %d, %q:\n%.600s", rec.Code, rec.Header().Get("Content-Disposition"), rec.Body.String())
}
// Five checks were made above, and the limiter allows ten in its window:
// after six more, a check is refused before the password is looked at,
// the right one included.
for i := 0; i < 6; i++ {
postFormAs(s.h.HandleBackup, globalPrincipal, "/server/backup", nil, url.Values{"account_password": {"guess"}})
}
rec = postFormAs(s.h.HandleExportDomain, globalPrincipal, exportPath, s.paths, url.Values{"account_password": {"correct-password-here!"}})
if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "Too many attempts.") {
t.Fatalf("after ten checks = %d:\n%.600s", rec.Code, rec.Body.String())
}
}