Files
stalwart-migrator/internal/validate/live_test.go
T
jcoffey-dev 12ec0c3fd4 Rename the module to the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. Here that is not a
documentation change: the old organisation was the module path, so it is
declared in go.mod and repeated in every internal import.

Leaving it would have been worse than a stale link. GitHub redirects the
repository, but a go.mod whose module line disagrees with the path it was
fetched from is an error rather than a redirect, so `go get` on the new
address would have failed against the old declaration.

go.mod, 34 files of imports, and the repository links in README and
ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved,
only our own path.
2026-08-30 15:24:18 -07:00

266 lines
9.9 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package validate
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
)
// A run that never captured a "before" cannot be checked against one. The
// point of these two is that such a run reports as unchecked rather than as
// passing: ARCHITECTURE.md §4.7 is explicit that this suite must not imply a
// guarantee it did not measure.
func TestRunLiveSkipsWithoutSnapshot(t *testing.T) {
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{AdminURL: "https://mail.example.org", Before: nil})
if err != nil {
t.Fatalf("RunLive: %v", err)
}
if got := report.Results[0].Status; got != StatusSkip {
t.Fatalf("status = %q, want %q", got, StatusSkip)
}
if report.Blocking() {
t.Fatal("a skipped check must not block the run")
}
if !strings.Contains(report.Results[0].Detail, "nothing to compare") {
t.Fatalf("detail should say why it was skipped, got %q", report.Results[0].Detail)
}
}
func TestRunLiveSkipsWithoutAdminURL(t *testing.T) {
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{Before: &checkpoint.PreflightSnapshot{}})
if err != nil {
t.Fatalf("RunLive: %v", err)
}
if got := report.Results[0].Status; got != StatusSkip {
t.Fatalf("status = %q, want %q", got, StatusSkip)
}
}
func TestRunLivePassesWhenEverythingSurvived(t *testing.T) {
srv := fakeInstance(t, []string{"example.org"}, map[string]float64{"[email protected]": 10, "[email protected]": 20})
defer srv.Close()
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "admin", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{
Domains: []string{"example.org"},
UsedQuota: map[string]int64{"[email protected]": 1, "[email protected]": 2},
},
})
if err != nil {
t.Fatalf("RunLive: %v", err)
}
if report.Blocking() {
t.Fatalf("expected a pass, got: %s", report.String())
}
if got := report.Results[0].Status; got != StatusOK {
t.Fatalf("status = %q, want %q", got, StatusOK)
}
}
func TestRunLiveFailsWhenAnAccountIsMissing(t *testing.T) {
// bob did not make it across.
srv := fakeInstance(t, []string{"example.org"}, map[string]float64{"[email protected]": 10})
defer srv.Close()
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "admin", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{
Domains: []string{"example.org"},
UsedQuota: map[string]int64{"[email protected]": 1, "[email protected]": 2},
},
})
// The comparison ran and found something: that is a finding, not an
// error, so the step completes and the report carries the verdict.
if err != nil {
t.Fatalf("RunLive returned an error for a completed comparison: %v", err)
}
if !report.Blocking() {
t.Fatalf("a missing account must block, got: %s", report.String())
}
if !strings.Contains(report.Results[0].Detail, "[email protected]") {
t.Fatalf("the report should name the missing account, got %q", report.Results[0].Detail)
}
// And it must be recorded, so `report <run-id>` can say so afterwards.
step := rs.Outcome(checkpoint.PhaseValidate, "content-integrity")
if step.Verdict != string(StatusFail) {
t.Fatalf("checkpoint verdict = %q, want %q", step.Verdict, StatusFail)
}
}
func TestRunLiveWarnsButDoesNotBlockWhenADomainIsMissing(t *testing.T) {
// What the two versions call a domain differs across the 0.15/0.16
// boundary - principals on one side, Domain objects on the other, with
// aliases counted differently - and INBUXA's own before-list was
// inflated with alias domains that the after-list structurally cannot
// contain. Failing the migration on that would abort a run that lost
// nothing, so it is reported and not treated as data loss.
srv := fakeInstance(t, []string{"example.org"}, map[string]float64{"[email protected]": 10})
defer srv.Close()
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "admin", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{
Domains: []string{"example.org", "alias.example"},
UsedQuota: map[string]int64{"[email protected]": 1},
},
})
if err != nil {
t.Fatalf("RunLive: %v", err)
}
if report.Blocking() {
t.Fatalf("a domain-only difference must not abort the migration, got: %s", report.String())
}
if got := report.Results[0].Status; got != StatusWarn {
t.Fatalf("status = %q, want %q", got, StatusWarn)
}
if !strings.Contains(report.Results[0].Detail, "alias.example") {
t.Fatalf("the operator still needs to be told which domain, got %q", report.Results[0].Detail)
}
}
func TestRunLiveStillBlocksWhenAnAccountAndADomainAreMissing(t *testing.T) {
// A lost account is a lost account, whatever the domain list says.
srv := fakeInstance(t, []string{"example.org"}, map[string]float64{"[email protected]": 10})
defer srv.Close()
store, rs := newRun(t)
report, _ := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "admin", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{
Domains: []string{"example.org", "alias.example"},
UsedQuota: map[string]int64{"[email protected]": 1, "[email protected]": 2},
},
})
if !report.Blocking() {
t.Fatalf("a missing account must still block, got: %s", report.String())
}
}
func TestRunLiveFailsWhenTheInstanceCannotBeRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "admin", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{Domains: []string{"example.org"}},
})
if err == nil {
t.Fatal("expected an error when the instance cannot be read")
}
if !report.Blocking() {
t.Fatalf("being unable to look must not read as a pass, got: %s", report.String())
}
}
func newRun(t *testing.T) (*checkpoint.Store, *checkpoint.RunState) {
t.Helper()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("create run: %v", err)
}
return store, rs
}
// fakeInstance answers the principal listing the snapshot is built from.
func fakeInstance(t *testing.T, domains []string, accounts map[string]float64) *httptest.Server {
t.Helper()
items := make([]map[string]any, 0, len(domains)+len(accounts))
for _, d := range domains {
items = append(items, map[string]any{"type": "domain", "name": d})
}
for name, quota := range accounts {
items = append(items, map[string]any{"type": "individual", "name": name, "usedQuota": quota})
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Honour ?types= the way the real REST API does: asking for
// individuals must not hand back domains, or every count is wrong.
want := r.URL.Query().Get("types")
out := items
if want != "" {
out = nil
for _, it := range items {
if it["type"] == want {
out = append(out, it)
}
}
}
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"items": out, "total": len(out)}})
}))
}
// Enumeration is permission-scoped. An account that authenticates but holds
// no management role is shown only what it may see, and a v0.16 migration
// does not always carry an admin role across - so the account that read the
// "before" side may have less reach afterwards. Observed on a clone of
// production: every account survived, SMTP confirmed it, and the comparison
// still called one lost because the reader could no longer see it.
func TestRunLiveWillNotCallItLossWhenItCouldNotLook(t *testing.T) {
// The migrated instance shows only the reader's own account.
srv := fakeInstance(t, []string{"example.org"}, map[string]float64{"[email protected]": 10})
defer srv.Close()
store, rs := newRun(t)
report, err := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "[email protected]", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{
Domains: []string{"example.org"},
UsedQuota: map[string]int64{"[email protected]": 1, "[email protected]": 2, "[email protected]": 3},
},
})
if err != nil {
t.Fatalf("RunLive: %v", err)
}
// It still fails: an unverified migration is not a verified one. What it
// must not do is call it data loss, which is a different claim.
if !report.Blocking() {
t.Fatal("an unverifiable result must still stop the run")
}
detail := report.Results[0].Detail
for _, want := range []string{"COULD NOT VERIFY", "not the same as data loss", "permission-scoped", "1 of 3"} {
if !strings.Contains(detail, want) {
t.Fatalf("detail should contain %q, got %q", want, detail)
}
}
}
// The other side of it: when the instance shows everything and an account is
// genuinely absent, that is still a failure.
func TestRunLiveStillFailsWhenTheInstanceShowedEverything(t *testing.T) {
srv := fakeInstance(t, []string{"example.org"}, map[string]float64{
"[email protected]": 10, "[email protected]": 20, "[email protected]": 30,
})
defer srv.Close()
store, rs := newRun(t)
report, _ := RunLive(context.Background(), store, rs, LiveOptions{
AdminURL: srv.URL, AdminUser: "[email protected]", AdminPassword: "pw", HTTPClient: srv.Client(),
Before: &checkpoint.PreflightSnapshot{
Domains: []string{"example.org"},
UsedQuota: map[string]int64{"[email protected]": 1, "[email protected]": 2},
},
})
if !report.Blocking() {
t.Fatalf("a genuine loss must still block, got: %s", report.String())
}
}