package membership import ( "errors" "strings" "testing" ) // a valid-shape Ed25519 public key in hex (64 hex chars). The bytes are // arbitrary: the store treats sign_pub as an opaque identifier and only the CLI // validates the length, so any 64-hex string round-trips through the store. const ( pubAlice = "1111111111111111111111111111111111111111111111111111111111111111" pubBob = "2222222222222222222222222222222222222222222222222222222222222222" ) // Golden: add a user, read it back, and confirm it authorizes. func TestAddGetIsAuthorized(t *testing.T) { s := openTestStore(t) if err := s.AddUser(pubAlice, "alice", RoleAdmin); err != nil { t.Fatalf("AddUser: %v", err) } u, err := s.GetUser(pubAlice) if err != nil { t.Fatalf("GetUser: %v", err) } if u.Handle != "alice" || u.Role != RoleAdmin || u.Status != StatusActive { t.Fatalf("GetUser mismatch: %+v", u) } if u.CreatedAt == "" { t.Fatalf("CreatedAt not stamped") } if u.RevokedAt != "" { t.Fatalf("RevokedAt should be empty for an active user, got %q", u.RevokedAt) } if !s.IsAuthorized(pubAlice) { t.Fatalf("active user should be authorized") } if !s.HasAdmin() { t.Fatalf("HasAdmin should be true after seeding an admin") } } // Edge: an empty role defaults to member; case-insensitive lookup; list order. func TestAddDefaultsAndListing(t *testing.T) { s := openTestStore(t) if err := s.AddUser(pubBob, "bob", ""); err != nil { t.Fatalf("AddUser bob: %v", err) } u, err := s.GetUser(pubBob) if err != nil { t.Fatalf("GetUser bob: %v", err) } if u.Role != RoleMember { t.Fatalf("empty role should default to member, got %q", u.Role) } // Adding bob (a member only) must not make HasAdmin true. if s.HasAdmin() { t.Fatalf("HasAdmin should be false with only a member registered") } // Lookup is case-insensitive: uppercase hex matches the lowercase-stored key. if !s.IsAuthorized(strings.ToUpper(pubBob)) { t.Fatalf("IsAuthorized should be case-insensitive on the hex key") } if err := s.AddUser(pubAlice, "alice", RoleAdmin); err != nil { t.Fatalf("AddUser alice: %v", err) } users, err := s.ListUsers() if err != nil { t.Fatalf("ListUsers: %v", err) } // Ordered by handle: alice before bob. if len(users) != 2 || users[0].Handle != "alice" || users[1].Handle != "bob" { t.Fatalf("ListUsers order/content wrong: %+v", users) } } // Edge: revocation flips status, stamps revoked_at, and denies authorization on // the spot — the property both planes rely on for revoke-without-restart. func TestRevokeDeniesAuthorization(t *testing.T) { s := openTestStore(t) if err := s.AddUser(pubAlice, "alice", RoleMember); err != nil { t.Fatalf("AddUser: %v", err) } if !s.IsAuthorized(pubAlice) { t.Fatalf("precondition: user should be authorized before revoke") } if err := s.RevokeUser(pubAlice); err != nil { t.Fatalf("RevokeUser: %v", err) } if s.IsAuthorized(pubAlice) { t.Fatalf("revoked user must NOT be authorized") } u, err := s.GetUser(pubAlice) if err != nil { t.Fatalf("GetUser after revoke: %v", err) } if u.Status != StatusRevoked || u.RevokedAt == "" { t.Fatalf("revoke should set status=revoked and stamp revoked_at, got %+v", u) } } // Error path: duplicate key, unknown user, invalid role, revoke of unknown. func TestUserErrorPaths(t *testing.T) { s := openTestStore(t) if err := s.AddUser(pubAlice, "alice", RoleAdmin); err != nil { t.Fatalf("AddUser: %v", err) } // Duplicate sign_pub -> typed ErrUserExists. if err := s.AddUser(pubAlice, "alice2", RoleMember); !errors.Is(err, ErrUserExists) { t.Fatalf("duplicate AddUser should return ErrUserExists, got %v", err) } // Invalid role rejected. if err := s.AddUser(pubBob, "bob", "superuser"); err == nil { t.Fatalf("invalid role should error") } // Missing handle/sign_pub rejected. if err := s.AddUser("", "nobody", RoleMember); err == nil { t.Fatalf("empty sign_pub should error") } // Unknown user is not authorized (fail closed) and GetUser errors. if s.IsAuthorized(pubBob) { t.Fatalf("unknown user must not be authorized") } if _, err := s.GetUser(pubBob); err == nil { t.Fatalf("GetUser of unknown user should error") } // Revoking an unknown (or already-revoked) user errors (no active row). if err := s.RevokeUser(pubBob); err == nil { t.Fatalf("revoking unknown user should error") } if err := s.RevokeUser(pubAlice); err != nil { t.Fatalf("first revoke should succeed: %v", err) } if err := s.RevokeUser(pubAlice); err == nil { t.Fatalf("second revoke of same user should error (already revoked)") } } // Migration safety: the users table and its index exist after Open, and the // users migration is idempotent on re-apply (mirrors TestMigrationsCreateSchema). func TestUsersMigrationIdempotent(t *testing.T) { s := openTestStore(t) var name string if err := s.db.QueryRow( `SELECT name FROM sqlite_master WHERE type='table' AND name='users'`, ).Scan(&name); err != nil { t.Fatalf("users table not created: %v", err) } if err := s.db.QueryRow( `SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_status'`, ).Scan(&name); err != nil { t.Fatalf("idx_users_status not created: %v", err) } if err := s.applyMigrations(); err != nil { t.Fatalf("re-apply migrations: %v", err) } }