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