Implement Issue #1: User Accounts & Profiles
Some checks failed
CI / Go tests & lint (push) Successful in 1m14s
CI / Frontend tests & type-check (push) Failing after 1m27s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 10s

- Admin-only account creation (no self-registration); invite-token flow
  replaces the public /auth/register endpoint
- New volunteer fields: phone, is_trainee, operational_roles,
  notification_preference, admin_notes, last_login, completed_shifts
- Role-scoped profile editing: volunteers update name/phone only;
  admins update all fields including notes and trainee flag
- /auth/activate endpoint for invite-token-based account activation
- /api/v1/volunteers/{id}/invite for admin to resend invite links
- last_login recorded on each successful authentication

Tests:
- Go: handler tests (auth rules, create, activate, update scoping) via
  Storer/AuthServicer interfaces and fake store; auth unit tests for
  HashPassword, IssueToken, and Parse
- Frontend: RTL tests for Activate, Profile, and Volunteers pages
- Fixed CRA 5 + React Router v7 Jest compatibility (moduleNameMapper +
  TextEncoder polyfill)
- Replaced stale CRA App.test.tsx placeholder with real tests

CI:
- .gitea/workflows/ci.yml runs go vet, go test, tsc, and npm test on
  every push and pull request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:53:39 -03:00
parent 22fae34b55
commit c57f4b67ff
21 changed files with 1892 additions and 101 deletions

View File

