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.
139 lines
4.3 KiB
Go
139 lines
4.3 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package stalwartapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFlexStringAcceptsEveryShapeAV015InstanceReturns(t *testing.T) {
|
|
cases := map[string]string{
|
|
`"acme"`: "acme",
|
|
`{"string":"acme"}`: "acme",
|
|
`{"name":"acme"}`: "acme",
|
|
`["acme","other"]`: "acme",
|
|
`null`: "",
|
|
`{}`: "",
|
|
`[]`: "",
|
|
`{"other":"ignored"}`: "",
|
|
}
|
|
for raw, want := range cases {
|
|
if got := flexString(json.RawMessage(raw)); got != want {
|
|
t.Errorf("flexString(%s) = %q, want %q", raw, got, want)
|
|
}
|
|
}
|
|
if got := flexString(nil); got != "" {
|
|
t.Errorf("flexString(nil) = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDomainsOfCollectsNameAndAliasDomains(t *testing.T) {
|
|
got := domainsOf("[email protected]", []string{"[email protected]", "[email protected]", "malformed", "trailing@"})
|
|
want := []string{"alias.net", "example.com"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Errorf("domainsOf = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeIsEmptyForSingleTenant(t *testing.T) {
|
|
l := &TenantLayout{DomainTenant: map[string]string{}}
|
|
plan := l.Analyze()
|
|
if len(plan.Adoptions) != 0 || len(plan.Problems) != 0 {
|
|
t.Errorf("single-tenant layout produced %+v", plan)
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeFlagsUndeclaredDomainForAdoption(t *testing.T) {
|
|
// The production failure: a tenant account with an address on a domain
|
|
// that was never declared, so the conversion gives it no tenant.
|
|
l := &TenantLayout{
|
|
Tenants: []string{"acme"},
|
|
DomainTenant: map[string]string{"acme-corp.test": "acme"},
|
|
Principals: []PrincipalTenancy{
|
|
{Name: "bob", Tenant: "acme", Domains: []string{"acme-corp.test", "inferred.test"}},
|
|
},
|
|
}
|
|
plan := l.Analyze()
|
|
if len(plan.Problems) != 0 {
|
|
t.Fatalf("unexpected problems: %+v", plan.Problems)
|
|
}
|
|
if len(plan.Adoptions) != 1 || plan.Adoptions[0] != "inferred.test" {
|
|
t.Errorf("adoptions = %v, want [inferred.test]", plan.Adoptions)
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeIgnoresGlobalAccountsOnTenantDomains(t *testing.T) {
|
|
// Verified against 0.16.14: this direction applies cleanly, so it must
|
|
// not be reported as anything.
|
|
l := &TenantLayout{
|
|
Tenants: []string{"acme"},
|
|
DomainTenant: map[string]string{"acme-corp.test": "acme"},
|
|
Principals: []PrincipalTenancy{
|
|
{Name: "admin", Tenant: "", Domains: []string{"acme-corp.test"}},
|
|
},
|
|
}
|
|
plan := l.Analyze()
|
|
if len(plan.Adoptions) != 0 || len(plan.Problems) != 0 {
|
|
t.Errorf("global account on a tenant domain produced %+v", plan)
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeReportsDomainSharedByTwoTenants(t *testing.T) {
|
|
l := &TenantLayout{
|
|
Tenants: []string{"alpha", "beta"},
|
|
DomainTenant: map[string]string{},
|
|
Principals: []PrincipalTenancy{
|
|
{Name: "u1", Tenant: "alpha", Domains: []string{"shared.test"}},
|
|
{Name: "u2", Tenant: "beta", Domains: []string{"shared.test"}},
|
|
},
|
|
}
|
|
plan := l.Analyze()
|
|
if len(plan.Problems) != 1 {
|
|
t.Fatalf("problems = %+v, want one", plan.Problems)
|
|
}
|
|
if len(plan.Adoptions) != 0 {
|
|
t.Errorf("a conflicting domain was also queued for adoption: %v", plan.Adoptions)
|
|
}
|
|
for _, want := range []string{"alpha", "beta", "u1", "u2"} {
|
|
if !strings.Contains(plan.Problems[0].Detail, want) {
|
|
t.Errorf("detail %q omits %q", plan.Problems[0].Detail, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeReportsAccountOnAnotherTenantsDomain(t *testing.T) {
|
|
l := &TenantLayout{
|
|
Tenants: []string{"alpha", "beta"},
|
|
DomainTenant: map[string]string{"owned.test": "alpha"},
|
|
Principals: []PrincipalTenancy{
|
|
{Name: "u2", Tenant: "beta", Domains: []string{"owned.test"}},
|
|
},
|
|
}
|
|
plan := l.Analyze()
|
|
if len(plan.Problems) != 1 || plan.Problems[0].Domain != "owned.test" {
|
|
t.Fatalf("problems = %+v", plan.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeAcceptsAConsistentMultiTenantLayout(t *testing.T) {
|
|
l := &TenantLayout{
|
|
Tenants: []string{"alpha", "beta"},
|
|
DomainTenant: map[string]string{
|
|
"alpha.test": "alpha",
|
|
"beta.test": "beta",
|
|
},
|
|
Principals: []PrincipalTenancy{
|
|
{Name: "u1", Tenant: "alpha", Domains: []string{"alpha.test"}},
|
|
{Name: "u2", Tenant: "beta", Domains: []string{"beta.test"}},
|
|
{Name: "admin", Tenant: "", Domains: []string{"alpha.test"}},
|
|
},
|
|
}
|
|
plan := l.Analyze()
|
|
if len(plan.Adoptions) != 0 || len(plan.Problems) != 0 {
|
|
t.Errorf("a already-consistent multi-tenant layout produced %+v", plan)
|
|
}
|
|
}
|