Rescoped colead role and revised session handling.

This commit is contained in:
Pen Anderson 2026-03-10 15:14:36 -05:00
parent da5f3524fa
commit 7dbcd05262
12 changed files with 376 additions and 50 deletions

14
auth.go
View file

@ -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

31
db.go
View file

@ -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) + "?"
}

View file

@ -84,7 +84,6 @@
const path = $derived(route || '/')
const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
</script>
{#if updateAvailable}
@ -125,7 +124,7 @@
{#if roles.length === 1 && roles[0] === 'colead'}
<ScheduleBoard {session} />
{:else}
<Dashboard {session} />
<Dashboard {session} {navigate} />
{/if}
{:else if path.startsWith('/participants')}
<Participants {session} />

View file

@ -80,6 +80,18 @@ export async function saveSession(token, user) {
}
export async function clearSession() {
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()
}
)
}

View file

@ -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 @@
<!-- Quick actions -->
{#if isAdmin}
<div class="dash-actions">
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
<a href="/settings" class="btn btn-ghost btn-sm">Settings</a>
<a href="/import" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/import') }}>Import CSV</a>
<a href="/participants" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/participants') }}>Manage Participants</a>
<a href="/settings" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/settings') }}>Settings</a>
</div>
{:else if isStaffing || isColead}
<div class="dash-actions">
<a href="/schedule" class="btn btn-ghost btn-sm">View Schedule</a>
<a href="/volunteers" class="btn btn-ghost btn-sm">Manage Volunteers</a>
<a href="/schedule" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/schedule') }}>View Schedule</a>
<a href="/volunteers" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/volunteers') }}>Manage Volunteers</a>
</div>
{/if}

View file

@ -135,11 +135,13 @@
try {
const res = await api.shifts.reorder(positions)
if (res && !res.ok) throw new Error()
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
}

View file

@ -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 {}
}

View file

@ -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

View file

@ -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}

View file

@ -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)

View file

@ -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
}

View file

@ -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)