Migration 0004_notifications.sql, DDL only: users.notify, a user's own
switch, on by default; notify_state, one row per subject the panel has
written about (dns:<domain>, cert, queue, limit:<domain>:<login>), so that
each thing is said once and a restart says nothing again.
Store: the instance switch as a settings key, on unless turned off;
SetUserNotify; NotifyRecipients - global users for everything, domain users
for the domains on their list or all of them, only with an e-mail and their
switch on, a shared mailbox once; the state's get, set, delete and list by
prefix; LimitRefusalsSince, the mail refused under a level-2 limit per domain
and application.
Nothing is sent yet. Checked on Windows: go vet clean, go test
./internal/store passes with the schema test at user_version 4. The page
guards fail from the design commit 87fa0a4 until the pages are built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
14 KiB
Go
384 lines
14 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 2.0 starts from one baseline: the 1.x chain is gone from the binary, not
|
|
// carried along (docs/schema-migrations.md).
|
|
func TestBaselineIsTheOnlyMigration(t *testing.T) {
|
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
|
if err != nil {
|
|
t.Fatalf("read migrations: %v", err)
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
names = append(names, e.Name())
|
|
}
|
|
if len(names) == 0 || names[0] != "0001_init.sql" {
|
|
t.Fatalf("migrations = %v, want the chain to start at 0001_init.sql", names)
|
|
}
|
|
baseline, _ := migrationsFS.ReadFile("migrations/0001_init.sql")
|
|
for _, gone := range []string{"CREATE TABLE admin", "DROP TABLE", "ALTER TABLE", "dmarc_report_email", "allowed_ips ", "domain_admin"} {
|
|
if strings.Contains(string(baseline), gone) {
|
|
t.Errorf("the baseline still contains %q — a leftover of the 1.x chain", gone)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The schema a fresh data directory gets, table by table and column by
|
|
// column. A change here is a schema change: it ships as a new migration file
|
|
// and a row in docs/schema-migrations.md, never as an edit of the baseline
|
|
// after 2.0.0.
|
|
func TestBaselineSchema(t *testing.T) {
|
|
st := openTestStore(t)
|
|
want := map[string]string{
|
|
"settings": "key value",
|
|
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify",
|
|
"sessions": "token_hash user_id created_at expires_at",
|
|
"domains": "id name dkim_selector dmarc_rua created_at",
|
|
"user_domains": "user_id domain_id",
|
|
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
|
|
"application_addresses": "id application_id address",
|
|
"rate_limits": "id scope ref_id max_messages window_seconds mode auto_multiplier auto_updated_at",
|
|
"send_log": "id queue_id domain app_login from_addr to_addr subject status created_at updated_at",
|
|
"logtail_state": "path fingerprint read_offset updated_at",
|
|
"inbound_domains": "id name recipient_mode created_at",
|
|
"inbound_transports": "inbound_domain_id host port tls_mode",
|
|
"inbound_recipients": "id inbound_domain_id address",
|
|
"user_inbound_domains": "user_id inbound_domain_id",
|
|
"dmarc_reports": "id domain reporter report_id period_begin period_end received_at contact_email policy_p policy_sp policy_pct policy_adkim policy_aspf pass_count fail_count recipient",
|
|
"dmarc_report_records": "id report_row_id source_ip count disposition spf_result dkim_result header_from",
|
|
"inbound_spam_log": "id inbound_domain client_ip from_addr to_addr subject decision engine detail created_at",
|
|
"inbound_spam_list": "id list_type entry_type value note created_at",
|
|
"send_log_lines": "id queue_id logged_at line",
|
|
"notify_state": "key phase count mark since sent_at",
|
|
}
|
|
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
if err != nil {
|
|
t.Fatalf("list tables: %v", err)
|
|
}
|
|
var tables []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tables = append(tables, name)
|
|
}
|
|
rows.Close()
|
|
for _, name := range tables {
|
|
cols, ok := want[name]
|
|
if !ok {
|
|
t.Errorf("unexpected table %q", name)
|
|
continue
|
|
}
|
|
if got := strings.Join(tableColumns(t, st, name), " "); got != cols {
|
|
t.Errorf("table %s has columns\n %s\nwant\n %s", name, got, cols)
|
|
}
|
|
delete(want, name)
|
|
}
|
|
for name := range want {
|
|
t.Errorf("table %q is missing", name)
|
|
}
|
|
|
|
var indexes []string
|
|
rows, err = st.db.Query("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
if err != nil {
|
|
t.Fatalf("list indexes: %v", err)
|
|
}
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
indexes = append(indexes, name)
|
|
}
|
|
rows.Close()
|
|
wantIdx := []string{
|
|
"idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
|
|
"idx_inbound_spam_log_created_at", "idx_inbound_spam_log_domain_created_at",
|
|
"idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_lines_queue_id", "idx_send_log_queue_id",
|
|
"idx_sessions_expires_at",
|
|
}
|
|
sort.Strings(wantIdx)
|
|
if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") {
|
|
t.Errorf("indexes = %v\nwant %v", indexes, wantIdx)
|
|
}
|
|
|
|
var version, appID int64
|
|
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 4 {
|
|
t.Errorf("user_version = %d, %v; want 4", version, err)
|
|
}
|
|
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
|
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
|
}
|
|
}
|
|
|
|
func tableColumns(t *testing.T, st *Store, table string) []string {
|
|
t.Helper()
|
|
rows, err := st.db.Query("SELECT name FROM pragma_table_info(?) ORDER BY cid", table)
|
|
if err != nil {
|
|
t.Fatalf("columns of %s: %v", table, err)
|
|
}
|
|
defer rows.Close()
|
|
var cols []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cols = append(cols, name)
|
|
}
|
|
return cols
|
|
}
|
|
|
|
// Reopening a 2.x database applies nothing twice and keeps its data.
|
|
func TestReopenKeepsTheDatabase(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
st, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
|
t.Fatalf("CreateGlobalUser: %v", err)
|
|
}
|
|
st.Close()
|
|
|
|
st, err = Open(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
defer st.Close()
|
|
if ok, err := st.UserExists(); err != nil || !ok {
|
|
t.Fatalf("after reopen: user exists = %v, %v", ok, err)
|
|
}
|
|
}
|
|
|
|
// A 2.0.x data directory - the baseline and nothing after it - is brought to
|
|
// the head by opening it, with what it holds left alone.
|
|
func TestOpenMigratesA20Database(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
baseline, err := migrationsFS.ReadFile("migrations/0001_init.sql")
|
|
if err != nil {
|
|
t.Fatalf("read the baseline: %v", err)
|
|
}
|
|
db, err := sql.Open("sqlite", "file:"+path)
|
|
if err != nil {
|
|
t.Fatalf("create a 2.0 db: %v", err)
|
|
}
|
|
for _, stmt := range []string{
|
|
string(baseline),
|
|
"PRAGMA user_version = 1",
|
|
"INSERT INTO users (username, password_hash, role, created_at) VALUES ('admin', 'hash', 'global', '2026-10-11T00:00:00Z')",
|
|
} {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
t.Fatalf("prepare a 2.0 db: %v", err)
|
|
}
|
|
}
|
|
db.Close()
|
|
|
|
st, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open a 2.0 database: %v", err)
|
|
}
|
|
defer st.Close()
|
|
var version int
|
|
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 4 {
|
|
t.Errorf("user_version = %d, %v; want 4", version, err)
|
|
}
|
|
if ok, err := st.UserExists(); err != nil || !ok {
|
|
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
|
|
}
|
|
for _, table := range []string{"inbound_spam_log", "inbound_spam_list", "send_log_lines", "notify_state"} {
|
|
if len(tableColumns(t, st, table)) == 0 {
|
|
t.Errorf("table %s was not created", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
// What the antispam tables refuse: a decision, engine or list the panel does
|
|
// not know, and the same entry twice in one list.
|
|
func TestInboundSpamConstraints(t *testing.T) {
|
|
st := openTestStore(t)
|
|
logRow := func(decision, engine string) error {
|
|
_, err := st.db.Exec(
|
|
"INSERT INTO inbound_spam_log (inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at)"+
|
|
" VALUES ('in.example.com', '192.0.2.1', 'a@example.net', 'b@in.example.com', '', ?, ?, '', '2026-10-11T00:00:00Z')",
|
|
decision, engine)
|
|
return err
|
|
}
|
|
if err := logRow("reject", "postfix"); err != nil {
|
|
t.Fatalf("a reject by postfix: %v", err)
|
|
}
|
|
if err := logRow("bounce", "postfix"); err == nil {
|
|
t.Error("decision bounce was accepted")
|
|
}
|
|
if err := logRow("accept", "spamassassin"); err == nil {
|
|
t.Error("engine spamassassin was accepted")
|
|
}
|
|
|
|
entry := func(list, kind, value string) error {
|
|
_, err := st.db.Exec(
|
|
"INSERT INTO inbound_spam_list (list_type, entry_type, value, created_at) VALUES (?, ?, ?, '2026-10-11T00:00:00Z')",
|
|
list, kind, value)
|
|
return err
|
|
}
|
|
if err := entry("allow", "address", "billing@vendor.example"); err != nil {
|
|
t.Fatalf("an allow entry: %v", err)
|
|
}
|
|
if err := entry("allow", "address", "billing@vendor.example"); err == nil {
|
|
t.Error("the same entry was added to one list twice")
|
|
}
|
|
if err := entry("deny", "address", "billing@vendor.example"); err != nil {
|
|
t.Errorf("the same value on the other list: %v", err)
|
|
}
|
|
if err := entry("block", "address", "x@example.net"); err == nil {
|
|
t.Error("list type block was accepted")
|
|
}
|
|
if err := entry("deny", "subject", "pills"); err == nil {
|
|
t.Error("entry type subject was accepted")
|
|
}
|
|
var note string
|
|
if err := st.db.QueryRow("SELECT note FROM inbound_spam_list WHERE list_type = 'allow'").Scan(¬e); err != nil || note != "" {
|
|
t.Errorf("note of an entry added without one = %q, %v", note, err)
|
|
}
|
|
}
|
|
|
|
// A 1.x data directory must be refused, loudly. Its user_version (10 at the
|
|
// last 1.x release) would otherwise read as "the baseline is already applied",
|
|
// and the panel would start on tables it does not know.
|
|
func TestOpenRefusesA1xDatabase(t *testing.T) {
|
|
for _, version := range []string{"1", "10"} {
|
|
path := filepath.Join(t.TempDir(), "old.db")
|
|
db, err := sql.Open("sqlite", "file:"+path)
|
|
if err != nil {
|
|
t.Fatalf("create old db: %v", err)
|
|
}
|
|
for _, stmt := range []string{
|
|
"CREATE TABLE admin (id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)",
|
|
"INSERT INTO admin VALUES (1, 'admin', 'hash', '2026-01-01T00:00:00Z')",
|
|
"PRAGMA user_version = " + version,
|
|
} {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
t.Fatalf("%s: %v", stmt, err)
|
|
}
|
|
}
|
|
db.Close()
|
|
|
|
st, err := Open(path)
|
|
if err == nil {
|
|
st.Close()
|
|
t.Fatalf("a 1.x database at version %s was opened", version)
|
|
}
|
|
if !errors.Is(err, ErrForeignSchema) {
|
|
t.Fatalf("version %s: error = %v, want ErrForeignSchema", version, err)
|
|
}
|
|
|
|
// Refusing must not have touched the file: the operator still has
|
|
// their 1.x data to go back to.
|
|
db, _ = sql.Open("sqlite", "file:"+path)
|
|
var n int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil || n != 1 {
|
|
t.Errorf("version %s: the refused database was altered (admin rows = %d, %v)", version, n, err)
|
|
}
|
|
var tables int
|
|
_ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").Scan(&tables)
|
|
if tables != 1 {
|
|
t.Errorf("version %s: the refused database now has %d tables", version, tables)
|
|
}
|
|
db.Close()
|
|
}
|
|
}
|
|
|
|
// A database written by a newer build is not guessed at either.
|
|
func TestOpenRefusesANewerSchema(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
st, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
if _, err := st.db.Exec("PRAGMA user_version = 99"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st.Close()
|
|
if st, err := Open(path); err == nil {
|
|
st.Close()
|
|
t.Fatal("a database at schema version 99 was opened")
|
|
}
|
|
}
|
|
|
|
// The checks the baseline puts on a user row, and what the new assignment
|
|
// table does when either side goes away.
|
|
func TestBaselineConstraints(t *testing.T) {
|
|
st := openTestStore(t)
|
|
insert := func(name, role string, all int) error {
|
|
_, err := st.db.Exec(
|
|
"INSERT INTO users (username, password_hash, role, all_domains, created_at) VALUES (?, 'h', ?, ?, 'now')",
|
|
name, role, all)
|
|
return err
|
|
}
|
|
if err := insert("plain", "global", 0); err != nil {
|
|
t.Fatalf("a plain global user: %v", err)
|
|
}
|
|
if err := insert("everything", "domain", 1); err != nil {
|
|
t.Fatalf("a domain user with All: %v", err)
|
|
}
|
|
if err := insert("old-role", "domain_admin", 0); err == nil {
|
|
t.Error("the 1.x role name domain_admin was accepted")
|
|
}
|
|
if err := insert("two", "domain", 2); err == nil {
|
|
t.Error("all_domains = 2 was accepted")
|
|
}
|
|
|
|
u, err := st.GetUserByUsername("everything")
|
|
if err != nil {
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
}
|
|
if !u.AllDomains || u.AllInboundDomains {
|
|
t.Errorf("flags read back as %+v", u)
|
|
}
|
|
|
|
in, err := st.AddInboundDomain("in.example.com")
|
|
if err != nil {
|
|
t.Fatalf("AddInboundDomain: %v", err)
|
|
}
|
|
other, err := st.AddInboundDomain("other.example.com")
|
|
if err != nil {
|
|
t.Fatalf("AddInboundDomain: %v", err)
|
|
}
|
|
for _, id := range []int64{in.ID, other.ID} {
|
|
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, id); err != nil {
|
|
t.Fatalf("assign inbound domain: %v", err)
|
|
}
|
|
}
|
|
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, in.ID+100); err == nil {
|
|
t.Error("an assignment to a missing inbound domain was accepted")
|
|
}
|
|
u, _ = st.GetUser(u.ID)
|
|
if len(u.InboundDomainIDs) != 2 || len(u.DomainIDs) != 0 {
|
|
t.Fatalf("assignments = inbound %v, outbound %v; the two lists are separate", u.InboundDomainIDs, u.DomainIDs)
|
|
}
|
|
|
|
if err := st.DeleteInboundDomain(in.ID); err != nil {
|
|
t.Fatalf("DeleteInboundDomain: %v", err)
|
|
}
|
|
u, _ = st.GetUser(u.ID)
|
|
if len(u.InboundDomainIDs) != 1 || u.InboundDomainIDs[0] != other.ID {
|
|
t.Errorf("after deleting an inbound domain its assignment stayed: %v", u.InboundDomainIDs)
|
|
}
|
|
if err := st.DeleteUser(u.ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
var n int
|
|
if err := st.db.QueryRow("SELECT COUNT(*) FROM user_inbound_domains").Scan(&n); err != nil || n != 0 {
|
|
t.Errorf("after deleting the user %d inbound assignment(s) stayed (%v)", n, err)
|
|
}
|
|
}
|