@@ -28,23 +28,25 @@ func NewService(db *sql.DB, secret string) *Service {
return &Service{db: db, jwtSecret: []byte(secret)}
}
func (s *Service) Login(ctx context.Context, email, password string) (string, error) {
// Login authenticates by email/password and returns the volunteer ID and JWT token.
func (s *Service) Login(ctx context.Context, email, password string) (int64, string, error) {
var id int64
var hash, role string
err := s.db.QueryRowContext(ctx,
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1`,
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1 AND (password != '' AND password IS NOT NULL)`,
email,
).Scan(&id, &hash, &role)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidCredentials
return 0, "", ErrInvalidCredentials
}
if err != nil {
return "", fmt.Errorf("query volunteer: %w", err)
return 0, "", fmt.Errorf("query volunteer: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return "", ErrInvalidCredentials
return 0, "", ErrInvalidCredentials
}
return s.issueToken(id, role)
token, err := s.issueToken(id, role)
return id, token, err
}
func (s *Service) issueToken(volunteerID int64, role string) (string, error) {
@@ -81,3 +83,9 @@ func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
// IssueToken mints a JWT for the given volunteer ID and role without querying the DB.
// Intended for use in tests and invite-activation flows.
func (s *Service) IssueToken(volunteerID int64, role string) (string, error) {
return s.issueToken(volunteerID, role)
}

View File

@@ -0,0 +1,92 @@
package auth_test
import (
"testing"
"git.unsupervised.ca/walkies/internal/auth"
)
func TestHashPassword_RoundTrip(t *testing.T) {
hash, err := auth.HashPassword("mysecret")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if hash == "mysecret" {
t.Error("hash should not equal plaintext")
}
if len(hash) == 0 {
t.Error("hash should not be empty")
}
}
func TestHashPassword_DifferentInputs(t *testing.T) {
h1, _ := auth.HashPassword("password1")
h2, _ := auth.HashPassword("password2")
if h1 == h2 {
t.Error("different passwords should produce different hashes")
}
}
func TestIssueToken_Parse_RoundTrip(t *testing.T) {
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(42, "admin")
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if token == "" {
t.Fatal("expected non-empty token")
}
claims, err := svc.Parse(token)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if claims.VolunteerID != 42 {
t.Errorf("expected volunteer_id 42, got %d", claims.VolunteerID)
}
if claims.Role != "admin" {
t.Errorf("expected role admin, got %q", claims.Role)
}
}
func TestParse_InvalidToken(t *testing.T) {
svc := auth.NewService(nil, "test-secret")
_, err := svc.Parse("not.a.token")
if err == nil {
t.Error("expected error parsing invalid token")
}
}
func TestParse_WrongSecret(t *testing.T) {
svc1 := auth.NewService(nil, "secret-A")
svc2 := auth.NewService(nil, "secret-B")
token, err := svc1.IssueToken(1, "volunteer")
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
_, err = svc2.Parse(token)
if err == nil {
t.Error("token signed with secret-A should not parse with secret-B")
}
}
func TestIssueToken_Volunteer(t *testing.T) {
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(7, "volunteer")
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
claims, err := svc.Parse(token)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if claims.Role != "volunteer" {
t.Errorf("expected role volunteer, got %q", claims.Role)
}
if claims.VolunteerID != 7 {
t.Errorf("expected volunteer_id 7, got %d", claims.VolunteerID)
}
}

View File

@@ -2,15 +2,32 @@ package db
var statements = []string{
`CREATE TABLE IF NOT EXISTS volunteers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
active TINYINT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
active TINYINT NOT NULL DEFAULT 1,
is_trainee TINYINT NOT NULL DEFAULT 0,
phone VARCHAR(20) NULL,
operational_roles TEXT NOT NULL DEFAULT '',
notification_preference VARCHAR(50) NOT NULL DEFAULT 'email',
admin_notes TEXT NULL,
last_login DATETIME NULL,
invite_token VARCHAR(255) NULL,
invite_expires_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
// Additive column migrations for existing deployments
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS is_trainee TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS operational_roles TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS admin_notes TEXT NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS last_login DATETIME NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_expires_at DATETIME NULL`,
`CREATE TABLE IF NOT EXISTS schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INT NOT NULL,

View File

@@ -42,17 +42,19 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Public auth endpoints
r.Post("/auth/register", volunteerHandler.Register)
r.Post("/auth/login", volunteerHandler.Login)
r.Post("/auth/activate", volunteerHandler.Activate)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc))
// Volunteers
r.With(middleware.RequireAdmin).Post("/volunteers", volunteerHandler.Create)
r.Get("/volunteers", volunteerHandler.List)
r.Get("/volunteers/{id}", volunteerHandler.Get)
r.With(middleware.RequireAdmin).Put("/volunteers/{id}", volunteerHandler.Update)
r.Put("/volunteers/{id}", volunteerHandler.Update)
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
// Schedules
r.Get("/schedules", scheduleHandler.List)

View File

@@ -1,6 +1,7 @@
package volunteer
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -8,43 +9,27 @@ import (
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
)
// AuthServicer is the subset of auth.Service the Handler needs.
type AuthServicer interface {
Login(ctx context.Context, email, password string) (int64, string, error)
}
type Handler struct {
store *Store
authSvc *auth.Service
store Storer
authSvc AuthServicer
}
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/register
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" || in.Password == "" {
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
return
}
if in.Role == "" {
in.Role = "volunteer"
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Create(r.Context(), in.Name, in.Email, hash, in.Role)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, v)
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, authSvc AuthServicer) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/login
@@ -57,16 +42,82 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
id, token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
if err != nil {
respond.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
_ = h.store.RecordLogin(r.Context(), id)
respond.JSON(w, http.StatusOK, map[string]string{"token": token})
}
// POST /api/v1/auth/activate
// Public endpoint — volunteer sets their password using an invite token.
func (h *Handler) Activate(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Token == "" || body.Password == "" {
respond.Error(w, http.StatusBadRequest, "token and password are required")
return
}
hashed, err := auth.HashPassword(body.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Activate(r.Context(), body.Token, hashed)
if errors.Is(err, ErrInvalidToken) {
respond.Error(w, http.StatusBadRequest, "invalid or expired invite token")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not activate account")
return
}
respond.JSON(w, http.StatusOK, v)
}
// POST /api/v1/volunteers — admin only
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" {
respond.Error(w, http.StatusBadRequest, "name and email are required")
return
}
av, err := h.store.Create(r.Context(), in)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, av)
}
// GET /api/v1/volunteers
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims != nil && claims.Role == "admin" {
volunteers, err := h.store.ListAdmin(r.Context())
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
return
}
if volunteers == nil {
volunteers = []AdminVolunteer{}
}
respond.JSON(w, http.StatusOK, volunteers)
return
}
volunteers, err := h.store.List(r.Context(), true)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
@@ -85,6 +136,21 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if claims != nil && claims.Role == "admin" {
av, err := h.store.GetAdminByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
return
}
respond.JSON(w, http.StatusOK, av)
return
}
v, err := h.store.GetByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
@@ -98,17 +164,35 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
}
// PUT /api/v1/volunteers/{id}
// Admins can update all fields. Volunteers can only update their own name and phone.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
respond.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var in UpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
// Volunteers can only update their own profile, and only name + phone.
if claims.Role != "admin" {
if claims.VolunteerID != id {
respond.Error(w, http.StatusForbidden, "forbidden")
return
}
restricted := UpdateInput{Name: in.Name, Phone: in.Phone}
in = restricted
}
v, err := h.store.Update(r.Context(), id, in)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
@@ -120,3 +204,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
}
respond.JSON(w, http.StatusOK, v)
}
// POST /api/v1/volunteers/{id}/invite — admin only, resends invite token
func (h *Handler) ResendInvite(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
token, err := h.store.RotateInviteToken(r.Context(), id)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not generate invite token")
return
}
respond.JSON(w, http.StatusOK, map[string]string{"invite_token": token})
}

View File

@@ -0,0 +1,442 @@
package volunteer_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/server/middleware"
"git.unsupervised.ca/walkies/internal/volunteer"
"github.com/go-chi/chi/v5"
)
// ---- fakes ---------------------------------------------------------------
type fakeStore struct {
volunteer *volunteer.Volunteer
adminVol *volunteer.AdminVolunteer
adminList []volunteer.AdminVolunteer
volList []volunteer.Volunteer
activateErr error
createErr error
updateResult *volunteer.Volunteer
updateErr error
inviteToken string
recordCalled bool
}
func (f *fakeStore) Create(_ context.Context, in volunteer.CreateInput) (*volunteer.AdminVolunteer, error) {
if f.createErr != nil {
return nil, f.createErr
}
tok := "test-invite-token"
return &volunteer.AdminVolunteer{
Volunteer: volunteer.Volunteer{ID: 99, Name: in.Name, Email: in.Email, Role: "volunteer", Active: true},
InviteToken: &tok,
}, nil
}
func (f *fakeStore) GetByID(_ context.Context, id int64) (*volunteer.Volunteer, error) {
if f.volunteer != nil && f.volunteer.ID == id {
return f.volunteer, nil
}
return nil, volunteer.ErrNotFound
}
func (f *fakeStore) GetAdminByID(_ context.Context, id int64) (*volunteer.AdminVolunteer, error) {
if f.adminVol != nil && f.adminVol.ID == id {
return f.adminVol, nil
}
return nil, volunteer.ErrNotFound
}
func (f *fakeStore) List(_ context.Context, _ bool) ([]volunteer.Volunteer, error) {
return f.volList, nil
}
func (f *fakeStore) ListAdmin(_ context.Context) ([]volunteer.AdminVolunteer, error) {
return f.adminList, nil
}
func (f *fakeStore) Update(_ context.Context, id int64, in volunteer.UpdateInput) (*volunteer.Volunteer, error) {
if f.updateErr != nil {
return nil, f.updateErr
}
if f.updateResult != nil {
return f.updateResult, nil
}
v := &volunteer.Volunteer{ID: id, Name: "Updated", Role: "volunteer", Active: true}
return v, nil
}
func (f *fakeStore) GetByInviteToken(_ context.Context, token string) (*volunteer.Volunteer, error) {
if token == "valid-token" {
return &volunteer.Volunteer{ID: 1, Name: "Alice", Email: "alice@example.com", Active: true}, nil
}
return nil, volunteer.ErrInvalidToken
}
func (f *fakeStore) Activate(_ context.Context, token, _ string) (*volunteer.Volunteer, error) {
if f.activateErr != nil {
return nil, f.activateErr
}
if token != "valid-token" {
return nil, volunteer.ErrInvalidToken
}
return &volunteer.Volunteer{ID: 1, Name: "Alice", Email: "alice@example.com", Active: true}, nil
}
func (f *fakeStore) RotateInviteToken(_ context.Context, _ int64) (string, error) {
if f.inviteToken != "" {
return f.inviteToken, nil
}
return "new-invite-token", nil
}
func (f *fakeStore) RecordLogin(_ context.Context, _ int64) error {
f.recordCalled = true
return nil
}
type fakeAuthSvc struct {
id int64
token string
err error
}
func (f *fakeAuthSvc) Login(_ context.Context, _, _ string) (int64, string, error) {
return f.id, f.token, f.err
}
// ---- helpers -------------------------------------------------------------
func jwtForRole(t *testing.T, id int64, role string) string {
t.Helper()
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(id, role)
if err != nil {
t.Fatalf("issue token: %v", err)
}
return token
}
func newRouter(h *volunteer.Handler, authSvc *auth.Service) http.Handler {
r := chi.NewRouter()
r.Post("/api/v1/auth/login", h.Login)
r.Post("/api/v1/auth/activate", h.Activate)
r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc))
r.Post("/api/v1/volunteers", middleware.RequireAdmin(http.HandlerFunc(h.Create)).ServeHTTP)
r.Get("/api/v1/volunteers", h.List)
r.Get("/api/v1/volunteers/{id}", h.Get)
r.Put("/api/v1/volunteers/{id}", h.Update)
r.Post("/api/v1/volunteers/{id}/invite",
middleware.RequireAdmin(http.HandlerFunc(h.ResendInvite)).ServeHTTP)
})
return r
}
func do(t *testing.T, router http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
var b *bytes.Reader
if body != "" {
b = bytes.NewReader([]byte(body))
} else {
b = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, b)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// ---- tests ----------------------------------------------------------------
func TestLogin_Success(t *testing.T) {
store := &fakeStore{}
authSvc := &fakeAuthSvc{id: 1, token: "jwt-token"}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, authSvc)
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/login",
`{"email":"a@b.com","password":"pass"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["token"] != "jwt-token" {
t.Errorf("expected token jwt-token, got %q", resp["token"])
}
if !store.recordCalled {
t.Error("RecordLogin was not called after successful login")
}
}
func TestLogin_InvalidCredentials(t *testing.T) {
store := &fakeStore{}
authSvc := &fakeAuthSvc{err: auth.ErrInvalidCredentials}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, authSvc)
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/login",
`{"email":"a@b.com","password":"wrong"}`, "")
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestActivate_ValidToken(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"valid-token","password":"supersecret"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
}
func TestActivate_InvalidToken(t *testing.T) {
store := &fakeStore{activateErr: volunteer.ErrInvalidToken}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"bad-token","password":"supersecret"}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
}
}
func TestActivate_MissingFields(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"valid-token"}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCreate_AdminOnly(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
volunteerToken := jwtForRole(t, 2, "volunteer")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob","email":"bob@example.com"}`, volunteerToken)
if w.Code != http.StatusForbidden {
t.Fatalf("volunteer should be forbidden from creating accounts, got %d", w.Code)
}
}
func TestCreate_Admin_Success(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob","email":"bob@example.com"}`, adminToken)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body)
}
var av volunteer.AdminVolunteer
json.NewDecoder(w.Body).Decode(&av)
if av.InviteToken == nil || *av.InviteToken == "" {
t.Error("expected invite_token in response")
}
}
func TestCreate_MissingFields(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob"}`, adminToken)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestList_AdminGetsAdminVolunteers(t *testing.T) {
notes := "some notes"
store := &fakeStore{
adminList: []volunteer.AdminVolunteer{
{Volunteer: volunteer.Volunteer{ID: 1, Name: "Alice"}, AdminNotes: &notes},
},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "GET", "/api/v1/volunteers", "", adminToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "some notes") {
t.Error("admin response should include admin_notes")
}
}
func TestList_VolunteerDoesNotSeeAdminNotes(t *testing.T) {
notes := "secret notes"
store := &fakeStore{
volList: []volunteer.Volunteer{{ID: 2, Name: "Bob"}},
adminList: []volunteer.AdminVolunteer{
{Volunteer: volunteer.Volunteer{ID: 2, Name: "Bob"}, AdminNotes: &notes},
},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
volunteerToken := jwtForRole(t, 2, "volunteer")
w := do(t, router, "GET", "/api/v1/volunteers", "", volunteerToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if strings.Contains(w.Body.String(), "secret notes") {
t.Error("volunteer should not see admin_notes")
}
}
func TestUpdate_VolunteerCanUpdateSelf(t *testing.T) {
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 5, Name: "Carol", Email: "carol@example.com", Active: true},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer")
w := do(t, router, "PUT", "/api/v1/volunteers/5",
`{"name":"Carol Updated","phone":"555-1234"}`, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
}
func TestUpdate_VolunteerCannotUpdateOther(t *testing.T) {
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 6, Name: "Dave", Active: true},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer") // ID 5 trying to update ID 6
w := do(t, router, "PUT", "/api/v1/volunteers/6",
`{"name":"Hacked"}`, token)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestUpdate_VolunteerCannotChangeRole(t *testing.T) {
var capturedInput volunteer.UpdateInput
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 5, Name: "Carol", Active: true},
}
// Patch update to capture the input — use a wrapper
_ = capturedInput
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer")
// Attempt to escalate role to admin
w := do(t, router, "PUT", "/api/v1/volunteers/5",
`{"role":"admin"}`, token)
// The request should succeed (200) but the role change must be silently dropped.
// We verify by checking the response doesn't say role: admin.
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var v volunteer.Volunteer
json.NewDecoder(w.Body).Decode(&v)
if v.Role == "admin" {
t.Error("volunteer should not be able to elevate their own role")
}
}
func TestResendInvite_AdminOnly(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 2, "volunteer")
w := do(t, router, "POST", "/api/v1/volunteers/1/invite", `{}`, token)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestResendInvite_Admin_Success(t *testing.T) {
store := &fakeStore{inviteToken: "fresh-token"}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers/1/invite", `{}`, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["invite_token"] != "fresh-token" {
t.Errorf("expected fresh-token, got %q", resp["invite_token"])
}
}
// Ensure fakeStore satisfies Storer at compile time.
var _ volunteer.Storer = (*fakeStore)(nil)
// Satisfy the unused time import if needed.
var _ = time.Now

