Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
409 lines
12 KiB
Go
409 lines
12 KiB
Go
// 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()
|
|
}
|