325 lines
9.1 KiB
Go
325 lines
9.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
store, err := NewStore(root)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func TestNewStoreCreatesDirectory(t *testing.T) {
|
|
root := t.TempDir()
|
|
store, err := NewStore(root)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
want := filepath.Join(root, ".zddc.d", DirName)
|
|
if store.Dir() != want {
|
|
t.Errorf("Dir() = %q, want %q", store.Dir(), want)
|
|
}
|
|
info, err := os.Stat(store.Dir())
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Error("expected directory")
|
|
}
|
|
// On Linux, expect mode 0700; some FS (NFS, SMB) won't honor chmod
|
|
// so we accept any mode where no group/other bits are set in the
|
|
// happy-path tempdir.
|
|
mode := info.Mode().Perm()
|
|
if mode&0o077 != 0 {
|
|
t.Errorf("expected mode 0700, got %o", mode)
|
|
}
|
|
}
|
|
|
|
func TestGenerateAndValidate(t *testing.T) {
|
|
store := newTestStore(t)
|
|
|
|
plaintext, tok, err := store.Generate("alice@example.com", "test laptop", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if plaintext == "" {
|
|
t.Fatal("plaintext token empty")
|
|
}
|
|
if len(plaintext) != 43 {
|
|
t.Errorf("plaintext length = %d, want 43", len(plaintext))
|
|
}
|
|
if tok.Email != "alice@example.com" {
|
|
t.Errorf("Email = %q", tok.Email)
|
|
}
|
|
if tok.Description != "test laptop" {
|
|
t.Errorf("Description = %q", tok.Description)
|
|
}
|
|
if !tok.Expires.IsZero() {
|
|
t.Errorf("Expires = %v, want zero", tok.Expires)
|
|
}
|
|
if len(tok.Hash) != HashLen {
|
|
t.Errorf("Hash length = %d, want %d", len(tok.Hash), HashLen)
|
|
}
|
|
if tok.ID() != tok.Hash[:IDLen] {
|
|
t.Errorf("ID() = %q, want %q", tok.ID(), tok.Hash[:IDLen])
|
|
}
|
|
|
|
// Validate the same plaintext.
|
|
got, err := store.Validate(plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if got.Email != "alice@example.com" {
|
|
t.Errorf("validated Email = %q", got.Email)
|
|
}
|
|
if got.Hash != tok.Hash {
|
|
t.Errorf("validated Hash = %q, want %q", got.Hash, tok.Hash)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsUnknownToken(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Validate("not-a-real-token"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsEmptyToken(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Validate(""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsExpiredToken(t *testing.T) {
|
|
store := newTestStore(t)
|
|
plaintext, _, err := store.Generate("bob@example.com", "expired", time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken for expired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAcceptsFutureExpiry(t *testing.T) {
|
|
store := newTestStore(t)
|
|
plaintext, _, err := store.Generate("bob@example.com", "valid", time.Now().Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if _, err := store.Validate(plaintext); err != nil {
|
|
t.Errorf("Validate of future-expiry token: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateProducesUniqueTokens(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seen := make(map[string]bool)
|
|
for i := 0; i < 50; i++ {
|
|
plaintext, _, err := store.Generate("alice@example.com", "", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate %d: %v", i, err)
|
|
}
|
|
if seen[plaintext] {
|
|
t.Fatalf("duplicate token at iteration %d", i)
|
|
}
|
|
seen[plaintext] = true
|
|
}
|
|
}
|
|
|
|
func TestList(t *testing.T) {
|
|
store := newTestStore(t)
|
|
|
|
if list, err := store.List("alice@example.com"); err != nil || len(list) != 0 {
|
|
t.Errorf("empty List: %v / %d", err, len(list))
|
|
}
|
|
|
|
_, _, _ = store.Generate("alice@example.com", "first", time.Time{})
|
|
time.Sleep(1100 * time.Millisecond)
|
|
_, _, _ = store.Generate("alice@example.com", "second", time.Time{})
|
|
_, _, _ = store.Generate("bob@example.com", "bob's", time.Time{})
|
|
|
|
alice, err := store.List("alice@example.com")
|
|
if err != nil {
|
|
t.Fatalf("List alice: %v", err)
|
|
}
|
|
if len(alice) != 2 {
|
|
t.Fatalf("List alice = %d, want 2", len(alice))
|
|
}
|
|
// Newest-first ordering.
|
|
if alice[0].Description != "second" {
|
|
t.Errorf("alice[0].Description = %q, want \"second\"", alice[0].Description)
|
|
}
|
|
if alice[1].Description != "first" {
|
|
t.Errorf("alice[1].Description = %q, want \"first\"", alice[1].Description)
|
|
}
|
|
|
|
bob, err := store.List("bob@example.com")
|
|
if err != nil {
|
|
t.Fatalf("List bob: %v", err)
|
|
}
|
|
if len(bob) != 1 {
|
|
t.Fatalf("List bob = %d, want 1", len(bob))
|
|
}
|
|
if bob[0].Description != "bob's" {
|
|
t.Errorf("bob[0].Description = %q", bob[0].Description)
|
|
}
|
|
}
|
|
|
|
func TestListIgnoresNonTokenFiles(t *testing.T) {
|
|
store := newTestStore(t)
|
|
_, _, err := store.Generate("alice@example.com", "real", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
// Drop a junk file alongside.
|
|
if err := os.WriteFile(filepath.Join(store.Dir(), "garbage"), []byte("nope"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(store.Dir(), "ZZ"+strings.Repeat("0", HashLen-2)), []byte("not hex"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
list, err := store.List("alice@example.com")
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Errorf("List = %d, want 1 (ignoring junk)", len(list))
|
|
}
|
|
}
|
|
|
|
func TestRevokeByShortID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
plaintext, tok, err := store.Generate("alice@example.com", "to revoke", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
|
|
if err := store.Revoke("alice@example.com", tok.ID()); err != nil {
|
|
t.Fatalf("Revoke: %v", err)
|
|
}
|
|
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken after revoke, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeByFullHash(t *testing.T) {
|
|
store := newTestStore(t)
|
|
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if err := store.Revoke("alice@example.com", tok.Hash); err != nil {
|
|
t.Fatalf("Revoke by hash: %v", err)
|
|
}
|
|
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken after revoke, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeOtherUsersTokenReturnsNotFound(t *testing.T) {
|
|
store := newTestStore(t)
|
|
plaintext, tok, err := store.Generate("alice@example.com", "alice's", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
// Bob attempts to revoke alice's token.
|
|
if err := store.Revoke("bob@example.com", tok.ID()); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
// Token must still be valid.
|
|
if _, err := store.Validate(plaintext); err != nil {
|
|
t.Errorf("token wrongly revoked: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeNonexistentReturnsNotFound(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if err := store.Revoke("alice@example.com", "deadbeef"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeRejectsInvalidIDs(t *testing.T) {
|
|
store := newTestStore(t)
|
|
for _, id := range []string{"", "xx", "ZZZ", "abc"} {
|
|
if err := store.Revoke("alice@example.com", id); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Revoke(%q) expected ErrNotFound, got %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateRejectsEmptyEmail(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, _, err := store.Generate("", "", time.Time{}); err == nil {
|
|
t.Error("expected error for empty email")
|
|
}
|
|
}
|
|
|
|
func TestTokenFileMode(t *testing.T) {
|
|
store := newTestStore(t)
|
|
_, tok, err := store.Generate("alice@example.com", "", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
info, err := os.Stat(filepath.Join(store.Dir(), tok.Hash))
|
|
if err != nil {
|
|
t.Fatalf("stat token file: %v", err)
|
|
}
|
|
if mode := info.Mode().Perm(); mode&0o077 != 0 {
|
|
t.Errorf("token file mode %o exposes group/other bits", mode)
|
|
}
|
|
}
|
|
|
|
func TestTokenFilenameIsHashNotPlaintext(t *testing.T) {
|
|
store := newTestStore(t)
|
|
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
// Plaintext must not appear as a filename.
|
|
entries, err := os.ReadDir(store.Dir())
|
|
if err != nil {
|
|
t.Fatalf("ReadDir: %v", err)
|
|
}
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), plaintext) {
|
|
t.Errorf("plaintext token leaked in filename %q", e.Name())
|
|
}
|
|
}
|
|
// And the hash file must exist.
|
|
if _, err := os.Stat(filepath.Join(store.Dir(), tok.Hash)); err != nil {
|
|
t.Errorf("hash-named file missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExpiredHelper(t *testing.T) {
|
|
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
|
|
cases := []struct {
|
|
expires time.Time
|
|
expired bool
|
|
}{
|
|
{time.Time{}, false}, // zero = no expiry
|
|
{now.Add(time.Hour), false}, // future
|
|
{now.Add(-time.Hour), true}, // past
|
|
{now, true}, // exactly now = expired
|
|
}
|
|
for _, c := range cases {
|
|
tok := Token{Expires: c.expires}
|
|
if got := tok.Expired(now); got != c.expired {
|
|
t.Errorf("Token{Expires:%v}.Expired(%v) = %v, want %v", c.expires, now, got, c.expired)
|
|
}
|
|
}
|
|
}
|