app: logins folded to lower case, unique without regard to case (audit H4, 0008)

Postfix's reject_sender_login_mismatch compares the SASL login with the
sender map without regard to case - seen on the e2e stand this time (a
one-off probe, not committed: map rewritten to "APP1", client authenticated
as "app1", RCPT accepted). The panel stored logins as typed under a BINARY
UNIQUE, so "Ops" and "ops" were two applications to the panel and one sender
to Postfix: a tenant could send with another tenant's envelope sender.

- app.foldLogin: Service.Create lower-cases the login (owner: fold, do not
  refuse); CreateWithSettings rolls back the stored (folded) login.
- ImportApplication keeps the login as written: folding it would rename the
  SASL account and break the imported application's working credentials;
  the index below refuses a case clash there too.
- Migration 0008 (DDL): CREATE UNIQUE INDEX idx_applications_login_nocase ON
  applications (login COLLATE NOCASE). The column's own BINARY UNIQUE stays
  (SQLite cannot change a collation without a table rebuild, which a
  migration transaction cannot do with foreign keys on). Checked on the live
  installation first: 4 logins, no case clash; "Quieter3C" keeps its case.
  0008 is not run anywhere yet; send_log.domain_id joins it later in stage 2.

Tests: "Ops" created as ops, SASL account and map binding in lower case,
OPS in another domain and an imported oPs refused; the store refuses ops
beside Ops; schema at user_version 8 with the index.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 15/61.
Evidence on the test server for this tree (with the probe): go vet clean,
go test ./... 23 packages ok, make e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 14:55:24 +03:00
co-authored by Claude Opus 5.5
parent a59e3d4d61
commit f51f5db245
9 changed files with 88 additions and 8 deletions
+34
View File
@@ -113,6 +113,40 @@ func TestServiceCreateListValidatesDomain(t *testing.T) {
}
}
// A new login is stored in lower case, and the sender map names it so: to
// Postfix "Ops" and "ops" are one login (audit 2026-10-12, H4). A second
// application that differs only in case is refused, created or imported,
// whichever domain it is for.
func TestServiceCreateFoldsTheLogin(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
a1 := addDomain(t, st, "a.example")
b := addDomain(t, st, "b.example")
a, _, err := svc.Create(a1.ID, "Ops", store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
if a.Login != "ops" {
t.Fatalf("login stored as %q, want ops", a.Login)
}
if _, ok := rec.set["ops"]; !ok {
t.Fatalf("SASL account written as %v, want ops", rec.set)
}
if len(maps.last) != 1 || maps.last[0].Login != "ops" {
t.Fatalf("map bindings = %+v, want the login in lower case", maps.last)
}
if _, _, err := svc.Create(b.ID, "OPS", store.AddressModeWildcard, nil); !errors.Is(err, store.ErrLoginExists) {
t.Fatalf("OPS in another domain = %v, want ErrLoginExists", err)
}
if err := svc.ImportApplication(b.ID, "oPs", store.AddressModeWildcard, nil, "imported-password"); !errors.Is(err, store.ErrLoginExists) {
t.Fatalf("an imported oPs = %v, want ErrLoginExists", err)
}
if _, ok := rec.set["OPS"]; ok {
t.Fatal("the refused OPS got a SASL account")
}
}
func TestServiceCreateDuplicateLogin(t *testing.T) {
svc, st, _, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")