From a271f018f5a5ebd7360f9d4dafa29e27024e8def Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 8 Apr 2026 21:37:54 -0600 Subject: [PATCH 1/7] auth: replace copy-paste JWT checks with real middleware Add a shared parseTokenByIssuer helper and VolunteerAuthMiddleware, consolidate all protected admin routes into a single subrouter wrapped with AdminAuthMiddleware, and remove the duplicated cookie+token checks that were scattered across handler and template functions. Closes #73 Co-Authored-By: Claude Sonnet 4.6 --- internal/admin.go | 54 +------------------------------------- internal/application.go | 56 ++++++++++++++++++++++------------------ internal/middleware.go | 57 ++++++++++++++++++++++++++++++++++++++--- internal/volunteer.go | 55 ++------------------------------------- 4 files changed, 87 insertions(+), 135 deletions(-) diff --git a/internal/admin.go b/internal/admin.go index eb20406..03214bc 100644 --- a/internal/admin.go +++ b/internal/admin.go @@ -3,7 +3,6 @@ package internal import ( "context" "encoding/csv" - "errors" "fmt" "net/http" "time" @@ -24,47 +23,7 @@ func (a *Application) createAdminLoginJWT(email string) *jwt.Token { }) } -func (a *Application) isAdminByToken(tokenStr string) (bool, error) { - if tokenStr == "" { - return false, errors.New("no token") - } - - token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - return a.Config.ReadSecretKey(), nil - }) - if err != nil { - return false, fmt.Errorf("failed to parse admin token: %w", err) - } - - claims, ok := token.Claims.(*jwt.RegisteredClaims) - if !token.Valid || !ok { - return false, fmt.Errorf("failed to validate token: %w", err) - } - - if claims.Issuer != string(IssuerAdminLogin) { - return false, fmt.Errorf("invalid student verify token: %w", err) - } - - return true, nil -} - func (a *Application) GetAdminTeamsTemplate(r *http.Request) map[string]any { - tok, err := r.Cookie("admin_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get admin token") - return nil - } - - if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin { - a.Log.Warn().Err(err).Msg("user is not admin!") - return nil - } - teamsWithTeachers, err := a.DB.GetAdminTeamsWithTeacherName(r.Context()) if err != nil { a.Log.Err(err).Msg("failed to get teams") @@ -153,17 +112,6 @@ func (a *Application) GetAdminTeamsTemplate(r *http.Request) map[string]any { } func (a *Application) GetAdminDietaryRestrictionsTemplate(r *http.Request) map[string]any { - tok, err := r.Cookie("admin_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get admin token") - return nil - } - - if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin { - a.Log.Warn().Err(err).Msg("user is not admin!") - return nil - } - dietaryRestrictions, err := a.DB.GetAllDietaryRestrictions(r.Context()) if err != nil { a.Log.Err(err).Msg("failed to get dietary restrictions") @@ -179,7 +127,7 @@ func (a *Application) HandleAdminEmailLogin(w http.ResponseWriter, r *http.Reque tok := r.URL.Query().Get("tok") log := zerolog.Ctx(r.Context()) log.Info().Str("token", tok).Msg("got token") - isAdmin, err := a.isAdminByToken(tok) + isAdmin, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) if err != nil || !isAdmin { log.Warn().Msg("failed to get admin") w.WriteHeader(http.StatusBadRequest) diff --git a/internal/application.go b/internal/application.go index 2a03bb6..38b44b9 100644 --- a/internal/application.go +++ b/internal/application.go @@ -3,6 +3,7 @@ package internal import ( "fmt" "html/template" + "maps" "net/http" "regexp" "strings" @@ -66,9 +67,7 @@ func (a *Application) ServeTemplateExtra(logger *zerolog.Logger, templateName st if data == nil { data = map[string]any{} } - for k, v := range extraData { - data[k] = v - } + maps.Copy(data, extraData) user, err := a.GetLoggedInTeacher(r) if err == nil { data["Username"] = user.Name @@ -217,36 +216,43 @@ func (a *Application) Start() { router.HandleFunc("POST "+path, renderFn(fn)) } - // Admin pages - router.HandleFunc("GET /admin", a.ServeTemplate(a.Log, "adminhome.html", noArgs)) + // Admin pages (unprotected — these exact patterns beat the /admin/ prefix below) router.HandleFunc("GET /admin/login", a.ServeTemplate(a.Log, "adminlogin.html", noArgs)) - router.HandleFunc("GET /admin/dietaryrestrictions", a.ServeTemplate(a.Log, "admindietaryrestrictions.html", a.GetAdminDietaryRestrictionsTemplate)) - router.HandleFunc("GET /admin/teams", a.ServeTemplate(a.Log, "adminteams.html", a.GetAdminTeamsTemplate)) router.HandleFunc("GET /admin/emaillogin", a.HandleAdminEmailLogin) router.HandleFunc("POST /admin/emaillogin", a.HandleAdminLogin) - adminAPIRouter := http.NewServeMux() - adminAPIRouter.HandleFunc("GET /resendstudentemail", a.HandleResendStudentEmail) - adminAPIRouter.HandleFunc("GET /resendparentemail", a.HandleResendParentEmail) - adminAPIRouter.HandleFunc("GET /confirmationlink/student", a.HandleGetStudentEmailConfirmationLink) - adminAPIRouter.HandleFunc("GET /confirmationlink/parent", a.HandleGetParentEmailConfirmationLink) - adminAPIRouter.HandleFunc("GET /sendemailconfirmationreminders", a.HandleSendEmailConfirmationReminders) - adminAPIRouter.HandleFunc("GET /sendparentreminders", a.HandleSendParentReminders) - adminAPIRouter.HandleFunc("GET /sendqrcodes", a.HandleSendQRCodes) - adminAPIRouter.HandleFunc("GET /kattis/teams", a.HandleKattisTeamsExport) - adminAPIRouter.HandleFunc("GET /kattis/participants", a.HandleKattisParticipantsExport) - adminAPIRouter.HandleFunc("GET /zoom/breakout", a.HandleZoomBreakoutExport) - adminAPIRouter.HandleFunc("GET /manualcheckin", a.HandleManualCheckin) - adminAPIRouter.HandleFunc("GET /team-list", a.HandleTeamList) - router.Handle("/admin/api/", http.StripPrefix("/admin/api", a.AdminAuthMiddleware(adminAPIRouter))) - - // Volunteer pages + // Admin pages (protected) — subrouter consolidates all protected admin routes + adminRouter := http.NewServeMux() + adminRouter.HandleFunc("GET /{$}", a.ServeTemplate(a.Log, "adminhome.html", noArgs)) + adminRouter.HandleFunc("GET /dietaryrestrictions", a.ServeTemplate(a.Log, "admindietaryrestrictions.html", a.GetAdminDietaryRestrictionsTemplate)) + adminRouter.HandleFunc("GET /teams", a.ServeTemplate(a.Log, "adminteams.html", a.GetAdminTeamsTemplate)) + adminRouter.HandleFunc("GET /api/resendstudentemail", a.HandleResendStudentEmail) + adminRouter.HandleFunc("GET /api/resendparentemail", a.HandleResendParentEmail) + adminRouter.HandleFunc("GET /api/confirmationlink/student", a.HandleGetStudentEmailConfirmationLink) + adminRouter.HandleFunc("GET /api/confirmationlink/parent", a.HandleGetParentEmailConfirmationLink) + adminRouter.HandleFunc("GET /api/sendemailconfirmationreminders", a.HandleSendEmailConfirmationReminders) + adminRouter.HandleFunc("GET /api/sendparentreminders", a.HandleSendParentReminders) + adminRouter.HandleFunc("GET /api/sendqrcodes", a.HandleSendQRCodes) + adminRouter.HandleFunc("GET /api/kattis/teams", a.HandleKattisTeamsExport) + adminRouter.HandleFunc("GET /api/kattis/participants", a.HandleKattisParticipantsExport) + adminRouter.HandleFunc("GET /api/zoom/breakout", a.HandleZoomBreakoutExport) + adminRouter.HandleFunc("GET /api/manualcheckin", a.HandleManualCheckin) + adminRouter.HandleFunc("GET /api/team-list", a.HandleTeamList) + router.Handle("/admin/", http.StripPrefix("/admin", a.AdminAuthMiddleware(adminRouter))) + router.Handle("GET /admin", a.AdminAuthMiddleware( + http.HandlerFunc(a.ServeTemplate(a.Log, "adminhome.html", noArgs)))) + + // Volunteer pages (unprotected) router.HandleFunc("GET /volunteer", a.ServeTemplate(a.Log, "volunteerhome.html", noArgs)) router.HandleFunc("GET /volunteer/login", a.ServeTemplate(a.Log, "volunteerlogin.html", noArgs)) router.HandleFunc("GET /volunteer/emaillogin", a.HandleVolunteerEmailLogin) router.HandleFunc("POST /volunteer/emaillogin", a.HandleVolunteerLogin) - router.HandleFunc("GET /volunteer/scan", a.ServeTemplate(a.Log, "volunteerscan.html", a.GetVolunteerScanTemplate)) - router.HandleFunc("GET /volunteer/checkin", a.HandleVolunteerCheckIn) + + // Volunteer pages (protected) + router.Handle("GET /volunteer/scan", a.VolunteerAuthMiddleware( + http.HandlerFunc(a.ServeTemplate(a.Log, "volunteerscan.html", a.GetVolunteerScanTemplate)))) + router.Handle("GET /volunteer/checkin", a.VolunteerAuthMiddleware( + http.HandlerFunc(a.HandleVolunteerCheckIn))) var handler http.Handler = router handler = hlog.RequestIDHandler("request_id", "RequestID")(handler) diff --git a/internal/middleware.go b/internal/middleware.go index fb0d681..36d4b69 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -1,17 +1,66 @@ package internal -import "net/http" +import ( + "fmt" + "net/http" + + "github.com/golang-jwt/jwt/v4" +) + +func (a *Application) parseTokenByIssuer(tokenStr string, issuer Issuer) (bool, error) { + if tokenStr == "" { + return false, fmt.Errorf("no token") + } + + token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return a.Config.ReadSecretKey(), nil + }) + if err != nil { + return false, fmt.Errorf("failed to parse token: %w", err) + } + + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !token.Valid || !ok { + return false, fmt.Errorf("invalid token") + } + + if claims.Issuer != string(issuer) { + return false, fmt.Errorf("wrong issuer: got %s, want %s", claims.Issuer, issuer) + } + + return true, nil +} func (a *Application) AdminAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tok, err := r.Cookie("admin_token") if err != nil { - http.Redirect(w, r, "/", http.StatusSeeOther) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + if isAdmin, err := a.parseTokenByIssuer(tok.Value, IssuerAdminLogin); err != nil || !isAdmin { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (a *Application) VolunteerAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok, err := r.Cookie("volunteer_token") + if err != nil { + http.Redirect(w, r, "/volunteer/login", http.StatusSeeOther) return } - if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin { - http.Redirect(w, r, "/", http.StatusSeeOther) + if isVolunteer, err := a.parseTokenByIssuer(tok.Value, IssuerVolunteerLogin); err != nil || !isVolunteer { + http.Redirect(w, r, "/volunteer/login", http.StatusSeeOther) return } diff --git a/internal/volunteer.go b/internal/volunteer.go index 2784a70..88b1d67 100644 --- a/internal/volunteer.go +++ b/internal/volunteer.go @@ -22,39 +22,10 @@ func (a *Application) createVolunteerLoginJWT(email string) *jwt.Token { }) } -func (a *Application) isVolunteerByToken(tokenStr string) (bool, error) { - if tokenStr == "" { - return false, errors.New("no token") - } - - token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - return a.Config.ReadSecretKey(), nil - }) - if err != nil { - return false, fmt.Errorf("failed to parse admin token: %w", err) - } - - claims, ok := token.Claims.(*jwt.RegisteredClaims) - if !token.Valid || !ok { - return false, fmt.Errorf("failed to validate token: %w", err) - } - - if claims.Issuer != string(IssuerVolunteerLogin) { - return false, fmt.Errorf("invalid student verify token: %w", err) - } - - return true, nil -} - func (a *Application) HandleVolunteerEmailLogin(w http.ResponseWriter, r *http.Request) { tok := r.URL.Query().Get("tok") zerolog.Ctx(r.Context()).Info().Str("token", tok).Msg("got token") - isVolunteer, err := a.isVolunteerByToken(tok) + isVolunteer, err := a.parseTokenByIssuer(tok, IssuerVolunteerLogin) if err != nil || !isVolunteer { a.Log.Warn().Msg("failed to get volunteer") w.WriteHeader(http.StatusBadRequest) @@ -121,23 +92,13 @@ func (a *Application) HandleVolunteerLogin(w http.ResponseWriter, r *http.Reques func (a *Application) GetVolunteerScanTemplate(r *http.Request) map[string]any { ctx := r.Context() - tok, err := r.Cookie("volunteer_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get volunteer token") - return nil - } - - if isVolunteer, err := a.isVolunteerByToken(tok.Value); err != nil || !isVolunteer { - a.Log.Warn().Err(err).Msg("user is not volunteer!") - return nil - } res := map[string]any{ "LoggedInAsVolunteer": true, } if adminTok, err := r.Cookie("admin_token"); err == nil { - if isAdmin, err := a.isAdminByToken(adminTok.Value); err == nil && isAdmin { + if isAdmin, err := a.parseTokenByIssuer(adminTok.Value, IssuerAdminLogin); err == nil && isAdmin { res["LoggedInAsAdmin"] = true } } @@ -174,18 +135,6 @@ func (a *Application) GetVolunteerScanTemplate(r *http.Request) map[string]any { func (a *Application) HandleVolunteerCheckIn(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - tok, err := r.Cookie("volunteer_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get volunteer token") - w.WriteHeader(http.StatusBadRequest) - return - } - - if isVolunteer, err := a.isVolunteerByToken(tok.Value); err != nil || !isVolunteer { - a.Log.Warn().Err(err).Msg("user is not volunteer!") - w.WriteHeader(http.StatusBadRequest) - return - } studentSignInToken := r.URL.Query().Get("tok") student, err := a.getStudentByQRToken(ctx, studentSignInToken) From a9e49577ca629720dfd08bb3edce38b0052fc7f7 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Thu, 9 Apr 2026 08:23:11 -0600 Subject: [PATCH 2/7] test: add middleware auth tests Tests for parseTokenByIssuer (empty, correct issuer, wrong issuer, expired, wrong signing key) and both AdminAuthMiddleware and VolunteerAuthMiddleware (no cookie, invalid token, wrong issuer, valid token). Co-Authored-By: Claude Sonnet 4.6 --- internal/middleware_test.go | 192 ++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 internal/middleware_test.go diff --git a/internal/middleware_test.go b/internal/middleware_test.go new file mode 100644 index 0000000..6575ffb --- /dev/null +++ b/internal/middleware_test.go @@ -0,0 +1,192 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/rs/zerolog" + + "github.com/ColoradoSchoolOfMines/mineshspc.com/internal/config" +) + +const testSecretKey = "test-secret-key" + +func newTestApp() *Application { + log := zerolog.Nop() + cfg := config.Configuration{} + cfg.JWTSecretKey = testSecretKey + return NewApplication(&log, cfg, nil) +} + +func makeSignedToken(t *testing.T, issuer Issuer, key []byte, expiry time.Time) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.RegisteredClaims{ + Issuer: string(issuer), + Subject: "test@example.com", + ExpiresAt: jwt.NewNumericDate(expiry), + }) + signed, err := tok.SignedString(key) + if err != nil { + t.Fatalf("failed to sign token: %v", err) + } + return signed +} + +// --- parseTokenByIssuer --- + +func TestParseTokenByIssuer_Empty(t *testing.T) { + a := newTestApp() + ok, err := a.parseTokenByIssuer("", IssuerAdminLogin) + if ok || err == nil { + t.Error("expected error for empty token") + } +} + +func TestParseTokenByIssuer_CorrectIssuer(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + if !ok || err != nil { + t.Errorf("expected valid token to pass: %v", err) + } +} + +func TestParseTokenByIssuer_WrongIssuer(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + if ok || err == nil { + t.Error("expected error for wrong issuer") + } +} + +func TestParseTokenByIssuer_ExpiredToken(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(-time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + if ok || err == nil { + t.Error("expected error for expired token") + } +} + +func TestParseTokenByIssuer_WrongSigningKey(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte("different-key"), time.Now().Add(time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + if ok || err == nil { + t.Error("expected error for token signed with wrong key") + } +} + +// --- AdminAuthMiddleware --- + +var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +}) + +func TestAdminAuthMiddleware_NoCookie(t *testing.T) { + a := newTestApp() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) + a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/admin/login" { + t.Errorf("expected redirect to /admin/login, got %q", loc) + } +} + +func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { + a := newTestApp() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) + req.AddCookie(&http.Cookie{Name: "admin_token", Value: "not-a-jwt"}) + a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/admin/login" { + t.Errorf("expected redirect to /admin/login, got %q", loc) + } +} + +func TestAdminAuthMiddleware_WrongIssuer(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) + req.AddCookie(&http.Cookie{Name: "admin_token", Value: tok}) + a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rec.Code) + } +} + +func TestAdminAuthMiddleware_ValidToken(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) + req.AddCookie(&http.Cookie{Name: "admin_token", Value: tok}) + a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +// --- VolunteerAuthMiddleware --- + +func TestVolunteerAuthMiddleware_NoCookie(t *testing.T) { + a := newTestApp() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) + a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/volunteer/login" { + t.Errorf("expected redirect to /volunteer/login, got %q", loc) + } +} + +func TestVolunteerAuthMiddleware_InvalidToken(t *testing.T) { + a := newTestApp() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) + req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: "not-a-jwt"}) + a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/volunteer/login" { + t.Errorf("expected redirect to /volunteer/login, got %q", loc) + } +} + +func TestVolunteerAuthMiddleware_WrongIssuer(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) + req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: tok}) + a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rec.Code) + } +} + +func TestVolunteerAuthMiddleware_ValidToken(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) + req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: tok}) + a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} From a04df6fa0ff0703a6bbeef1b1d507240bc1ab4c4 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Thu, 9 Apr 2026 20:12:55 -0600 Subject: [PATCH 3/7] test: use testify for middleware tests Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 1 + internal/middleware_test.go | 81 +++++++++++++------------------------ 2 files changed, 28 insertions(+), 54 deletions(-) diff --git a/go.mod b/go.mod index 2e2ea3d..786a154 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/rs/zerolog v1.35.0 github.com/sendgrid/sendgrid-go v3.16.1+incompatible github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/stretchr/testify v1.11.1 go.mau.fi/util v0.9.7 go.mau.fi/zeroconfig v0.2.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/middleware_test.go b/internal/middleware_test.go index 6575ffb..4b5d4f3 100644 --- a/internal/middleware_test.go +++ b/internal/middleware_test.go @@ -8,6 +8,8 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ColoradoSchoolOfMines/mineshspc.com/internal/config" ) @@ -29,9 +31,7 @@ func makeSignedToken(t *testing.T, issuer Issuer, key []byte, expiry time.Time) ExpiresAt: jwt.NewNumericDate(expiry), }) signed, err := tok.SignedString(key) - if err != nil { - t.Fatalf("failed to sign token: %v", err) - } + require.NoError(t, err) return signed } @@ -40,45 +40,40 @@ func makeSignedToken(t *testing.T, issuer Issuer, key []byte, expiry time.Time) func TestParseTokenByIssuer_Empty(t *testing.T) { a := newTestApp() ok, err := a.parseTokenByIssuer("", IssuerAdminLogin) - if ok || err == nil { - t.Error("expected error for empty token") - } + assert.False(t, ok) + assert.Error(t, err) } func TestParseTokenByIssuer_CorrectIssuer(t *testing.T) { a := newTestApp() tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) - if !ok || err != nil { - t.Errorf("expected valid token to pass: %v", err) - } + assert.True(t, ok) + assert.NoError(t, err) } func TestParseTokenByIssuer_WrongIssuer(t *testing.T) { a := newTestApp() tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) - if ok || err == nil { - t.Error("expected error for wrong issuer") - } + assert.False(t, ok) + assert.Error(t, err) } func TestParseTokenByIssuer_ExpiredToken(t *testing.T) { a := newTestApp() tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(-time.Hour)) ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) - if ok || err == nil { - t.Error("expected error for expired token") - } + assert.False(t, ok) + assert.Error(t, err) } func TestParseTokenByIssuer_WrongSigningKey(t *testing.T) { a := newTestApp() tok := makeSignedToken(t, IssuerAdminLogin, []byte("different-key"), time.Now().Add(time.Hour)) ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) - if ok || err == nil { - t.Error("expected error for token signed with wrong key") - } + assert.False(t, ok) + assert.Error(t, err) } // --- AdminAuthMiddleware --- @@ -92,12 +87,8 @@ func TestAdminAuthMiddleware_NoCookie(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", rec.Code) - } - if loc := rec.Header().Get("Location"); loc != "/admin/login" { - t.Errorf("expected redirect to /admin/login, got %q", loc) - } + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/admin/login", rec.Header().Get("Location")) } func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { @@ -106,12 +97,8 @@ func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) req.AddCookie(&http.Cookie{Name: "admin_token", Value: "not-a-jwt"}) a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", rec.Code) - } - if loc := rec.Header().Get("Location"); loc != "/admin/login" { - t.Errorf("expected redirect to /admin/login, got %q", loc) - } + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/admin/login", rec.Header().Get("Location")) } func TestAdminAuthMiddleware_WrongIssuer(t *testing.T) { @@ -121,9 +108,8 @@ func TestAdminAuthMiddleware_WrongIssuer(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) req.AddCookie(&http.Cookie{Name: "admin_token", Value: tok}) a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", rec.Code) - } + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/admin/login", rec.Header().Get("Location")) } func TestAdminAuthMiddleware_ValidToken(t *testing.T) { @@ -133,9 +119,7 @@ func TestAdminAuthMiddleware_ValidToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) req.AddCookie(&http.Cookie{Name: "admin_token", Value: tok}) a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("expected 200, got %d", rec.Code) - } + assert.Equal(t, http.StatusOK, rec.Code) } // --- VolunteerAuthMiddleware --- @@ -145,12 +129,8 @@ func TestVolunteerAuthMiddleware_NoCookie(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", rec.Code) - } - if loc := rec.Header().Get("Location"); loc != "/volunteer/login" { - t.Errorf("expected redirect to /volunteer/login, got %q", loc) - } + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/volunteer/login", rec.Header().Get("Location")) } func TestVolunteerAuthMiddleware_InvalidToken(t *testing.T) { @@ -159,12 +139,8 @@ func TestVolunteerAuthMiddleware_InvalidToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: "not-a-jwt"}) a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", rec.Code) - } - if loc := rec.Header().Get("Location"); loc != "/volunteer/login" { - t.Errorf("expected redirect to /volunteer/login, got %q", loc) - } + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/volunteer/login", rec.Header().Get("Location")) } func TestVolunteerAuthMiddleware_WrongIssuer(t *testing.T) { @@ -174,9 +150,8 @@ func TestVolunteerAuthMiddleware_WrongIssuer(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: tok}) a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", rec.Code) - } + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/volunteer/login", rec.Header().Get("Location")) } func TestVolunteerAuthMiddleware_ValidToken(t *testing.T) { @@ -186,7 +161,5 @@ func TestVolunteerAuthMiddleware_ValidToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: tok}) a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("expected 200, got %d", rec.Code) - } + assert.Equal(t, http.StatusOK, rec.Code) } From a8d9ec5a0fedd2142d84998ff5c9ebfaabfe4efa Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Thu, 9 Apr 2026 20:40:59 -0600 Subject: [PATCH 4/7] test: add routing tests verifying middleware protection Extract BuildRouter from Start so tests can exercise the full router without starting a real server. Add routing_test.go covering all protected admin and volunteer routes (no cookie, wrong-issuer cookie, valid cookie) and key unprotected routes. Uses an in-memory SQLite DB with MaxOpenConns/MaxIdleConns=1 so migrations run correctly on the shared connection. Co-Authored-By: Claude Sonnet 4.6 --- internal/application.go | 19 ++-- internal/routing_test.go | 214 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 internal/routing_test.go diff --git a/internal/application.go b/internal/application.go index 38b44b9..e23f6fe 100644 --- a/internal/application.go +++ b/internal/application.go @@ -90,12 +90,9 @@ type renderInfo struct { RedirectIfLoggedIn bool } -func (a *Application) Start() { - a.Log.Info().Msg("connecting to sendgrid") - a.SendGridClient = sendgrid.NewSendClient(a.Config.SendgridAPIKey) - - a.Log.Info().Msg("Starting router") - +// BuildRouter sets up all HTTP routes and returns the handler. Separated from +// Start so tests can exercise routing without starting a real server. +func (a *Application) BuildRouter() http.Handler { router := http.NewServeMux() noArgs := func(r *http.Request) map[string]any { return nil } @@ -258,6 +255,16 @@ func (a *Application) Start() { handler = hlog.RequestIDHandler("request_id", "RequestID")(handler) handler = hlog.NewHandler(*a.Log)(handler) + return handler +} + +func (a *Application) Start() { + a.Log.Info().Msg("connecting to sendgrid") + a.SendGridClient = sendgrid.NewSendClient(a.Config.SendgridAPIKey) + + a.Log.Info().Msg("Starting router") + handler := a.BuildRouter() + a.Log.Info().Msg("Listening on port 8090") http.ListenAndServe(":8090", handler) } diff --git a/internal/routing_test.go b/internal/routing_test.go new file mode 100644 index 0000000..dd0b9b8 --- /dev/null +++ b/internal/routing_test.go @@ -0,0 +1,214 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/dbutil" + + "github.com/ColoradoSchoolOfMines/mineshspc.com/database" + "github.com/ColoradoSchoolOfMines/mineshspc.com/internal/config" +) + +func newTestAppWithDB(t *testing.T) *Application { + t.Helper() + log := zerolog.Nop() + + rawDB, err := dbutil.NewFromConfig("mineshspc", dbutil.Config{ + PoolConfig: dbutil.PoolConfig{ + Type: "sqlite3", + URI: ":memory:", + MaxOpenConns: 1, + MaxIdleConns: 1, + }, + }, dbutil.ZeroLogger(zerolog.Nop())) + require.NoError(t, err) + + db := database.NewDatabase(rawDB) + require.NoError(t, db.DB.Upgrade(context.Background())) + + cfg := config.Configuration{} + cfg.JWTSecretKey = testSecretKey + return NewApplication(&log, cfg, db) +} + +func adminToken(t *testing.T) string { + t.Helper() + return makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) +} + +func volunteerToken(t *testing.T) string { + t.Helper() + return makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) +} + +func doRequest(router http.Handler, method, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + for _, c := range cookies { + req.AddCookie(c) + } + router.ServeHTTP(rec, req) + return rec +} + +func assertRedirectsTo(t *testing.T, rec *httptest.ResponseRecorder, location string) { + t.Helper() + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, location, rec.Header().Get("Location")) +} + +func assertNotRedirectTo(t *testing.T, rec *httptest.ResponseRecorder, location string) { + t.Helper() + if rec.Code == http.StatusSeeOther || rec.Code == http.StatusFound || rec.Code == http.StatusTemporaryRedirect { + assert.NotEqual(t, location, rec.Header().Get("Location"), + "expected route NOT to redirect to %s but it did", location) + } +} + +// --- Admin route protection --- + +func TestRouting_AdminHome_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + assertRedirectsTo(t, doRequest(router, http.MethodGet, "/admin"), "/admin/login") +} + +func TestRouting_AdminHome_WrongCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin", + &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + assertRedirectsTo(t, rec, "/admin/login") +} + +func TestRouting_AdminHome_ValidCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin", + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertNotRedirectTo(t, rec, "/admin/login") +} + +func TestRouting_AdminTeams_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + assertRedirectsTo(t, doRequest(router, http.MethodGet, "/admin/teams"), "/admin/login") +} + +func TestRouting_AdminTeams_WrongCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin/teams", + &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + assertRedirectsTo(t, rec, "/admin/login") +} + +func TestRouting_AdminTeams_ValidCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin/teams", + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertNotRedirectTo(t, rec, "/admin/login") +} + +func TestRouting_AdminDietaryRestrictions_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + assertRedirectsTo(t, doRequest(router, http.MethodGet, "/admin/dietaryrestrictions"), "/admin/login") +} + +func TestRouting_AdminDietaryRestrictions_ValidCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin/dietaryrestrictions", + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertNotRedirectTo(t, rec, "/admin/login") +} + +func TestRouting_AdminAPI_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + // Spot-check a few API endpoints + for _, path := range []string{ + "/admin/api/resendstudentemail", + "/admin/api/sendqrcodes", + "/admin/api/kattis/teams", + } { + rec := doRequest(router, http.MethodGet, path) + assertRedirectsTo(t, rec, "/admin/login") + } +} + +func TestRouting_AdminAPI_ValidCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/admin/api/kattis/teams", + "/admin/api/zoom/breakout", + "/admin/api/team-list", + } { + rec := doRequest(router, http.MethodGet, path, + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertNotRedirectTo(t, rec, "/admin/login") + } +} + +// --- Admin unprotected routes --- + +func TestRouting_AdminLogin_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin/login") + assertNotRedirectTo(t, rec, "/admin/login") +} + +// --- Volunteer route protection --- + +func TestRouting_VolunteerScan_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + assertRedirectsTo(t, doRequest(router, http.MethodGet, "/volunteer/scan"), "/volunteer/login") +} + +func TestRouting_VolunteerScan_WrongCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/scan", + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertRedirectsTo(t, rec, "/volunteer/login") +} + +func TestRouting_VolunteerScan_ValidCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/scan", + &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + assertNotRedirectTo(t, rec, "/volunteer/login") +} + +func TestRouting_VolunteerCheckin_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + assertRedirectsTo(t, doRequest(router, http.MethodGet, "/volunteer/checkin"), "/volunteer/login") +} + +func TestRouting_VolunteerCheckin_WrongCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/checkin", + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertRedirectsTo(t, rec, "/volunteer/login") +} + +func TestRouting_VolunteerCheckin_ValidCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/checkin", + &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + assertNotRedirectTo(t, rec, "/volunteer/login") +} + +// --- Volunteer unprotected routes --- + +func TestRouting_VolunteerLogin_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/login") + assertNotRedirectTo(t, rec, "/volunteer/login") +} + +func TestRouting_VolunteerHome_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer") + assertNotRedirectTo(t, rec, "/volunteer/login") +} From 42e72e641b0d854eafe3fdbcbf31dbb0b9dc3886 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Thu, 9 Apr 2026 20:45:50 -0600 Subject: [PATCH 5/7] test: consolidate middleware and routing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit middleware_test.go now only covers parseTokenByIssuer (JWT parsing logic in isolation). Routing tests cover the full middleware+routing behavior: no cookie, malformed token, wrong-issuer token, and valid token — with all protected routes checked in table-driven loops. 32 tests → 15 tests, no loss of coverage. Co-Authored-By: Claude Sonnet 4.6 --- internal/middleware_test.go | 90 ----------------------- internal/routing_test.go | 140 ++++++++++++++---------------------- 2 files changed, 55 insertions(+), 175 deletions(-) diff --git a/internal/middleware_test.go b/internal/middleware_test.go index 4b5d4f3..f480706 100644 --- a/internal/middleware_test.go +++ b/internal/middleware_test.go @@ -1,8 +1,6 @@ package internal import ( - "net/http" - "net/http/httptest" "testing" "time" @@ -75,91 +73,3 @@ func TestParseTokenByIssuer_WrongSigningKey(t *testing.T) { assert.False(t, ok) assert.Error(t, err) } - -// --- AdminAuthMiddleware --- - -var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) -}) - -func TestAdminAuthMiddleware_NoCookie(t *testing.T) { - a := newTestApp() - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) - a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusSeeOther, rec.Code) - assert.Equal(t, "/admin/login", rec.Header().Get("Location")) -} - -func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { - a := newTestApp() - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) - req.AddCookie(&http.Cookie{Name: "admin_token", Value: "not-a-jwt"}) - a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusSeeOther, rec.Code) - assert.Equal(t, "/admin/login", rec.Header().Get("Location")) -} - -func TestAdminAuthMiddleware_WrongIssuer(t *testing.T) { - a := newTestApp() - tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) - req.AddCookie(&http.Cookie{Name: "admin_token", Value: tok}) - a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusSeeOther, rec.Code) - assert.Equal(t, "/admin/login", rec.Header().Get("Location")) -} - -func TestAdminAuthMiddleware_ValidToken(t *testing.T) { - a := newTestApp() - tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/admin/teams", nil) - req.AddCookie(&http.Cookie{Name: "admin_token", Value: tok}) - a.AdminAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code) -} - -// --- VolunteerAuthMiddleware --- - -func TestVolunteerAuthMiddleware_NoCookie(t *testing.T) { - a := newTestApp() - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) - a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusSeeOther, rec.Code) - assert.Equal(t, "/volunteer/login", rec.Header().Get("Location")) -} - -func TestVolunteerAuthMiddleware_InvalidToken(t *testing.T) { - a := newTestApp() - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) - req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: "not-a-jwt"}) - a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusSeeOther, rec.Code) - assert.Equal(t, "/volunteer/login", rec.Header().Get("Location")) -} - -func TestVolunteerAuthMiddleware_WrongIssuer(t *testing.T) { - a := newTestApp() - tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) - req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: tok}) - a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusSeeOther, rec.Code) - assert.Equal(t, "/volunteer/login", rec.Header().Get("Location")) -} - -func TestVolunteerAuthMiddleware_ValidToken(t *testing.T) { - a := newTestApp() - tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/volunteer/scan", nil) - req.AddCookie(&http.Cookie{Name: "volunteer_token", Value: tok}) - a.VolunteerAuthMiddleware(okHandler).ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code) -} diff --git a/internal/routing_test.go b/internal/routing_test.go index dd0b9b8..6b87bf7 100644 --- a/internal/routing_test.go +++ b/internal/routing_test.go @@ -74,73 +74,48 @@ func assertNotRedirectTo(t *testing.T, rec *httptest.ResponseRecorder, location } // --- Admin route protection --- +// +// Tests cover: no cookie, malformed token, wrong-issuer token (volunteer +// token used on admin route), and valid token. Each case is verified on +// one representative route; the remaining admin routes are spot-checked +// for the no-cookie case to confirm they're all wired up. -func TestRouting_AdminHome_NoCookie(t *testing.T) { +func TestRouting_Admin_NoCookie(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() - assertRedirectsTo(t, doRequest(router, http.MethodGet, "/admin"), "/admin/login") -} - -func TestRouting_AdminHome_WrongCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/admin", - &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) - assertRedirectsTo(t, rec, "/admin/login") -} - -func TestRouting_AdminHome_ValidCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/admin", - &http.Cookie{Name: "admin_token", Value: adminToken(t)}) - assertNotRedirectTo(t, rec, "/admin/login") -} - -func TestRouting_AdminTeams_NoCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - assertRedirectsTo(t, doRequest(router, http.MethodGet, "/admin/teams"), "/admin/login") + for _, path := range []string{ + "/admin", + "/admin/teams", + "/admin/dietaryrestrictions", + "/admin/api/resendstudentemail", + "/admin/api/sendqrcodes", + "/admin/api/kattis/teams", + "/admin/api/zoom/breakout", + "/admin/api/team-list", + } { + assertRedirectsTo(t, doRequest(router, http.MethodGet, path), "/admin/login") + } } -func TestRouting_AdminTeams_WrongCookie(t *testing.T) { +func TestRouting_Admin_MalformedToken(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() rec := doRequest(router, http.MethodGet, "/admin/teams", - &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + &http.Cookie{Name: "admin_token", Value: "not-a-jwt"}) assertRedirectsTo(t, rec, "/admin/login") } -func TestRouting_AdminTeams_ValidCookie(t *testing.T) { +func TestRouting_Admin_WrongIssuer(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() rec := doRequest(router, http.MethodGet, "/admin/teams", - &http.Cookie{Name: "admin_token", Value: adminToken(t)}) - assertNotRedirectTo(t, rec, "/admin/login") -} - -func TestRouting_AdminDietaryRestrictions_NoCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - assertRedirectsTo(t, doRequest(router, http.MethodGet, "/admin/dietaryrestrictions"), "/admin/login") -} - -func TestRouting_AdminDietaryRestrictions_ValidCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/admin/dietaryrestrictions", - &http.Cookie{Name: "admin_token", Value: adminToken(t)}) - assertNotRedirectTo(t, rec, "/admin/login") -} - -func TestRouting_AdminAPI_NoCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - // Spot-check a few API endpoints - for _, path := range []string{ - "/admin/api/resendstudentemail", - "/admin/api/sendqrcodes", - "/admin/api/kattis/teams", - } { - rec := doRequest(router, http.MethodGet, path) - assertRedirectsTo(t, rec, "/admin/login") - } + &http.Cookie{Name: "admin_token", Value: volunteerToken(t)}) + assertRedirectsTo(t, rec, "/admin/login") } -func TestRouting_AdminAPI_ValidCookie(t *testing.T) { +func TestRouting_Admin_ValidToken(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() for _, path := range []string{ + "/admin", + "/admin/teams", + "/admin/dietaryrestrictions", "/admin/api/kattis/teams", "/admin/api/zoom/breakout", "/admin/api/team-list", @@ -155,60 +130,55 @@ func TestRouting_AdminAPI_ValidCookie(t *testing.T) { func TestRouting_AdminLogin_NoCookie(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/admin/login") - assertNotRedirectTo(t, rec, "/admin/login") + assertNotRedirectTo(t, doRequest(router, http.MethodGet, "/admin/login"), "/admin/login") } // --- Volunteer route protection --- +// +// Same structure: all protected routes checked for no-cookie, then one +// representative route for malformed/wrong-issuer/valid-token. -func TestRouting_VolunteerScan_NoCookie(t *testing.T) { +func TestRouting_Volunteer_NoCookie(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() - assertRedirectsTo(t, doRequest(router, http.MethodGet, "/volunteer/scan"), "/volunteer/login") + for _, path := range []string{ + "/volunteer/scan", + "/volunteer/checkin", + } { + assertRedirectsTo(t, doRequest(router, http.MethodGet, path), "/volunteer/login") + } } -func TestRouting_VolunteerScan_WrongCookie(t *testing.T) { +func TestRouting_Volunteer_MalformedToken(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() rec := doRequest(router, http.MethodGet, "/volunteer/scan", - &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + &http.Cookie{Name: "volunteer_token", Value: "not-a-jwt"}) assertRedirectsTo(t, rec, "/volunteer/login") } -func TestRouting_VolunteerScan_ValidCookie(t *testing.T) { +func TestRouting_Volunteer_WrongIssuer(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() rec := doRequest(router, http.MethodGet, "/volunteer/scan", - &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) - assertNotRedirectTo(t, rec, "/volunteer/login") -} - -func TestRouting_VolunteerCheckin_NoCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - assertRedirectsTo(t, doRequest(router, http.MethodGet, "/volunteer/checkin"), "/volunteer/login") -} - -func TestRouting_VolunteerCheckin_WrongCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/volunteer/checkin", - &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + &http.Cookie{Name: "volunteer_token", Value: adminToken(t)}) assertRedirectsTo(t, rec, "/volunteer/login") } -func TestRouting_VolunteerCheckin_ValidCookie(t *testing.T) { +func TestRouting_Volunteer_ValidToken(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/volunteer/checkin", - &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) - assertNotRedirectTo(t, rec, "/volunteer/login") + for _, path := range []string{ + "/volunteer/scan", + "/volunteer/checkin", + } { + rec := doRequest(router, http.MethodGet, path, + &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + assertNotRedirectTo(t, rec, "/volunteer/login") + } } // --- Volunteer unprotected routes --- -func TestRouting_VolunteerLogin_NoCookie(t *testing.T) { - router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/volunteer/login") - assertNotRedirectTo(t, rec, "/volunteer/login") -} - -func TestRouting_VolunteerHome_NoCookie(t *testing.T) { +func TestRouting_VolunteerPublic_NoCookie(t *testing.T) { router := newTestAppWithDB(t).BuildRouter() - rec := doRequest(router, http.MethodGet, "/volunteer") - assertNotRedirectTo(t, rec, "/volunteer/login") + for _, path := range []string{"/volunteer", "/volunteer/login"} { + assertNotRedirectTo(t, doRequest(router, http.MethodGet, path), "/volunteer/login") + } } From 1146d1c6dfc3a628e5ef9e39b09fe19e3b6b676f Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 11 Apr 2026 18:01:00 -0600 Subject: [PATCH 6/7] auth: redirect unauthenticated users on teacher-protected routes The renderFn loop was only redirecting to /register/teacher/login for pages marked RedirectIfLoggedIn:true (login/confirm-email pages). Pages with RedirectIfLoggedIn:false (schoolinfo, teams, team/edit, addmember) would render with nil data when accessed without a session. Now any unauthenticated GET on a /register/teacher/* protected page redirects to /register/teacher/login with 303. Co-Authored-By: Claude Sonnet 4.6 --- internal/application.go | 4 ++++ internal/routing_test.go | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/internal/application.go b/internal/application.go index e23f6fe..607a3f3 100644 --- a/internal/application.go +++ b/internal/application.go @@ -168,6 +168,10 @@ func (a *Application) BuildRouter() http.Handler { a.Log.Info().Err(err). Bool("redirect_if_logged_in", rend.RedirectIfLoggedIn). Msg("Failed to get logged in teacher") + if !rend.RedirectIfLoggedIn && strings.HasPrefix(path, "/register/teacher/") { + http.Redirect(w, r, "/register/teacher/login", http.StatusSeeOther) + return + } if rend.RedirectIfLoggedIn && path != "/register/teacher/login" && path != "/register/teacher/createaccount" && path != "/register/teacher/confirmemail" { http.Redirect(w, r, "/register/teacher/login", http.StatusTemporaryRedirect) } diff --git a/internal/routing_test.go b/internal/routing_test.go index 6b87bf7..f4a5c06 100644 --- a/internal/routing_test.go +++ b/internal/routing_test.go @@ -73,6 +73,20 @@ func assertNotRedirectTo(t *testing.T, rec *httptest.ResponseRecorder, location } } +// --- Teacher route protection --- + +func TestRouting_Teacher_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/register/teacher/schoolinfo", + "/register/teacher/teams", + "/register/teacher/team/edit", + "/register/teacher/team/addmember", + } { + assertRedirectsTo(t, doRequest(router, http.MethodGet, path), "/register/teacher/login") + } +} + // --- Admin route protection --- // // Tests cover: no cookie, malformed token, wrong-issuer token (volunteer From 9fe993ab3081343e46c3dcf785a1af3e0c544ac9 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 11 Apr 2026 18:13:19 -0600 Subject: [PATCH 7/7] flake: update vendorHash Signed-off-by: Sumner Evans --- flake.nix | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/flake.nix b/flake.nix index 3a47cdf..8bbabb8 100644 --- a/flake.nix +++ b/flake.nix @@ -5,21 +5,34 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - (flake-utils.lib.eachDefaultSystem (system: - let pkgs = import nixpkgs { inherit system; }; - in rec { + outputs = + { + self, + nixpkgs, + flake-utils, + }: + (flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + rec { packages.mineshspc = pkgs.buildGoModule { pname = "mineshspc.com"; version = "unstable-2026-01-11"; src = self; subPackages = [ "cmd/mineshspc" ]; - vendorHash = "sha256-q4AXwm8QRedjkO3565GASsCIxnmhzMGAYHEr6p0Es+0="; + vendorHash = "sha256-ylJWr0OFXY15APTq+ozVbFkAQ1lreM/9kZhJ6qYhx8E="; }; packages.default = packages.mineshspc; devShells.default = pkgs.mkShell { - packages = with pkgs; [ go gotools pre-commit ]; + packages = with pkgs; [ + go + gotools + pre-commit + ]; }; - })); + } + )); }