The second production attempt failed during recovery-mode apply, with the
mail server already stopped and the store already at schema v6:
create Account restore-13: invalidForeignKey | Object id: Domain#d
v0.16 requires a tenant-scoped Account to sit on a Domain owned by that
same tenant, for its primary domain and for every alias. v0.15 imposed no
such rule, and migrate_v016.py carries the two facts over independently:
_build_domains sets a domain's memberTenantId only for domains declared as
their own `domain` principal with a `tenant`, while _build_user sets the
account's from the account's own record. A domain that exists only inside
an email address is inferred, gets no tenant, and every tenant-scoped
account using it is then rejected.
Established by reproduction rather than inference: a synthetic v0.15
principal dump, run through the unpatched upstream converter and applied to
a real 0.16.14 in recovery mode, reproduces the error character for
character - the `#d` is the server's own object id for the offending
domain, not a plan client-id. The same harness establishes which directions
are constrained: a tenant-scoped account on a tenant-less domain or on
another tenant's domain is rejected; a global account on a tenant-owned
domain is accepted.
- applyplan.ReconcileDomainTenants repairs the plan between convert and
apply. Where a tenant-less domain is used only by accounts of one
tenant, the domain adopts that tenant - the sole assignment that both
applies and keeps every account. Where accounts genuinely disagree it
changes nothing and reports why, because forcing such a plan through
would mean dropping mailboxes.
- stalwartapi.FetchTenantLayout maps tenant membership over the 0.15 REST
API and predicts the outcome with the same rule the server enforces, so
preflight either warns about the domains that will adopt a tenant or
fails - while the service is still running.
- The plan is parsed generically rather than through the typed Operation.
A real export.json mixes shapes: `create` maps a client-id to an object,
`update` carries a flat one. The typed form failed on the first `update`
line, found by running against actual converter output. Numbers decode
as json.Number so a 10 GiB quota is not rewritten as 1.073741824e+10.
Corrects the record: the previous commit claimed the converter emits every
Account with `tenantId: null` and made preflight refuse every multi-tenant
install on that basis. The field is memberTenantId, the converter does
populate it, and the export had been inspected for a key no version of the
script ever writes. The refusal is now narrowed to what v0.16 genuinely
cannot represent.
The same fix has been prepared for migrate_v016.py upstream. The tool
downloads that script rather than vendoring it, so the repair stays here
until a released version carries it, and is a no-op on a consistent plan.
330 lines
10 KiB
Go
330 lines
10 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package applyplan
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The four cases below are the ones checked against a real Stalwart 0.16.14
|
|
// in recovery mode, via `stalwart-cli apply`, before this code was written:
|
|
//
|
|
// tenant account -> tenant-less domain : invalidForeignKey (repaired here)
|
|
// tenant account -> other tenant's dom : invalidForeignKey (unrepresentable)
|
|
// global account -> tenant-owned domain: accepted (left alone)
|
|
// tenant account -> own tenant's domain: accepted (left alone)
|
|
func plan(ops ...PlanOp) []PlanOp { return ops }
|
|
|
|
func tenantOp(cid, name string) PlanOp {
|
|
return PlanOp{"@type": "create", "object": "Tenant",
|
|
"value": map[string]any{cid: map[string]any{"name": name}}}
|
|
}
|
|
|
|
func domainOp(cid, name string, tenantRef string) PlanOp {
|
|
body := map[string]any{"name": name}
|
|
if tenantRef != "" {
|
|
body["memberTenantId"] = tenantRef
|
|
}
|
|
return PlanOp{"@type": "create", "object": "Domain",
|
|
"value": map[string]any{cid: body}}
|
|
}
|
|
|
|
func accountOp(cid, name, domainRef, tenantRef string, aliasDomainRefs ...string) PlanOp {
|
|
body := map[string]any{"@type": "User", "name": name, "domainId": domainRef}
|
|
if tenantRef != "" {
|
|
body["memberTenantId"] = tenantRef
|
|
}
|
|
if len(aliasDomainRefs) > 0 {
|
|
aliases := map[string]any{}
|
|
for i, r := range aliasDomainRefs {
|
|
aliases[string(rune('0'+i))] = map[string]any{"name": name, "domainId": r}
|
|
}
|
|
body["aliases"] = aliases
|
|
}
|
|
return PlanOp{"@type": "create", "object": "Account",
|
|
"value": map[string]any{cid: body}}
|
|
}
|
|
|
|
func domainTenant(t *testing.T, ops []PlanOp, cid string) string {
|
|
t.Helper()
|
|
for _, op := range ops {
|
|
if op.objectName() != "Domain" {
|
|
continue
|
|
}
|
|
if body, ok := op.createBodies()[cid]; ok {
|
|
s, _ := body["memberTenantId"].(string)
|
|
return s
|
|
}
|
|
}
|
|
t.Fatalf("no domain %q in plan", cid)
|
|
return ""
|
|
}
|
|
|
|
func TestReconcileAdoptsTenantForInferredDomain(t *testing.T) {
|
|
ops := plan(
|
|
tenantOp("t0", "acme"),
|
|
domainOp("d0", "acme-corp.test", "#t0"),
|
|
domainOp("d1", "inferred.test", ""), // never declared in v0.15
|
|
accountOp("a0", "bob", "#d0", "#t0", "#d1"),
|
|
)
|
|
res := ReconcileDomainTenants(ops)
|
|
if !res.OK() {
|
|
t.Fatalf("unexpected conflicts: %s", res.String())
|
|
}
|
|
if got := domainTenant(t, ops, "d1"); got != "#t0" {
|
|
t.Errorf("inferred domain tenant = %q, want %q", got, "#t0")
|
|
}
|
|
if len(res.Adoptions) != 1 || res.Adoptions[0].Domain != "inferred.test" {
|
|
t.Errorf("adoptions = %+v, want one for inferred.test", res.Adoptions)
|
|
}
|
|
if res.Adoptions[0].Tenant != "acme" {
|
|
t.Errorf("adoption tenant = %q, want the tenant name %q", res.Adoptions[0].Tenant, "acme")
|
|
}
|
|
}
|
|
|
|
func TestReconcileLeavesGlobalAccountOnTenantDomainAlone(t *testing.T) {
|
|
// Confirmed accepted by 0.16.14: a global account may live on a
|
|
// tenant-owned domain. Nothing should be invented for it.
|
|
ops := plan(
|
|
tenantOp("t0", "acme"),
|
|
domainOp("d0", "acme-corp.test", "#t0"),
|
|
accountOp("a0", "admin", "#d0", ""),
|
|
)
|
|
res := ReconcileDomainTenants(ops)
|
|
if !res.OK() || len(res.Adoptions) != 0 {
|
|
t.Fatalf("expected no changes, got %s", res.String())
|
|
}
|
|
}
|
|
|
|
func TestReconcileLeavesTenantlessDomainWithOnlyGlobalUsers(t *testing.T) {
|
|
ops := plan(
|
|
tenantOp("t0", "acme"),
|
|
domainOp("d0", "global.test", ""),
|
|
accountOp("a0", "carol", "#d0", ""),
|
|
)
|
|
res := ReconcileDomainTenants(ops)
|
|
if got := domainTenant(t, ops, "d0"); got != "" {
|
|
t.Errorf("global domain gained tenant %q; it has no tenant-scoped users", got)
|
|
}
|
|
if len(res.Adoptions) != 0 {
|
|
t.Errorf("adoptions = %+v, want none", res.Adoptions)
|
|
}
|
|
}
|
|
|
|
func TestReconcileReportsTwoTenantsSharingADomain(t *testing.T) {
|
|
ops := plan(
|
|
tenantOp("t0", "alpha"),
|
|
tenantOp("t1", "beta"),
|
|
domainOp("d0", "shared.test", ""),
|
|
accountOp("a0", "u1", "#d0", "#t0"),
|
|
accountOp("a1", "u2", "#d0", "#t1"),
|
|
)
|
|
res := ReconcileDomainTenants(ops)
|
|
if res.OK() {
|
|
t.Fatal("expected a conflict for a domain shared by two tenants")
|
|
}
|
|
if got := domainTenant(t, ops, "d0"); got != "" {
|
|
t.Errorf("conflicting domain was modified to %q; it must be left untouched", got)
|
|
}
|
|
c := res.Conflicts[0]
|
|
if c.Domain != "shared.test" || len(c.Tenants) != 2 {
|
|
t.Errorf("conflict = %+v, want shared.test across two tenants", c)
|
|
}
|
|
for _, want := range []string{"alpha", "beta"} {
|
|
if !strings.Contains(c.Detail, want) {
|
|
t.Errorf("conflict detail %q does not name tenant %q", c.Detail, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconcileReportsAccountOnAnotherTenantsDomain(t *testing.T) {
|
|
ops := plan(
|
|
tenantOp("t0", "alpha"),
|
|
tenantOp("t1", "beta"),
|
|
domainOp("d0", "owned.test", "#t0"),
|
|
accountOp("a0", "u2", "#d0", "#t1"),
|
|
)
|
|
res := ReconcileDomainTenants(ops)
|
|
if res.OK() {
|
|
t.Fatal("expected a conflict when an account's tenant differs from its domain's")
|
|
}
|
|
if got := domainTenant(t, ops, "d0"); got != "#t0" {
|
|
t.Errorf("domain ownership changed to %q; declared ownership must not be overwritten", got)
|
|
}
|
|
}
|
|
|
|
func TestReconcileIsNoOpWithoutTenants(t *testing.T) {
|
|
ops := plan(
|
|
domainOp("d0", "example.test", ""),
|
|
accountOp("a0", "alice", "#d0", ""),
|
|
)
|
|
res := ReconcileDomainTenants(ops)
|
|
if !res.OK() || len(res.Adoptions) != 0 {
|
|
t.Fatalf("single-tenant plan should be untouched, got %s", res.String())
|
|
}
|
|
if res.String() != "no domain/tenant mismatches" {
|
|
t.Errorf("summary = %q", res.String())
|
|
}
|
|
}
|
|
|
|
func TestReconcileFileRoundTripsAndRewritesOnlyWhenChanged(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "export.json")
|
|
|
|
ops := plan(
|
|
tenantOp("t0", "acme"),
|
|
domainOp("d0", "acme-corp.test", "#t0"),
|
|
domainOp("d1", "inferred.test", ""),
|
|
accountOp("a0", "bob", "#d0", "#t0", "#d1"),
|
|
)
|
|
if err := WritePlan(path, ops); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
res, err := ReconcileDomainTenantsFile(path)
|
|
if err != nil {
|
|
t.Fatalf("reconcile file: %v", err)
|
|
}
|
|
if len(res.Adoptions) != 1 {
|
|
t.Fatalf("adoptions = %+v", res.Adoptions)
|
|
}
|
|
|
|
reread, err := ReadPlan(path)
|
|
if err != nil {
|
|
t.Fatalf("reread: %v", err)
|
|
}
|
|
if got := domainTenant(t, reread, "d1"); got != "#t0" {
|
|
t.Errorf("rewritten plan has tenant %q, want %q", got, "#t0")
|
|
}
|
|
|
|
// Second pass must be a no-op: the plan is already consistent.
|
|
before, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res2, err := ReconcileDomainTenantsFile(path)
|
|
if err != nil {
|
|
t.Fatalf("second reconcile: %v", err)
|
|
}
|
|
if len(res2.Adoptions) != 0 {
|
|
t.Errorf("second pass adopted %+v, want none", res2.Adoptions)
|
|
}
|
|
after, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(before) != string(after) {
|
|
t.Error("second pass rewrote an already-consistent plan")
|
|
}
|
|
}
|
|
|
|
func TestReconcileFileRefusesConflictingPlan(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "export.json")
|
|
ops := plan(
|
|
tenantOp("t0", "alpha"),
|
|
tenantOp("t1", "beta"),
|
|
domainOp("d0", "shared.test", ""),
|
|
accountOp("a0", "u1", "#d0", "#t0"),
|
|
accountOp("a1", "u2", "#d0", "#t1"),
|
|
)
|
|
if err := WritePlan(path, ops); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
before, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ReconcileDomainTenantsFile(path); err == nil {
|
|
t.Fatal("expected an error for an unrepresentable plan")
|
|
}
|
|
after, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(before) != string(after) {
|
|
t.Error("a plan that could not be repaired was rewritten anyway")
|
|
}
|
|
}
|
|
|
|
func TestReadPlanHandlesLongLines(t *testing.T) {
|
|
// Plans embed certificates and DKIM private keys inline, which blow
|
|
// past bufio.Scanner's default 64KiB line limit.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "big.json")
|
|
huge := strings.Repeat("x", 200<<10)
|
|
ops := plan(PlanOp{"@type": "update", "object": "Certificate",
|
|
"value": map[string]any{"c0": map[string]any{"cert": huge}}})
|
|
if err := WritePlan(path, ops); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
got, err := ReadPlan(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].createBodies()["c0"]["cert"] != huge {
|
|
t.Error("long line did not round-trip")
|
|
}
|
|
}
|
|
|
|
// A real export.json mixes `create` operations (client-id keyed) with
|
|
// `update` operations carrying a flat object. An earlier version of this
|
|
// code modelled every operation the first way and failed to parse a real
|
|
// plan at the first `update` line - found by running it against actual
|
|
// migrate_v016.py output rather than hand-built fixtures.
|
|
func TestReconcileHandlesRealExportShapes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "export.json")
|
|
raw := strings.Join([]string{
|
|
`{"@type":"create","object":"Tenant","value":{"create-0":{"name":"acme","quotas":{}}}}`,
|
|
`{"@type":"create","object":"Domain","value":{"create-1":{"name":"acme-corp.test","memberTenantId":"#create-0"},"create-2":{"name":"inferred.test"}}}`,
|
|
`{"@type":"create","object":"Account","value":{"restore-5":{"@type":"User","name":"bob","domainId":"#create-1","memberTenantId":"#create-0","aliases":{"0":{"name":"bob","domainId":"#create-2"}},"quotas":{"maxDiskQuota":10737418240}}}}`,
|
|
`{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":"#create-1","defaultHostname":"mail.acme-corp.test"}}`,
|
|
`{"@type":"update","object":"BlobStore","value":{"@type":"Default"}}`,
|
|
"",
|
|
}, "\n")
|
|
if err := os.WriteFile(path, []byte(raw), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := ReconcileDomainTenantsFile(path)
|
|
if err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if len(res.Adoptions) != 1 || res.Adoptions[0].Domain != "inferred.test" {
|
|
t.Fatalf("adoptions = %+v", res.Adoptions)
|
|
}
|
|
|
|
out, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A large quota must survive as an integer: decoding through float64
|
|
// would rewrite it as 1.073741824e+10 and hand the server a different
|
|
// document than the conversion produced.
|
|
if !strings.Contains(string(out), "10737418240") {
|
|
t.Errorf("quota was reformatted; plan now reads:\n%s", out)
|
|
}
|
|
// The update operations must come back untouched.
|
|
for _, want := range []string{`"defaultHostname":"mail.acme-corp.test"`, `"object":"BlobStore"`} {
|
|
if !strings.Contains(strings.ReplaceAll(string(out), ", ", ","), want) {
|
|
t.Errorf("rewritten plan lost %s:\n%s", want, out)
|
|
}
|
|
}
|
|
|
|
reread, err := ReadPlan(path)
|
|
if err != nil {
|
|
t.Fatalf("reread: %v", err)
|
|
}
|
|
if len(reread) != 5 {
|
|
t.Errorf("operation count = %d, want 5", len(reread))
|
|
}
|
|
if got := domainTenant(t, reread, "create-2"); got != "#create-0" {
|
|
t.Errorf("inferred domain tenant = %q, want %q", got, "#create-0")
|
|
}
|
|
}
|