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>
146 lines
3.5 KiB
Go
146 lines
3.5 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func newTestTokens(t *testing.T) (*Tokens, string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "tokens.json")
|
|
tk, err := NewTokens(path)
|
|
if err != nil {
|
|
t.Fatalf("NewTokens: %v", err)
|
|
}
|
|
return tk, path
|
|
}
|
|
|
|
func TestTokensCreateAndAuthenticate(t *testing.T) {
|
|
tk, _ := newTestTokens(t)
|
|
|
|
tok, plain, err := tk.Create("user-1", "ci token")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if tok.Hash != "" {
|
|
t.Fatal("Create must return a redacted token")
|
|
}
|
|
if !strings.HasPrefix(plain, "dmv_") {
|
|
t.Fatalf("plaintext = %q, want dmv_ prefix", plain)
|
|
}
|
|
if tok.Hint != plain[len(plain)-4:] {
|
|
t.Fatalf("hint = %q, want last 4 chars of %q", tok.Hint, plain)
|
|
}
|
|
|
|
got, err := tk.Authenticate(plain)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
if got.UserID != "user-1" {
|
|
t.Fatalf("UserID = %q, want user-1", got.UserID)
|
|
}
|
|
if got.Hash != "" {
|
|
t.Fatal("Authenticate must return a redacted token")
|
|
}
|
|
|
|
if _, err := tk.Authenticate("dmv_deadbeef"); err != ErrTokenNotFound {
|
|
t.Fatalf("Authenticate with bogus token = %v, want ErrTokenNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestTokensAuthenticateRecordsLastUsed(t *testing.T) {
|
|
tk, _ := newTestTokens(t)
|
|
|
|
tok, plain, err := tk.Create("user-1", "ci token")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if tok.LastUsedAt != nil {
|
|
t.Fatal("a fresh token should have no last-used time")
|
|
}
|
|
got, err := tk.Authenticate(plain)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
if got.LastUsedAt == nil {
|
|
t.Fatal("Authenticate should record LastUsedAt")
|
|
}
|
|
}
|
|
|
|
func TestTokensListScopedToOwner(t *testing.T) {
|
|
tk, _ := newTestTokens(t)
|
|
|
|
if _, _, err := tk.Create("user-1", "a"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if _, _, err := tk.Create("user-2", "b"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
list := tk.List("user-1")
|
|
if len(list) != 1 || list[0].Name != "a" {
|
|
t.Fatalf("List(user-1) = %+v, want just token a", list)
|
|
}
|
|
}
|
|
|
|
func TestTokensRevokeOwnershipChecked(t *testing.T) {
|
|
tk, _ := newTestTokens(t)
|
|
|
|
tok, plain, err := tk.Create("user-1", "a")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
if err := tk.Revoke("user-2", tok.ID); err != ErrTokenNotFound {
|
|
t.Fatalf("Revoke by non-owner = %v, want ErrTokenNotFound", err)
|
|
}
|
|
if err := tk.Revoke("user-1", tok.ID); err != nil {
|
|
t.Fatalf("Revoke by owner: %v", err)
|
|
}
|
|
if _, err := tk.Authenticate(plain); err != ErrTokenNotFound {
|
|
t.Fatalf("Authenticate after revoke = %v, want ErrTokenNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestTokensRevokeAllForUser(t *testing.T) {
|
|
tk, _ := newTestTokens(t)
|
|
|
|
if _, _, err := tk.Create("user-1", "a"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if _, _, err := tk.Create("user-1", "b"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if _, _, err := tk.Create("user-2", "c"); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
if err := tk.RevokeAllForUser("user-1"); err != nil {
|
|
t.Fatalf("RevokeAllForUser: %v", err)
|
|
}
|
|
if len(tk.List("user-1")) != 0 {
|
|
t.Fatal("user-1 should have no tokens left")
|
|
}
|
|
if len(tk.List("user-2")) != 1 {
|
|
t.Fatal("user-2's token should be untouched")
|
|
}
|
|
}
|
|
|
|
func TestTokensPersistAcrossReload(t *testing.T) {
|
|
tk, path := newTestTokens(t)
|
|
|
|
_, plain, err := tk.Create("user-1", "a")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
reopened, err := NewTokens(path)
|
|
if err != nil {
|
|
t.Fatalf("NewTokens: %v", err)
|
|
}
|
|
if _, err := reopened.Authenticate(plain); err != nil {
|
|
t.Fatalf("Authenticate after reload: %v", err)
|
|
}
|
|
}
|