Implement Issue #1: User Accounts & Profiles
- 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:
442
internal/volunteer/handler_test.go
Normal file
442
internal/volunteer/handler_test.go
Normal 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: ¬es},
|
||||
},
|
||||
}
|
||||
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: ¬es},
|
||||
},
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user