From 7dbcd052620f92d953a21e1a63b072533a3e54b4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:14:36 -0500 Subject: [PATCH] Rescoped colead role and revised session handling. --- auth.go | 14 +++ db.go | 31 ++++-- frontend/src/App.svelte | 3 +- frontend/src/db.js | 16 ++- frontend/src/pages/Dashboard.svelte | 12 +-- frontend/src/pages/ScheduleBoard.svelte | 12 ++- frontend/src/sync.js | 2 +- handle_settings.go | 2 +- handle_shifts.go | 51 ++++++++-- handle_shifts_test.go | 80 +++++++++++++++ handle_volunteers.go | 79 +++++++++++---- handle_volunteers_test.go | 124 ++++++++++++++++++++++++ 12 files changed, 376 insertions(+), 50 deletions(-) diff --git a/auth.go b/auth.go index 0cc812a..b675e6f 100644 --- a/auth.go +++ b/auth.go @@ -108,6 +108,20 @@ func hasAnyRole(roles []string, allowed []string) bool { return false } +func isCoLeadOnly(claims *Claims) bool { + return hasAnyRole(claims.Roles, []string{"colead"}) && + !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) +} + +func inSlice(v int, s []int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + func claimsFromContext(r *http.Request) *Claims { c, _ := r.Context().Value(claimsKey).(*Claims) return c diff --git a/db.go b/db.go index 99b335c..7857020 100644 --- a/db.go +++ b/db.go @@ -939,6 +939,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error { ); err != nil { return err } + app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID) + app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID) _, err := app.db.Exec( `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID, ) @@ -1206,7 +1208,7 @@ const volunteerSelect = `v.id, v.participant_id, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` -func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { +func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` var args []any if since != "" { @@ -1220,9 +1222,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu s := "%" + search + "%" args = append(args, s, s) } - if deptID != nil { + if len(deptIDs) == 1 { q += ` AND v.department_id = ?` - args = append(args, *deptID) + args = append(args, deptIDs[0]) + } else if len(deptIDs) > 1 { + q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)` + for _, id := range deptIDs { + args = append(args, id) + } } q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) @@ -1422,7 +1429,7 @@ func generateConfirmationToken() (string, error) { // --- Shifts --- -func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { +func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` var args []any if since != "" { @@ -1431,9 +1438,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { } else { q += ` AND deleted_at IS NULL` } - if deptID != nil { + if len(deptIDs) == 1 { q += ` AND department_id = ?` - args = append(args, *deptID) + args = append(args, deptIDs[0]) + } else if len(deptIDs) > 1 { + q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)` + for _, id := range deptIDs { + args = append(args, id) + } } if day != "" { q += ` AND day = ?` @@ -1669,3 +1681,10 @@ func boolInt(b bool) int { } return 0 } + +func placeholders(n int) string { + if n <= 0 { + return "" + } + return strings.Repeat("?,", n-1) + "?" +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 52c6741..a1fa253 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -84,7 +84,6 @@ const path = $derived(route || '/') const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } {#if updateAvailable} @@ -125,7 +124,7 @@ {#if roles.length === 1 && roles[0] === 'colead'} {:else} - + {/if} {:else if path.startsWith('/participants')} diff --git a/frontend/src/db.js b/frontend/src/db.js index bfddf9f..bd3b490 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -80,6 +80,18 @@ export async function saveSession(token, user) { } export async function clearSession() { - await db.session.clear() - await db.meta.clear() + await db.transaction('rw', + [db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + async () => { + await db.session.clear() + await db.meta.clear() + await db.event.clear() + await db.participants.clear() + await db.tickets.clear() + await db.departments.clear() + await db.volunteers.clear() + await db.shifts.clear() + await db.volunteer_shifts.clear() + } + ) } diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 0f6decc..9756b31 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -2,7 +2,7 @@ import { liveQuery } from 'dexie' import { db } from '../db.js' - let { session } = $props() + let { session, navigate } = $props() const roles = $derived(session?.user?.roles ?? []) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } @@ -147,14 +147,14 @@ {#if isAdmin} {:else if isStaffing || isColead} {/if} diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 922a0b0..6bea05e 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -135,11 +135,13 @@ try { const res = await api.shifts.reorder(positions) - if (res && !res.ok) throw new Error() - for (const p of positions) { - const s = await db.shifts.get(p.id) - if (s) await db.shifts.put({ ...s, position: p.position }) - } + if (res && !res.ok) throw new Error('Reorder failed') + await db.transaction('rw', db.shifts, async () => { + for (const p of positions) { + const s = await db.shifts.get(p.id) + if (s) await db.shifts.put({ ...s, position: p.position }) + } + }) } catch (err) { error = err.message } diff --git a/frontend/src/sync.js b/frontend/src/sync.js index e2c4bff..ef22313 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -22,10 +22,10 @@ async function checkBuildChanged() { await db.volunteers.clear() await db.shifts.clear() await db.volunteer_shifts.clear() + await db.meta.put({ key: 'build', value: build }) } ) } - await db.meta.put({ key: 'build', value: build }) } catch {} } diff --git a/handle_settings.go b/handle_settings.go index d4ed01c..2c0c991 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -58,7 +58,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { var val string switch vv := v.(type) { case string: - if k == "smtp_password" && vv == "" { + if k == "smtp_password" && (vv == "" || vv == "***") { continue } val = vv diff --git a/handle_shifts.go b/handle_shifts.go index 67312c5..9299916 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -8,20 +8,19 @@ import ( func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - var deptID *int + var deptIDs []int if d := q.Get("dept"); d != "" { - id, err := strconv.Atoi(d) - if err == nil { - deptID = &id + if id, err := strconv.Atoi(d); err == nil { + deptIDs = []int{id} } } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { - deptID = &claims.DeptIDs[0] + if isCoLeadOnly(claims) && len(deptIDs) == 0 { + deptIDs = claims.DeptIDs } - shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) + shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since")) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !inSlice(s.DepartmentID, claims.DeptIDs) { + if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -87,6 +86,14 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(id) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.deleteShift(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques writeError(w, "volunteer_id required", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(shiftID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if !body.Force { conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID) @@ -149,6 +164,14 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ writeError(w, "invalid volunteer id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(shiftID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) { writeError(w, "array of {id, position} required", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + for _, p := range raw { + s, _ := app.getShift(p.ID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + } positions := make([]struct{ ID, Position int }, len(raw)) for i, p := range raw { positions[i] = struct{ ID, Position int }{p.ID, p.Position} diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 940164e..19c49bf 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -104,6 +104,86 @@ func TestShiftAssignConflict(t *testing.T) { } } +func TestCoLeadDeleteShiftOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadDeleteShiftOwnDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{ + "volunteer_id": v.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadReorderShiftsOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{ + {"id": s1.ID, "position": 2}, + {"id": s2.ID, "position": 1}, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept reorder, got %d", w.Code) + } +} + func TestShiftReorder(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) diff --git a/handle_volunteers.go b/handle_volunteers.go index ec3a317..cd891d2 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -12,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { search := q.Get("search") since := q.Get("since") - var deptID *int + var deptIDs []int if d := q.Get("dept"); d != "" { - id, err := strconv.Atoi(d) - if err == nil { - deptID = &id + if id, err := strconv.Atoi(d); err == nil { + deptIDs = []int{id} } } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { - deptID = &claims.DeptIDs[0] + if isCoLeadOnly(claims) && len(deptIDs) == 0 { + deptIDs = claims.DeptIDs } - volunteers, err := app.listVolunteers(search, deptID, since) + volunteers, err := app.listVolunteers(search, deptIDs, since) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } + if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden) + return + } } v := Volunteer{ ID: id, @@ -157,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.deleteVolunteer(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -171,6 +182,13 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) return } claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } v, err := app.markVolunteerReady(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -186,6 +204,14 @@ func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } v, err := app.confirmVolunteer(id) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -207,7 +233,24 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "shift_id required", http.StatusBadRequest) return } - if err := app.assignShift(volunteerID, body.ShiftID); err != nil { + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(volunteerID) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + shift, err := app.getShift(body.ShiftID) + if err != nil || shift == nil { + writeError(w, "shift not found", http.StatusNotFound) + return + } + if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil { + if err == errShiftFull { + writeError(w, "shift is at capacity", http.StatusConflict) + return + } writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -225,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid shift id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(volunteerID) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -232,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func inSlice(v int, s []int) bool { - for _, x := range s { - if x == v { - return true - } - } - return false -} diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index 19ff1b0..dc61f28 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -79,6 +79,130 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { } } +func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptAID := deptA.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadReadyVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadAssignShiftOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{ + "shift_id": s.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptAID := deptA.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "department_id": deptB.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String()) + } +} + func TestUpdateVolunteerDepartment(t *testing.T) { app := testApp(t) mux := testMux(app)