Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup - Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access - Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal) - Login rate limiting (exponential backoff 1s–30s cap) per IP - Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping) - Every account can manage every other account (no roles in v1) - Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check - Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed - Account tab for personal token management; sign-out button in topbar - Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
3.3 KiB
Go
132 lines
3.3 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func newTestUsers(t *testing.T) (*Users, string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "users.json")
|
|
u, err := NewUsers(path)
|
|
if err != nil {
|
|
t.Fatalf("NewUsers: %v", err)
|
|
}
|
|
return u, path
|
|
}
|
|
|
|
func TestUsersCreateAndVerify(t *testing.T) {
|
|
u, _ := newTestUsers(t)
|
|
|
|
usr, err := u.Create("Alice", "correct-password")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if usr.PasswordHash != "" {
|
|
t.Fatal("Create must return a redacted user")
|
|
}
|
|
if usr.ID == "" {
|
|
t.Fatal("an id should have been generated")
|
|
}
|
|
|
|
if _, err := u.Verify("alice", "correct-password"); err != nil {
|
|
t.Fatalf("Verify with matching case-insensitive username: %v", err)
|
|
}
|
|
if _, err := u.Verify("Alice", "wrong-password"); err != ErrInvalidCredentials {
|
|
t.Fatalf("Verify with wrong password = %v, want ErrInvalidCredentials", err)
|
|
}
|
|
if _, err := u.Verify("nobody", "correct-password"); err != ErrInvalidCredentials {
|
|
t.Fatalf("Verify with unknown username = %v, want ErrInvalidCredentials", err)
|
|
}
|
|
}
|
|
|
|
func TestUsersUsernameUniqueCaseInsensitive(t *testing.T) {
|
|
u, _ := newTestUsers(t)
|
|
|
|
if _, err := u.Create("Bob", "password1"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if _, err := u.Create("bob", "password2"); err != ErrUsernameTaken {
|
|
t.Fatalf("Create with case-different duplicate = %v, want ErrUsernameTaken", err)
|
|
}
|
|
}
|
|
|
|
func TestUsersValidation(t *testing.T) {
|
|
u, _ := newTestUsers(t)
|
|
|
|
if _, err := u.Create("", "password"); err == nil {
|
|
t.Fatal("empty username should be rejected")
|
|
}
|
|
if _, err := u.Create("carol", ""); err == nil {
|
|
t.Fatal("empty password should be rejected")
|
|
}
|
|
if _, err := u.Create("dave", strings.Repeat("x", 73)); err != ErrPasswordTooLong {
|
|
t.Fatalf("73-byte password = %v, want ErrPasswordTooLong", err)
|
|
}
|
|
}
|
|
|
|
func TestUsersDeleteRefusesLastAccount(t *testing.T) {
|
|
u, _ := newTestUsers(t)
|
|
|
|
a, err := u.Create("alice", "password1")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
b, err := u.Create("bob", "password2")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
if err := u.Delete(a.ID); err != nil {
|
|
t.Fatalf("Delete first account: %v", err)
|
|
}
|
|
if err := u.Delete(b.ID); err != ErrLastAccount {
|
|
t.Fatalf("Delete last account = %v, want ErrLastAccount", err)
|
|
}
|
|
if u.Count() != 1 {
|
|
t.Fatalf("Count = %d, want 1", u.Count())
|
|
}
|
|
}
|
|
|
|
func TestUsersPersistAcrossReload(t *testing.T) {
|
|
u, path := newTestUsers(t)
|
|
|
|
if _, err := u.Create("alice", "correct-password"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
reopened, err := NewUsers(path)
|
|
if err != nil {
|
|
t.Fatalf("NewUsers: %v", err)
|
|
}
|
|
if reopened.Count() != 1 {
|
|
t.Fatalf("Count after reload = %d, want 1", reopened.Count())
|
|
}
|
|
if _, err := reopened.Verify("alice", "correct-password"); err != nil {
|
|
t.Fatalf("Verify after reload: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUsersTouchLastLogin(t *testing.T) {
|
|
u, _ := newTestUsers(t)
|
|
|
|
usr, err := u.Create("alice", "correct-password")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if usr.LastLoginAt != nil {
|
|
t.Fatal("a fresh account should have no last login")
|
|
}
|
|
if err := u.TouchLastLogin(usr.ID); err != nil {
|
|
t.Fatalf("TouchLastLogin: %v", err)
|
|
}
|
|
got, err := u.Get(usr.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.LastLoginAt == nil {
|
|
t.Fatal("LastLoginAt should be set after TouchLastLogin")
|
|
}
|
|
}
|