Files

409 lines
12 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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()
}