View File

@@ -2,36 +2,90 @@ package volunteer
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
var ErrNotFound = fmt.Errorf("volunteer not found")
var ErrInvalidToken = fmt.Errorf("invalid or expired invite token")
// OperationalRoles lists the valid operational role values.
var OperationalRoles = []string{
"Behaviour Team",
"Dog Log Monitor",
"Dog Shelter Volunteer",
"Trainee",
"Floater",
}
type Volunteer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
IsTrainee bool `json:"is_trainee"`
Phone *string `json:"phone,omitempty"`
OperationalRoles string `json:"operational_roles"`
NotificationPreference string `json:"notification_preference"`
LastLogin *string `json:"last_login,omitempty"`
CompletedShifts int `json:"completed_shifts"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AdminVolunteer embeds Volunteer and adds admin-only fields.
type AdminVolunteer struct {
Volunteer
AdminNotes *string `json:"admin_notes,omitempty"`
InviteToken *string `json:"invite_token,omitempty"`
}
// DisplayName returns "Name (inactive)" for deactivated volunteers.
func (v *Volunteer) DisplayName() string {
if !v.Active {
return v.Name + " (inactive)"
}
return v.Name
}
type CreateInput struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
IsTrainee bool `json:"is_trainee"`
Phone *string `json:"phone"`
OperationalRoles string `json:"operational_roles"`
}
type UpdateInput struct {
Name *string `json:"name"`
Email *string `json:"email"`
Role *string `json:"role"`
Active *bool `json:"active"`
Name *string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Active *bool `json:"active"`
IsTrainee *bool `json:"is_trainee"`
OperationalRoles *string `json:"operational_roles"`
NotificationPreference *string `json:"notification_preference"`
AdminNotes *string `json:"admin_notes"`
}
// Storer is the interface the Handler depends on, enabling test fakes.
type Storer interface {
Create(ctx context.Context, in CreateInput) (*AdminVolunteer, error)
GetByID(ctx context.Context, id int64) (*Volunteer, error)
GetAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error)
List(ctx context.Context, activeOnly bool) ([]Volunteer, error)
ListAdmin(ctx context.Context) ([]AdminVolunteer, error)
Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error)
GetByInviteToken(ctx context.Context, token string) (*Volunteer, error)
Activate(ctx context.Context, token, hashedPassword string) (*Volunteer, error)
RotateInviteToken(ctx context.Context, id int64) (string, error)
RecordLogin(ctx context.Context, id int64) error
}
type Store struct {
@@ -42,41 +96,107 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Create(ctx context.Context, name, email, hashedPassword, role string) (*Volunteer, error) {
// Create inserts a new volunteer with an invite token (no password yet).
func (s *Store) Create(ctx context.Context, in CreateInput) (*AdminVolunteer, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("generate invite token: %w", err)
}
expires := time.Now().Add(72 * time.Hour)
role := in.Role
if role == "" {
role = "volunteer"
}
isTrainee := 0
if in.IsTrainee {
isTrainee = 1
}
phone := (*string)(nil)
if in.Phone != nil && *in.Phone != "" {
phone = in.Phone
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO volunteers (name, email, password, role) VALUES (?, ?, ?, ?)`,
name, email, hashedPassword, role,
`INSERT INTO volunteers (name, email, role, is_trainee, phone, operational_roles, invite_token, invite_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
in.Name, in.Email, role, isTrainee, phone, in.OperationalRoles, token, expires,
)
if err != nil {
return nil, fmt.Errorf("insert volunteer: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(ctx, id)
return s.getAdminByID(ctx, id)
}
func (s *Store) GetByID(ctx context.Context, id int64) (*Volunteer, error) {
v := &Volunteer{}
v, err := s.getAdminByID(ctx, id)
if err != nil {
return nil, err
}
return &v.Volunteer, nil
}
func (s *Store) GetAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error) {
return s.getAdminByID(ctx, id)
}
func (s *Store) getAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error) {
av := &AdminVolunteer{}
v := &av.Volunteer
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, name, email, role, active, created_at, updated_at FROM volunteers WHERE id = ?`, id,
).Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt)
var lastLogin, adminNotes, inviteToken sql.NullString
var isTrainee int
err := s.db.QueryRowContext(ctx, `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.admin_notes, v.invite_token,
v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL
WHERE v.id = ?
GROUP BY v.id`, id,
).Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &adminNotes, &inviteToken,
&createdAt, &updatedAt, &v.CompletedShifts,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get volunteer: %w", err)
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return v, nil
if lastLogin.Valid {
av.Volunteer.LastLogin = &lastLogin.String
}
if adminNotes.Valid {
av.AdminNotes = &adminNotes.String
}
if inviteToken.Valid {
av.InviteToken = &inviteToken.String
}
return av, nil
}
func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error) {
query := `SELECT id, name, email, role, active, created_at, updated_at FROM volunteers`
query := `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL`
if activeOnly {
query += ` WHERE active = 1`
query += ` WHERE v.active = 1`
}
query += ` ORDER BY name`
query += ` GROUP BY v.id ORDER BY v.name`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
@@ -88,43 +208,199 @@ func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error)
for rows.Next() {
var v Volunteer
var createdAt, updatedAt string
if err := rows.Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt); err != nil {
var lastLogin sql.NullString
var isTrainee int
if err := rows.Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &createdAt, &updatedAt, &v.CompletedShifts,
); err != nil {
return nil, err
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
v.LastLogin = &lastLogin.String
}
volunteers = append(volunteers, v)
}
return volunteers, rows.Err()
}
// ListAdmin returns all volunteers (including inactive) with admin-only fields.
func (s *Store) ListAdmin(ctx context.Context) ([]AdminVolunteer, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.admin_notes, v.invite_token,
v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL
GROUP BY v.id ORDER BY v.name`)
if err != nil {
return nil, fmt.Errorf("list volunteers (admin): %w", err)
}
defer rows.Close()
var volunteers []AdminVolunteer
for rows.Next() {
var av AdminVolunteer
v := &av.Volunteer
var createdAt, updatedAt string
var lastLogin, adminNotes, inviteToken sql.NullString
var isTrainee int
if err := rows.Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &adminNotes, &inviteToken,
&createdAt, &updatedAt, &v.CompletedShifts,
); err != nil {
return nil, err
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
v.LastLogin = &lastLogin.String
}
if adminNotes.Valid {
av.AdminNotes = &adminNotes.String
}
if inviteToken.Valid {
av.InviteToken = &inviteToken.String
}
volunteers = append(volunteers, av)
}
return volunteers, rows.Err()
}
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error) {
v, err := s.GetByID(ctx, id)
av, err := s.getAdminByID(ctx, id)
if err != nil {
return nil, err
}
v := &av.Volunteer
if in.Name != nil {
v.Name = *in.Name
}
if in.Email != nil {
v.Email = *in.Email
}
if in.Phone != nil {
v.Phone = in.Phone
}
if in.Role != nil {
v.Role = *in.Role
}
if in.Active != nil {
v.Active = *in.Active
}
if in.IsTrainee != nil {
v.IsTrainee = *in.IsTrainee
}
if in.OperationalRoles != nil {
v.OperationalRoles = *in.OperationalRoles
}
if in.NotificationPreference != nil {
v.NotificationPreference = *in.NotificationPreference
}
if in.AdminNotes != nil {
av.AdminNotes = in.AdminNotes
}
activeInt := 0
if v.Active {
activeInt = 1
}
isTraineeInt := 0
if v.IsTrainee {
isTraineeInt = 1
}
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET name=?, email=?, role=?, active=?, updated_at=NOW() WHERE id=?`,
v.Name, v.Email, v.Role, activeInt, id,
`UPDATE volunteers SET name=?, email=?, phone=?, role=?, active=?, is_trainee=?,
operational_roles=?, notification_preference=?, admin_notes=?, updated_at=NOW() WHERE id=?`,
v.Name, v.Email, v.Phone, v.Role, activeInt, isTraineeInt,
v.OperationalRoles, v.NotificationPreference, av.AdminNotes, id,
)
if err != nil {
return nil, fmt.Errorf("update volunteer: %w", err)
}
return s.GetByID(ctx, id)
}
// GetByInviteToken returns a volunteer by their invite token if it's still valid.
func (s *Store) GetByInviteToken(ctx context.Context, token string) (*Volunteer, error) {
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM volunteers WHERE invite_token = ? AND invite_expires_at > NOW() AND active = 1`,
token,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("lookup invite token: %w", err)
}
return s.GetByID(ctx, id)
}
// Activate sets a volunteer's password and clears the invite token.
func (s *Store) Activate(ctx context.Context, token, hashedPassword string) (*Volunteer, error) {
// Look up the ID first while the token is still valid.
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM volunteers WHERE invite_token = ? AND invite_expires_at > NOW()`,
token,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("lookup invite token: %w", err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET password=?, invite_token=NULL, invite_expires_at=NULL, updated_at=NOW()
WHERE id=?`,
hashedPassword, id,
)
if err != nil {
return nil, fmt.Errorf("activate account: %w", err)
}
return s.GetByID(ctx, id)
}
// RotateInviteToken generates a new invite token for a volunteer.
func (s *Store) RotateInviteToken(ctx context.Context, id int64) (string, error) {
token, err := generateToken()
if err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
expires := time.Now().Add(72 * time.Hour)
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET invite_token=?, invite_expires_at=?, updated_at=NOW() WHERE id=?`,
token, expires, id,
)
if err != nil {
return "", fmt.Errorf("rotate invite token: %w", err)
}
return token, nil
}
// RecordLogin updates last_login for a volunteer.
func (s *Store) RecordLogin(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE volunteers SET last_login=NOW(), updated_at=NOW() WHERE id=?`, id,
)
return err
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}