// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package users import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" ) func TestAddFirst(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) user, err := u.AddFirst("admin", "a-very-good-passphrase", "cs", "plasma", "Petr Balvín") if err != nil { t.Fatalf("AddFirst: %v", err) } if user.Role != "admin" || user.Language != "cs" || user.Theme != "plasma" || user.Name != "Petr Balvín" { t.Fatalf("first account = %+v", user) } if _, err := u.AddFirst("other", "another-good-passphrase", "", "", ""); !errors.Is(err, ErrUsersExist) { t.Fatalf("second first account: err = %v", err) } if len(u.All()) != 1 { t.Fatalf("accounts = %v", u.All()) } } // The wizard claim is serialised: two concurrent AddFirst calls create // exactly one account, never two admins racing for the installation. func TestAddFirstIsSerialised(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) var wg sync.WaitGroup var mu sync.Mutex ok := 0 for range 4 { wg.Go(func() { if _, err := u.AddFirst("claim", "a-very-good-passphrase", "", "", ""); err == nil { mu.Lock() ok++ mu.Unlock() } }) } wg.Wait() if ok != 1 { t.Fatalf("%d claims won", ok) } if len(u.All()) != 1 { t.Fatalf("accounts = %v", u.All()) } } func stat(path string) (os.FileInfo, error) { return os.Stat(path) } // The helpers below keep the tests readable now that every mutation // reports why it failed. func addSucceeded(u *Users, username, secret, role string) bool { _, err := u.Add(username, secret, role) return err == nil } func addFailed(u *Users, username, secret, role string) bool { return !addSucceeded(u, username, secret, role) } func mustAdd(t *testing.T, u *Users, username, secret, role string) { t.Helper() if _, err := u.Add(username, secret, role); err != nil { t.Fatalf("Add(%q): %v", username, err) } } func renameSucceeded(u *Users, from, to string) bool { _, err := u.Rename(from, to) return err == nil } func renameFailed(u *Users, from, to string) bool { return !renameSucceeded(u, from, to) } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write: %v", err) } } func TestNoAccountsBeforeFirstAdd(t *testing.T) { u := New(filepath.Join(t.TempDir(), "users.toml")) if u.Any() { t.Fatal("a missing file must hold no accounts: the first-run wizard is the only creator") } } func TestAddAndPersist(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) added, err := u.Add("petr", "heslo12345", "admin") if err != nil { t.Fatalf("Add: %v", err) } if added == nil { t.Fatal("Add failed") } if added.Role != "admin" { t.Fatalf("role = %q", added.Role) } // Invalid role falls back to the default. if second, err := u.Add("joe", "heslo12345", "root"); err != nil || second.Role != DefaultRole { t.Fatalf("second = %v", second) } // Duplicate and empty names are rejected. if addSucceeded(u, "petr", "x", "admin") { t.Fatal("duplicate accepted") } if addSucceeded(u, "", "x", "admin") { t.Fatal("empty name accepted") } reopened := New(path) if len(reopened.All()) != 2 { t.Fatalf("reopened users = %v", reopened.All()) } if reopened.Authenticate("petr", "heslo12345") == nil { t.Fatal("authenticate failed after reopen") } info, err := stat(path) if err != nil { t.Fatalf("stat: %v", err) } if info.Mode().Perm() != 0o600 { t.Fatalf("mode = %v, want 0600", info.Mode().Perm()) } } func TestUpdates(t *testing.T) { u := New(filepath.Join(t.TempDir(), "users.toml")) if addFailed(u, "petr", "heslo12345", "admin") { t.Fatal("Add failed") } if got, err := u.UpdateName("petr", "Petr Balvín"); err != nil || got.Name != "Petr Balvín" { t.Fatalf("UpdateName = %v", got) } if got, err := u.UpdateName("petr", ""); err != nil || got.Name != "" { t.Fatalf("clear name = %v", got) } if got, err := u.UpdateFediverseCreator("petr", "@petr@social"); err != nil || got.FediverseCreator != "@petr@social" { t.Fatalf("UpdateFediverseCreator = %v", got) } if got, err := u.UpdatePhoto("petr", "/media/p.webp"); err != nil || got.Photo != "/media/p.webp" { t.Fatalf("UpdatePhoto = %v", got) } if _, err := u.UpdatePassword("petr", "noveheslo123"); err != nil { t.Fatal("UpdatePassword failed") } if u.Authenticate("petr", "noveheslo123") == nil { t.Fatal("new password does not verify") } if _, err := u.UpdateName("missing", "x"); err == nil { t.Fatal("update of missing user succeeded") } } func TestUpdateLanguage(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) mustAdd(t, u, "petr", "heslo12345", "admin") if got, err := u.UpdateLanguage("petr", "cs"); err != nil || got.Language != "cs" { t.Fatalf("UpdateLanguage = %v, %v", got, err) } // The choice survives the round trip through the users file, and an // unknown language is refused rather than stored. if u.Find("petr").Language != "cs" { t.Fatal("language did not persist") } if _, err := u.UpdateLanguage("petr", "de"); err == nil { t.Fatal("unsupported language accepted") } } func TestUpdateTheme(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) mustAdd(t, u, "petr", "heslo12345", "admin") if got, err := u.UpdateTheme("petr", "plasma"); err != nil || got.Theme != "plasma" { t.Fatalf("UpdateTheme = %v, %v", got, err) } // The choice survives the round trip through the users file, and an // unknown scheme is refused rather than stored. if u.Find("petr").Theme != "plasma" { t.Fatal("theme did not persist") } if _, err := u.UpdateTheme("petr", "sepia"); err == nil { t.Fatal("unsupported colour scheme accepted") } } func TestUpdateOrcid(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) mustAdd(t, u, "petr", "heslo12345", "admin") // A valid iD persists, normalised to its upper-case form. if got, err := u.UpdateOrcid("petr", "0000-0002-1825-0097"); err != nil || got.Orcid != "0000-0002-1825-0097" { t.Fatalf("UpdateOrcid = %v, %v", got, err) } if u.Find("petr").Orcid != "0000-0002-1825-0097" { t.Fatal("orcid did not persist") } // A broken check digit is refused and the old value stays. if _, err := u.UpdateOrcid("petr", "0000-0002-1825-0098"); err == nil { t.Fatal("invalid orcid accepted") } if u.Find("petr").Orcid != "0000-0002-1825-0097" { t.Fatal("refused orcid overwrote the stored one") } // Empty clears it. if got, err := u.UpdateOrcid("petr", ""); err != nil || got.Orcid != "" { t.Fatalf("UpdateOrcid clear = %v, %v", got, err) } } func TestRename(t *testing.T) { u := New(filepath.Join(t.TempDir(), "users.toml")) mustAdd(t, u, "petr", "heslo12345", "admin") mustAdd(t, u, "joe", "heslo12345", "author") if got, err := u.Rename("petr", "balvin"); err != nil || got.Username != "balvin" { t.Fatalf("Rename = %v", got) } if u.Find("petr") != nil { t.Fatal("old name still present") } if renameSucceeded(u, "balvin", "joe") { t.Fatal("rename onto existing user succeeded") } if renameSucceeded(u, "balvin", "") { t.Fatal("rename to empty succeeded") } } func TestLastAdminProtection(t *testing.T) { u := New(filepath.Join(t.TempDir(), "users.toml")) mustAdd(t, u, "petr", "heslo12345", "admin") mustAdd(t, u, "joe", "heslo12345", "author") if got, err := u.SetRole("petr", "author"); err == nil && got != nil { t.Fatal("demoting the last admin succeeded") } if got, err := u.SetRole("petr", "wizard"); err == nil && got != nil { t.Fatal("invalid role accepted") } if got, err := u.Delete("petr"); err == nil || got != "" { t.Fatal("deleting the last admin succeeded") } if got, err := u.Delete("joe"); err != nil || got != "joe" { t.Fatalf("Delete = %q", got) } if got, err := u.Delete("joe"); err == nil || got != "" { t.Fatal("deleting the last user succeeded") } // With two admins, demotion and deletion are allowed. mustAdd(t, u, "second", "heslo12345", "admin") if got, err := u.SetRole("petr", "author"); err != nil || got == nil { t.Fatal("demoting with two admins failed") } if got, err := u.SetRole("petr", "admin"); err != nil || got == nil { t.Fatal("re-promoting failed") } if got, err := u.Delete("second"); err != nil || got != "second" { t.Fatalf("Delete = %q", got) } } func TestCacheInvalidationOnOutOfBandEdit(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) mustAdd(t, u, "petr", "heslo12345", "admin") if len(u.All()) != 1 { t.Fatal("want 1 user") } other := New(path) other.Add("joe", "heslo12345", "author") if got := len(u.All()); got != 2 { t.Fatalf("cache not invalidated: %d users", got) } } func TestUnreadableFileYieldsNoUsers(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") writeFile(t, path, "not = valid = toml") u := New(path) if len(u.All()) != 0 { t.Fatalf("users = %v, want none", u.All()) } } func TestWeakStoredHashRejected(t *testing.T) { // N=1024 hash: below the policy floor, must not authenticate. path := filepath.Join(t.TempDir(), "users.toml") weak := `[[users]] username = "old" password_hash = "scrypt$1024$8$1$c2FsdHNhbHRzYWx0c2E=$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA==" role = "admin" ` writeFile(t, path, weak) u := New(path) if len(u.All()) != 1 { t.Fatalf("users = %v", u.All()) } if u.Authenticate("old", "anything") != nil { t.Fatal("weak hash authenticated") } if !strings.Contains(u.All()[0].PasswordHash, "scrypt$1024") { t.Fatal("hash not loaded") } } // A users file that exists but cannot be parsed fails closed: no // account is served, a mutation refuses rather than write the empty // list back, and the unreadable file is left untouched. The first-run // wizard gate reads the health separately, so a corrupted file never // turns into an open setup page. func TestUnreadableFileFailsClosed(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) if u.Any() { t.Fatal("no account is expected while the file is absent") } if err := os.WriteFile(path, []byte("this is not TOML [[["), 0o600); err != nil { t.Fatalf("write: %v", err) } u.Invalidate() if got := u.All(); len(got) != 0 { t.Fatalf("a corrupted file yielded %d account(s)", len(got)) } if err := u.Health(); err == nil { t.Fatal("Health reported no problem with a corrupted file") } // A mutation must refuse rather than write the empty list back. if addSucceeded(u, "joe", "joes-good-passphrase", "author") { t.Fatal("Add wrote over an unreadable file") } raw, err := os.ReadFile(path) if err != nil || string(raw) != "this is not TOML [[[" { t.Fatalf("the unreadable file was overwritten: %q", raw) } } func TestRenameIsSerialised(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) if addFailed(u, "first", "first-good-passphrase", "admin") { t.Fatal("Add failed") } if addFailed(u, "second", "second-good-passphrase", "author") { t.Fatal("Add failed") } var wg sync.WaitGroup for range 8 { wg.Go(func() { u.Rename("first", "merged") u.Rename("second", "merged") }) } wg.Wait() merged := 0 for _, user := range u.All() { if user.Username == "merged" { merged++ } } if merged > 1 { t.Fatalf("%d accounts share one username", merged) } } // TestWritersDoNotRaceReaders exercises a mutation against concurrent // readers: loadLocked writes the cache fields readers read under u.mu, // so a writer that called it without u.mu would be a data race. func TestWritersDoNotRaceReaders(t *testing.T) { path := filepath.Join(t.TempDir(), "users.toml") u := New(path) if addFailed(u, "admin", "admin-good-passphrase", "admin") { t.Fatal("Add failed") } var wg sync.WaitGroup for i := range 4 { wg.Go(func() { u.UpdateName("admin", fmt.Sprintf("name-%d", i)) }) } for range 4 { wg.Go(func() { u.All() u.Health() u.Find("admin") }) } wg.Wait() }