From bdd4322f385845d7f8cefdf8d0ba3fc993f186c7 Mon Sep 17 00:00:00 2001 From: HuanCheng65 <22636177+HuanCheng65@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:08:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(web,api):=20=E5=BE=AE=E4=BA=BA?= =?UTF-8?q?=E5=A4=A7=20web=20login=20+=20course=20final=20grades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a browser-only 微人大 login path and a read-only course-grade view so students can sign in on the web (no CLI/key) and see their externally-computed final grade for a lab. Web login: - auth.BuildWebLoginAuthorizeURL / HandleWebLoginCallback (UpsertUser by student_id; no device_auth_requests, no user_key) - GET /auth/login mints a CSRF state (double-submit cookie) and redirects to 微人大; the shared /api/device/verify callback routes to web login when the labkit_oauth_state cookie matches, else falls through to the device flow - browser session gains a Source marker; web sessions are key-less - /auth/login proxied via vite (dev) and Caddy (prod) Final grades: - final_grades table (lab_id, student_id, total + breakdown, published_at) - grade service: CSV import (header-matched), publish, published-only read - GET /api/labs/{labID}/grade (browser session or CLI signature) - POST /api/admin/labs/{labID}/grades/{import,publish} under adminGuard Frontend: - LoginView (微人大登录) and GradeView (total, breakdown, formula, remark; 404 -> 成绩尚未发布, 401 -> login), /login, /grade, /labs/:labID/grade Submit/keys/board stay key-only; web sessions can't reach them. Bumped the migrate runner migration-count assertion 10 -> 11. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/cmd/labkit-api/main.go | 2 + apps/api/internal/http/auth_router_test.go | 13 + apps/api/internal/http/browser_session.go | 18 ++ .../internal/http/device_verify_handler.go | 13 +- apps/api/internal/http/grade_handler.go | 170 ++++++++++ apps/api/internal/http/grade_handler_test.go | 175 +++++++++++ apps/api/internal/http/router.go | 25 +- apps/api/internal/http/web_login_handler.go | 162 ++++++++++ .../internal/http/web_login_handler_test.go | 152 +++++++++ apps/api/internal/service/auth/device_flow.go | 1 + .../internal/service/auth/device_flow_test.go | 10 + apps/api/internal/service/auth/repo.go | 4 + apps/api/internal/service/auth/web_login.go | 70 +++++ .../internal/service/auth/web_login_test.go | 119 +++++++ apps/api/internal/service/grade/repo.go | 43 +++ apps/api/internal/service/grade/service.go | 290 ++++++++++++++++++ .../internal/service/grade/service_test.go | 207 +++++++++++++ apps/migrate/internal/runner/runner_test.go | 3 +- apps/web/src/lib/grade.ts | 49 +++ apps/web/src/main.ts | 1 + apps/web/src/router.test.ts | 30 ++ apps/web/src/router.ts | 20 ++ apps/web/src/views/GradeView.test.ts | 120 ++++++++ apps/web/src/views/GradeView.vue | 262 ++++++++++++++++ apps/web/src/views/LoginView.test.ts | 69 +++++ apps/web/src/views/LoginView.vue | 190 ++++++++++++ apps/web/vite.config.ts | 5 + db/migrations/0011_final_grades.down.sql | 5 + db/migrations/0011_final_grades.up.sql | 18 ++ db/queries/auth.sql | 6 + db/queries/grades.sql | 35 +++ deploy/caddy/Caddyfile | 4 + packages/go/db/sqlc/auth.sql.go | 19 ++ packages/go/db/sqlc/grades.sql.go | 133 ++++++++ packages/go/db/sqlc/models.go | 14 + 35 files changed, 2450 insertions(+), 7 deletions(-) create mode 100644 apps/api/internal/http/grade_handler.go create mode 100644 apps/api/internal/http/grade_handler_test.go create mode 100644 apps/api/internal/http/web_login_handler.go create mode 100644 apps/api/internal/http/web_login_handler_test.go create mode 100644 apps/api/internal/service/auth/web_login.go create mode 100644 apps/api/internal/service/auth/web_login_test.go create mode 100644 apps/api/internal/service/grade/repo.go create mode 100644 apps/api/internal/service/grade/service.go create mode 100644 apps/api/internal/service/grade/service_test.go create mode 100644 apps/web/src/lib/grade.ts create mode 100644 apps/web/src/views/GradeView.test.ts create mode 100644 apps/web/src/views/GradeView.vue create mode 100644 apps/web/src/views/LoginView.test.ts create mode 100644 apps/web/src/views/LoginView.vue create mode 100644 db/migrations/0011_final_grades.down.sql create mode 100644 db/migrations/0011_final_grades.up.sql create mode 100644 db/queries/grades.sql create mode 100644 packages/go/db/sqlc/grades.sql.go diff --git a/apps/api/cmd/labkit-api/main.go b/apps/api/cmd/labkit-api/main.go index c98275a..c39ac11 100644 --- a/apps/api/cmd/labkit-api/main.go +++ b/apps/api/cmd/labkit-api/main.go @@ -16,6 +16,7 @@ import ( adminsvc "labkit.local/apps/api/internal/service/admin" authsvc "labkit.local/apps/api/internal/service/auth" authproviders "labkit.local/apps/api/internal/service/auth/providers" + gradesvc "labkit.local/apps/api/internal/service/grade" labsvc "labkit.local/apps/api/internal/service/labs" boardsvc "labkit.local/apps/api/internal/service/leaderboard" personalsvc "labkit.local/apps/api/internal/service/personal" @@ -82,6 +83,7 @@ func main() { httpapi.WithAuthService(authsvc.NewServiceWithProvider(authsvc.NewRepository(pool), authProvider, oauthConfig)), httpapi.WithLabsService(labsvc.NewService(db.New(pool))), httpapi.WithAdminService(adminsvc.NewService(adminsvc.NewRepository(pool))), + httpapi.WithGradeService(gradesvc.NewService(gradesvc.NewRepository(pool))), httpapi.WithLeaderboardService(leaderboardService), httpapi.WithPersonalService(personalService), httpapi.WithWebSessionService(websession.NewPersistentService(websession.NewRepository(pool))), diff --git a/apps/api/internal/http/auth_router_test.go b/apps/api/internal/http/auth_router_test.go index c2bff3c..9707b7d 100644 --- a/apps/api/internal/http/auth_router_test.go +++ b/apps/api/internal/http/auth_router_test.go @@ -515,6 +515,19 @@ func (r *routerAuthRepo) GetUserByID(_ context.Context, id int64) (sqlc.Users, e return user, nil } +func (r *routerAuthRepo) UpsertUser(_ context.Context, studentID string) (sqlc.UpsertUserRow, error) { + for _, user := range r.usersByID { + if user.StudentID == studentID { + return sqlc.UpsertUserRow{ID: user.ID, StudentID: user.StudentID}, nil + } + } + id := r.nextID + r.nextID++ + user := sqlc.Users{ID: id, StudentID: studentID} + r.usersByID[id] = user + return sqlc.UpsertUserRow{ID: id, StudentID: studentID}, nil +} + type noopOAuthClient struct{} func (noopOAuthClient) ExchangeCode(context.Context, string) (string, error) { diff --git a/apps/api/internal/http/browser_session.go b/apps/api/internal/http/browser_session.go index aa877ef..ffcb9f5 100644 --- a/apps/api/internal/http/browser_session.go +++ b/apps/api/internal/http/browser_session.go @@ -11,6 +11,11 @@ import ( const ( browserSessionCookieName = "labkit_browser_session" browserSessionTTL = 8 * time.Hour + + // browserSessionSource* records how a browser session was minted, for + // auditing and for any future "web sessions are read-only" enforcement. + browserSessionSourceDevice = "device" + browserSessionSourceWeb = "web" ) var browserSessions sync.Map @@ -19,10 +24,22 @@ type browserSession struct { UserID int64 KeyID int64 StudentID string + Source string ExpiresAt time.Time } +// issueBrowserSession mints a session for the CLI device flow (key-bound). func issueBrowserSession(userID, keyID int64, studentID string) (string, error) { + return issueBrowserSessionWithSource(userID, keyID, studentID, browserSessionSourceDevice) +} + +// issueWebBrowserSession mints a session for the 微人大 web login. It is not +// bound to a device key (KeyID == 0); read handlers rely on UserID/StudentID. +func issueWebBrowserSession(userID int64, studentID string) (string, error) { + return issueBrowserSessionWithSource(userID, 0, studentID, browserSessionSourceWeb) +} + +func issueBrowserSessionWithSource(userID, keyID int64, studentID, source string) (string, error) { token, err := randomBrowserSessionToken() if err != nil { return "", err @@ -31,6 +48,7 @@ func issueBrowserSession(userID, keyID int64, studentID string) (string, error) UserID: userID, KeyID: keyID, StudentID: studentID, + Source: source, ExpiresAt: time.Now().UTC().Add(browserSessionTTL), }) return token, nil diff --git a/apps/api/internal/http/device_verify_handler.go b/apps/api/internal/http/device_verify_handler.go index 7d34f43..ada744c 100644 --- a/apps/api/internal/http/device_verify_handler.go +++ b/apps/api/internal/http/device_verify_handler.go @@ -11,6 +11,8 @@ import ( type DeviceVerifyHandler struct { Service *authsvc.Service BrowserSessionSecure bool + // WebLogin handles the browser-only login path that shares this callback. + WebLogin *WebLoginHandler } func (h *DeviceVerifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -20,7 +22,16 @@ func (h *DeviceVerifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } query := r.URL.Query() - if strings.TrimSpace(query.Get("code")) != "" || strings.TrimSpace(query.Get("state")) != "" { + state := strings.TrimSpace(query.Get("state")) + code := strings.TrimSpace(query.Get("code")) + if code != "" || state != "" { + // Web login and device flow share this redirect_uri. A matching + // labkit_oauth_state cookie means the browser started a web login; + // otherwise fall through to the device flow (unchanged). + if h.WebLogin != nil && hasMatchingOAuthStateCookie(r, state) { + h.WebLogin.handleCallback(w, r, state, code) + return + } callback := &OAuthCallbackHandler{Service: h.Service, BrowserSessionSecure: h.BrowserSessionSecure} callback.ServeHTTP(w, r) return diff --git a/apps/api/internal/http/grade_handler.go b/apps/api/internal/http/grade_handler.go new file mode 100644 index 0000000..e351dc7 --- /dev/null +++ b/apps/api/internal/http/grade_handler.go @@ -0,0 +1,170 @@ +package httpapi + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + + "labkit.local/apps/api/internal/http/middleware" + gradesvc "labkit.local/apps/api/internal/service/grade" +) + +// GradeService is the grade surface used by the HTTP layer (student read + +// admin import/publish). +type GradeService interface { + GetGrade(context.Context, string, string) (gradesvc.Grade, error) + ImportGrades(context.Context, string, io.Reader) (gradesvc.ImportGradesResult, error) + PublishGrades(context.Context, string) (gradesvc.PublishGradesResult, error) +} + +// GradeHandler serves the student-facing grade view and the admin grade import. +type GradeHandler struct { + Service GradeService + Personal PersonalService +} + +// maxGradeCSVBytes caps an uploaded grades CSV (defensive; class-sized files +// are tiny). +const maxGradeCSVBytes = 8 << 20 // 8 MiB + +// GetMyGrade handles GET /api/labs/{labID}/grade. Browser session is preferred +// (it already carries the student id); CLI signature auth is also accepted. +func (h *GradeHandler) GetMyGrade(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil { + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return + } + studentID, ok := h.resolveStudentID(w, r) + if !ok { + return + } + grade, err := h.Service.GetGrade(r.Context(), r.PathValue("labID"), studentID) + if err != nil { + switch { + case errors.Is(err, gradesvc.ErrGradeNotFound): + middleware.WriteError(w, r, http.StatusNotFound, "grade_not_found", "成绩尚未发布") + default: + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + } + return + } + writeJSON(w, http.StatusOK, grade) +} + +// ImportGrades handles POST /api/admin/labs/{labID}/grades/import. The CSV may +// arrive as a multipart "file" field or as a raw text/csv body. +func (h *GradeHandler) ImportGrades(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil { + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return + } + reader, cleanup, err := gradeCSVReader(r) + if err != nil { + middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + defer cleanup() + + result, err := h.Service.ImportGrades(r.Context(), r.PathValue("labID"), reader) + if err != nil { + h.writeImportError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +// PublishGrades handles POST /api/admin/labs/{labID}/grades/publish. +func (h *GradeHandler) PublishGrades(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil { + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return + } + result, err := h.Service.PublishGrades(r.Context(), r.PathValue("labID")) + if err != nil { + if errors.Is(err, gradesvc.ErrInvalidLab) { + middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "lab id is required") + return + } + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *GradeHandler) writeImportError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, gradesvc.ErrInvalidCSV): + middleware.WriteError(w, r, http.StatusBadRequest, "invalid_csv", err.Error()) + case errors.Is(err, gradesvc.ErrInvalidLab): + middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "lab id is required") + default: + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + } +} + +// resolveStudentID returns the requesting student's id from a browser session +// (preferred) or from CLI signature auth (reverse-lookup via profile). +func (h *GradeHandler) resolveStudentID(w http.ResponseWriter, r *http.Request) (string, bool) { + if session, ok := browserSessionFromRequest(r); ok { + if sid := strings.TrimSpace(session.StudentID); sid != "" { + return sid, true + } + if h.Personal != nil { + if profile, err := h.Personal.GetProfile(r.Context(), session.UserID); err == nil { + if sid := strings.TrimSpace(profile.StudentID); sid != "" { + return sid, true + } + } + } + middleware.WriteError(w, r, http.StatusUnauthorized, "unauthorized", http.StatusText(http.StatusUnauthorized)) + return "", false + } + + if h.Personal == nil { + middleware.WriteError(w, r, http.StatusUnauthorized, "unauthorized", http.StatusText(http.StatusUnauthorized)) + return "", false + } + body, err := readRequestBody(r) + if err != nil { + middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "invalid request body") + return "", false + } + user, ok := authenticatePersonalRequest(w, r, h.Personal, body) + if !ok { + return "", false + } + profile, err := h.Personal.GetProfile(r.Context(), user.UserID) + if err != nil { + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return "", false + } + sid := strings.TrimSpace(profile.StudentID) + if sid == "" { + middleware.WriteError(w, r, http.StatusUnauthorized, "unauthorized", http.StatusText(http.StatusUnauthorized)) + return "", false + } + return sid, true +} + +// gradeCSVReader extracts the CSV body from either a multipart upload or a raw +// request body, returning a reader and a cleanup func. +func gradeCSVReader(r *http.Request) (io.Reader, func(), error) { + noop := func() {} + contentType := r.Header.Get("Content-Type") + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "multipart/form-data") { + if err := r.ParseMultipartForm(maxGradeCSVBytes); err != nil { + return nil, noop, errors.New("failed to parse multipart upload") + } + file, _, err := r.FormFile("file") + if err != nil { + return nil, noop, errors.New("missing CSV file field \"file\"") + } + return io.LimitReader(file, maxGradeCSVBytes), func() { _ = file.Close() }, nil + } + if r.Body == nil { + return nil, noop, errors.New("empty request body") + } + return io.LimitReader(r.Body, maxGradeCSVBytes), noop, nil +} diff --git a/apps/api/internal/http/grade_handler_test.go b/apps/api/internal/http/grade_handler_test.go new file mode 100644 index 0000000..789c757 --- /dev/null +++ b/apps/api/internal/http/grade_handler_test.go @@ -0,0 +1,175 @@ +package httpapi + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gradesvc "labkit.local/apps/api/internal/service/grade" +) + +type fakeGradeService struct { + grade gradesvc.Grade + getErr error + importRes gradesvc.ImportGradesResult + importErr error + publishRes gradesvc.PublishGradesResult + lastLabID string + lastStudentID string + lastImportCSV string +} + +func (f *fakeGradeService) GetGrade(_ context.Context, labID, studentID string) (gradesvc.Grade, error) { + f.lastLabID = labID + f.lastStudentID = studentID + if f.getErr != nil { + return gradesvc.Grade{}, f.getErr + } + return f.grade, nil +} + +func (f *fakeGradeService) ImportGrades(_ context.Context, labID string, r io.Reader) (gradesvc.ImportGradesResult, error) { + body, _ := io.ReadAll(r) + f.lastImportCSV = string(body) + f.lastLabID = labID + if f.importErr != nil { + return gradesvc.ImportGradesResult{}, f.importErr + } + return f.importRes, nil +} + +func (f *fakeGradeService) PublishGrades(_ context.Context, labID string) (gradesvc.PublishGradesResult, error) { + f.lastLabID = labID + return f.publishRes, nil +} + +func TestGradeRouteReturnsGradeForBrowserSession(t *testing.T) { + svc := &fakeGradeService{grade: gradesvc.Grade{LabID: "colab-2026-p2", StudentID: "2026001", Total: 86.5}} + router := NewRouter(WithGradeService(svc)) + + token, err := issueWebBrowserSession(7, "2026001") + if err != nil { + t.Fatalf("issueWebBrowserSession() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/labs/colab-2026-p2/grade", nil) + req.AddCookie(&http.Cookie{Name: browserSessionCookieName, Value: token}) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if svc.lastStudentID != "2026001" { + t.Fatalf("queried student id = %q, want %q", svc.lastStudentID, "2026001") + } + if svc.lastLabID != "colab-2026-p2" { + t.Fatalf("queried lab id = %q, want %q", svc.lastLabID, "colab-2026-p2") + } + var payload gradesvc.Grade + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal grade: %v", err) + } + if payload.Total != 86.5 { + t.Fatalf("total = %v, want 86.5", payload.Total) + } +} + +func TestGradeRouteReturns404WhenUnpublished(t *testing.T) { + svc := &fakeGradeService{getErr: gradesvc.ErrGradeNotFound} + router := NewRouter(WithGradeService(svc)) + + token, _ := issueWebBrowserSession(7, "2026001") + req := httptest.NewRequest(http.MethodGet, "/api/labs/colab-2026-p2/grade", nil) + req.AddCookie(&http.Cookie{Name: browserSessionCookieName, Value: token}) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "grade_not_found") { + t.Fatalf("body = %s, want grade_not_found", rr.Body.String()) + } +} + +func TestGradeRouteRequiresAuth(t *testing.T) { + svc := &fakeGradeService{} + router := NewRouter(WithGradeService(svc)) + + req := httptest.NewRequest(http.MethodGet, "/api/labs/colab-2026-p2/grade", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusUnauthorized, rr.Body.String()) + } +} + +func TestAdminGradeImportRoute(t *testing.T) { + svc := &fakeGradeService{importRes: gradesvc.ImportGradesResult{LabID: "colab-2026-p2", Imported: 3}} + router := NewRouter(WithGradeService(svc), WithAdminToken("secret")) + + csv := "student_id,total\n2026001,80\n2026002,90\n2026003,70\n" + req := httptest.NewRequest(http.MethodPost, "/api/admin/labs/colab-2026-p2/grades/import", strings.NewReader(csv)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Content-Type", "text/csv") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if svc.lastImportCSV != csv { + t.Fatalf("imported csv = %q, want %q", svc.lastImportCSV, csv) + } + var payload gradesvc.ImportGradesResult + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.Imported != 3 { + t.Fatalf("imported = %d, want 3", payload.Imported) + } +} + +func TestAdminGradeImportRequiresAdminToken(t *testing.T) { + svc := &fakeGradeService{} + router := NewRouter(WithGradeService(svc), WithAdminToken("secret")) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/labs/colab-2026-p2/grades/import", strings.NewReader("student_id,total\n")) + req.Header.Set("Content-Type", "text/csv") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusUnauthorized, rr.Body.String()) + } +} + +func TestAdminGradePublishRoute(t *testing.T) { + svc := &fakeGradeService{publishRes: gradesvc.PublishGradesResult{LabID: "colab-2026-p2", Published: 5}} + router := NewRouter(WithGradeService(svc), WithAdminToken("secret")) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/labs/colab-2026-p2/grades/publish", nil) + req.Header.Set("Authorization", "Bearer secret") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if svc.lastLabID != "colab-2026-p2" { + t.Fatalf("published lab id = %q, want %q", svc.lastLabID, "colab-2026-p2") + } + var payload gradesvc.PublishGradesResult + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.Published != 5 { + t.Fatalf("published = %d, want 5", payload.Published) + } +} diff --git a/apps/api/internal/http/router.go b/apps/api/internal/http/router.go index eebdcf7..30ce112 100644 --- a/apps/api/internal/http/router.go +++ b/apps/api/internal/http/router.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "strings" - v2http "labkit.local/apps/api/internal/http/v2" "labkit.local/apps/api/internal/http/middleware" + v2http "labkit.local/apps/api/internal/http/v2" authsvc "labkit.local/apps/api/internal/service/auth" labsvc "labkit.local/apps/api/internal/service/labs" websession "labkit.local/apps/api/internal/service/websession" @@ -26,6 +26,7 @@ type routerConfig struct { submissionsService SubmissionsService personalService PersonalService adminService AdminService + gradeService GradeService adminToken string devMode bool webSessionService *websession.Service @@ -73,6 +74,12 @@ func WithAdminService(service AdminService) RouterOption { } } +func WithGradeService(service GradeService) RouterOption { + return func(cfg *routerConfig) { + cfg.gradeService = service + } +} + func WithWebSessionService(service *websession.Service) RouterOption { return func(cfg *routerConfig) { cfg.webSessionService = service @@ -95,7 +102,8 @@ func NewRouter(options ...RouterOption) *Router { mux := http.NewServeMux() authHandler := &AuthHandler{Service: cfg.authService} - verifyHandler := &DeviceVerifyHandler{Service: cfg.authService, BrowserSessionSecure: !cfg.devMode} + webLoginHandler := &WebLoginHandler{Service: cfg.authService, BrowserSessionSecure: !cfg.devMode} + verifyHandler := &DeviceVerifyHandler{Service: cfg.authService, BrowserSessionSecure: !cfg.devMode, WebLogin: webLoginHandler} devDeviceHandler := &DevDeviceHandler{Service: cfg.authService} labsHandler := &LabsHandler{Service: cfg.labsService} leaderboardHandler := &LeaderboardHandler{Service: cfg.leaderboardService, Personal: cfg.personalService} @@ -104,6 +112,7 @@ func NewRouter(options ...RouterOption) *Router { profileHandler := &ProfileHandler{Service: cfg.personalService} keysHandler := &KeysHandler{Service: cfg.personalService} adminHandler := &AdminHandler{Service: cfg.adminService} + gradeHandler := &GradeHandler{Service: cfg.gradeService, Personal: cfg.personalService} webSessionService := cfg.webSessionService if webSessionService == nil { webSessionService = websession.NewService() @@ -117,15 +126,15 @@ func NewRouter(options ...RouterOption) *Router { mux.Handle("GET /healthz", &HealthHandler{}) - registerAuthRoutes(mux, authHandler, verifyHandler, webSessionHandler) + registerAuthRoutes(mux, authHandler, verifyHandler, webSessionHandler, webLoginHandler) if cfg.devMode { registerDevRoutes(mux, devDeviceHandler) } // v1 API: existing production surface. - registerV1APIRoutes(mux, "/api", labsHandler, leaderboardHandler, submissionsHandler, historyHandler, profileHandler, keysHandler, adminHandler, adminGuard) + registerV1APIRoutes(mux, "/api", labsHandler, leaderboardHandler, submissionsHandler, historyHandler, profileHandler, keysHandler, adminHandler, gradeHandler, adminGuard) // Versioned alias for v1, so clients can opt into explicit versioning. - registerV1APIRoutes(mux, "/api/v1", labsHandler, leaderboardHandler, submissionsHandler, historyHandler, profileHandler, keysHandler, adminHandler, adminGuard) + registerV1APIRoutes(mux, "/api/v1", labsHandler, leaderboardHandler, submissionsHandler, historyHandler, profileHandler, keysHandler, adminHandler, gradeHandler, adminGuard) // v2 API: new stable JSON contract (lowercase keys), implemented incrementally. registerV2Routes(mux, cfg.labsService) @@ -169,10 +178,12 @@ func registerAuthRoutes( authHandler *AuthHandler, verifyHandler *DeviceVerifyHandler, webSessionHandler *WebSessionHandler, + webLoginHandler *WebLoginHandler, ) { mux.HandleFunc("POST /api/device/authorize", authHandler.CreateDeviceAuthorizationRequest) mux.HandleFunc("POST /api/device/poll", authHandler.PollDeviceAuthorizationRequest) mux.Handle("GET /api/device/verify", verifyHandler) + mux.HandleFunc("GET /auth/login", webLoginHandler.Start) mux.HandleFunc("POST /api/web/session-ticket", webSessionHandler.CreateSessionTicket) mux.HandleFunc("GET /auth/session", webSessionHandler.ServeSessionShell) mux.HandleFunc("POST /auth/session/exchange", webSessionHandler.ExchangeSessionTicket) @@ -192,11 +203,13 @@ func registerV1APIRoutes( profileHandler *ProfileHandler, keysHandler *KeysHandler, adminHandler *AdminHandler, + gradeHandler *GradeHandler, adminGuard func(http.Handler) http.Handler, ) { mux.HandleFunc("GET "+apiPrefix+"/labs", labsHandler.ListLabs) mux.HandleFunc("GET "+apiPrefix+"/labs/{labID}", labsHandler.GetLab) mux.HandleFunc("GET "+apiPrefix+"/labs/{labID}/board", leaderboardHandler.GetBoard) + mux.HandleFunc("GET "+apiPrefix+"/labs/{labID}/grade", gradeHandler.GetMyGrade) mux.HandleFunc("GET "+apiPrefix+"/labs/{labID}/submit/precheck", submissionsHandler.GetSubmitPrecheck) mux.HandleFunc("POST "+apiPrefix+"/labs/{labID}/submit", submissionsHandler.CreateSubmission) mux.HandleFunc("POST "+apiPrefix+"/labs/{labID}/submissions", submissionsHandler.CreateSubmission) @@ -212,6 +225,8 @@ func registerV1APIRoutes( mux.Handle("PUT "+apiPrefix+"/admin/labs/{labID}", adminGuard(http.HandlerFunc(labsHandler.UpdateLab))) mux.Handle("GET "+apiPrefix+"/admin/labs/{labID}", adminGuard(http.HandlerFunc(adminHandler.GetLabDetail))) mux.Handle("GET "+apiPrefix+"/admin/labs/{labID}/grades", adminGuard(http.HandlerFunc(adminHandler.ExportGrades))) + mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/grades/import", adminGuard(http.HandlerFunc(gradeHandler.ImportGrades))) + mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/grades/publish", adminGuard(http.HandlerFunc(gradeHandler.PublishGrades))) mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/reeval", adminGuard(http.HandlerFunc(adminHandler.Reevaluate))) mux.Handle("GET "+apiPrefix+"/admin/labs/{labID}/queue", adminGuard(http.HandlerFunc(adminHandler.GetQueueStatus))) mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/quota/reset", adminGuard(http.HandlerFunc(adminHandler.ResetLabQuota))) diff --git a/apps/api/internal/http/web_login_handler.go b/apps/api/internal/http/web_login_handler.go new file mode 100644 index 0000000..7f7cc23 --- /dev/null +++ b/apps/api/internal/http/web_login_handler.go @@ -0,0 +1,162 @@ +package httpapi + +import ( + "crypto/subtle" + "errors" + "net/http" + "strings" + "time" + + authsvc "labkit.local/apps/api/internal/service/auth" +) + +const ( + // oauthStateCookieName carries the double-submit CSRF token for web login. + oauthStateCookieName = "labkit_oauth_state" + // oauthNextCookieName remembers the post-login landing path across the + // round-trip to 微人大 (the school callback can't carry our query params). + oauthNextCookieName = "labkit_oauth_next" + oauthStateCookieTTL = 10 * time.Minute + + defaultWebLoginNext = "/grade" +) + +// WebLoginHandler implements the browser-only 微人大 login path. It shares the +// school OAuth callback (/api/device/verify) with the device flow and is +// disambiguated there by the presence of the labkit_oauth_state cookie. +type WebLoginHandler struct { + Service *authsvc.Service + BrowserSessionSecure bool +} + +// Start handles GET /auth/login: it mints a CSRF state, stores it (and the +// desired landing path) in short-lived cookies, then redirects to 微人大. +func (h *WebLoginHandler) Start(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil { + http.Error(w, "service unavailable", http.StatusInternalServerError) + return + } + authorizeURL, state, err := h.Service.BuildWebLoginAuthorizeURL() + if err != nil { + if errors.Is(err, authsvc.ErrProviderNotConfigured) { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + http.Error(w, "failed to start login", http.StatusInternalServerError) + return + } + + next := sanitizeWebLoginNext(r.URL.Query().Get("next")) + h.setShortCookie(w, oauthStateCookieName, state) + h.setShortCookie(w, oauthNextCookieName, next) + http.Redirect(w, r, authorizeURL, http.StatusFound) +} + +// handleCallback completes web login from the shared OAuth callback. The caller +// (DeviceVerifyHandler) only routes here when a labkit_oauth_state cookie is +// present; we still re-verify it against the query state (double-submit CSRF). +func (h *WebLoginHandler) handleCallback(w http.ResponseWriter, r *http.Request, state, code string) { + if h == nil || h.Service == nil { + http.Error(w, "service unavailable", http.StatusInternalServerError) + return + } + if !hasMatchingOAuthStateCookie(r, state) { + http.Error(w, "invalid oauth state", http.StatusBadRequest) + return + } + + result, err := h.Service.HandleWebLoginCallback(r.Context(), code) + if err != nil { + switch { + case errors.Is(err, authsvc.ErrInvalidCode): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, authsvc.ErrProviderNotConfigured): + http.Error(w, err.Error(), http.StatusServiceUnavailable) + default: + http.Error(w, err.Error(), http.StatusBadGateway) + } + return + } + + sessionToken, err := issueWebBrowserSession(result.UserID, result.StudentID) + if err != nil { + http.Error(w, "failed to create browser session", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: browserSessionCookieName, + Value: sessionToken, + Path: "/", + HttpOnly: true, + Secure: h.BrowserSessionSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(browserSessionTTL.Seconds()), + }) + + next := defaultWebLoginNext + if cookie, err := r.Cookie(oauthNextCookieName); err == nil { + next = sanitizeWebLoginNext(cookie.Value) + } + h.clearCookie(w, oauthStateCookieName) + h.clearCookie(w, oauthNextCookieName) + http.Redirect(w, r, next, http.StatusFound) +} + +func (h *WebLoginHandler) setShortCookie(w http.ResponseWriter, name, value string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: true, + Secure: h.BrowserSessionSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(oauthStateCookieTTL.Seconds()), + }) +} + +func (h *WebLoginHandler) clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: h.BrowserSessionSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// hasMatchingOAuthStateCookie reports whether the request carries a +// labkit_oauth_state cookie equal to the supplied (query) state. +func hasMatchingOAuthStateCookie(r *http.Request, state string) bool { + state = strings.TrimSpace(state) + if r == nil || state == "" { + return false + } + cookie, err := r.Cookie(oauthStateCookieName) + if err != nil { + return false + } + got := strings.TrimSpace(cookie.Value) + if got == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(state)) == 1 +} + +// sanitizeWebLoginNext only allows same-origin absolute paths to prevent open +// redirects; anything else falls back to the default landing page. +func sanitizeWebLoginNext(next string) string { + next = strings.TrimSpace(next) + if next == "" { + return defaultWebLoginNext + } + // Reject protocol-relative ("//host") and absolute ("https://host") URLs. + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { + return defaultWebLoginNext + } + if strings.Contains(next, "\\") { + return defaultWebLoginNext + } + return next +} diff --git a/apps/api/internal/http/web_login_handler_test.go b/apps/api/internal/http/web_login_handler_test.go new file mode 100644 index 0000000..e151ebc --- /dev/null +++ b/apps/api/internal/http/web_login_handler_test.go @@ -0,0 +1,152 @@ +package httpapi + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "labkit.local/apps/api/internal/config" + authsvc "labkit.local/apps/api/internal/service/auth" + authproviders "labkit.local/apps/api/internal/service/auth/providers" +) + +func newWebLoginRouter(repo *routerAuthRepo) *Router { + casCfg := config.CASRUCConfig{ + ClientID: "labkit-client", + AuthorizeURL: "https://sso.example.edu/oauth2/authorize", + RedirectURL: "/api/device/verify", + } + svc := authsvc.NewServiceWithProvider(repo, authproviders.NewCASRUCProvider(verifyOAuthClient{}, casCfg), config.OAuthConfig{CASRUC: casCfg}) + return NewRouter(WithAuthService(svc)) +} + +func cookieByName(cookies []*http.Cookie, name string) *http.Cookie { + for _, c := range cookies { + if c.Name == name { + return c + } + } + return nil +} + +func TestWebLoginStartSetsStateCookieAndRedirects(t *testing.T) { + router := newWebLoginRouter(newRouterAuthRepo()) + + req := httptest.NewRequest(http.MethodGet, "/auth/login?next=/grade", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusFound, rr.Body.String()) + } + location := rr.Header().Get("Location") + if !strings.HasPrefix(location, "https://sso.example.edu/oauth2/authorize") { + t.Fatalf("location = %q, want authorize prefix", location) + } + locURL, err := url.Parse(location) + if err != nil { + t.Fatalf("parse location: %v", err) + } + state := locURL.Query().Get("state") + if state == "" { + t.Fatal("authorize URL missing state") + } + + cookies := rr.Result().Cookies() + stateCookie := cookieByName(cookies, oauthStateCookieName) + if stateCookie == nil { + t.Fatal("missing labkit_oauth_state cookie") + } + if stateCookie.Value != state { + t.Fatalf("state cookie = %q, want %q (must match authorize state)", stateCookie.Value, state) + } + if !stateCookie.HttpOnly { + t.Fatal("state cookie must be HttpOnly") + } + nextCookie := cookieByName(cookies, oauthNextCookieName) + if nextCookie == nil || nextCookie.Value != "/grade" { + t.Fatalf("next cookie = %+v, want value /grade", nextCookie) + } +} + +func TestWebLoginStartRejectsOpenRedirectNext(t *testing.T) { + router := newWebLoginRouter(newRouterAuthRepo()) + + req := httptest.NewRequest(http.MethodGet, "/auth/login?next=https://evil.example/x", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } + nextCookie := cookieByName(rr.Result().Cookies(), oauthNextCookieName) + if nextCookie == nil || nextCookie.Value != defaultWebLoginNext { + t.Fatalf("next cookie = %+v, want default %q", nextCookie, defaultWebLoginNext) + } +} + +func TestWebLoginCallbackIssuesBrowserSessionAndRedirectsToNext(t *testing.T) { + repo := newRouterAuthRepo() + router := newWebLoginRouter(repo) + + // 1) start to obtain a matching state + cookies. + startReq := httptest.NewRequest(http.MethodGet, "/auth/login?next=/grade", nil) + startRR := httptest.NewRecorder() + router.ServeHTTP(startRR, startReq) + startCookies := startRR.Result().Cookies() + stateCookie := cookieByName(startCookies, oauthStateCookieName) + if stateCookie == nil { + t.Fatal("start did not set state cookie") + } + state := stateCookie.Value + + // 2) school redirects back to the shared callback with matching state + cookie. + callbackReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/device/verify?state=%s&code=auth-code", url.QueryEscape(state)), nil) + for _, c := range startCookies { + callbackReq.AddCookie(c) + } + callbackRR := httptest.NewRecorder() + router.ServeHTTP(callbackRR, callbackReq) + + if callbackRR.Code != http.StatusFound { + t.Fatalf("callback status = %d, want %d body=%s", callbackRR.Code, http.StatusFound, callbackRR.Body.String()) + } + if got := callbackRR.Header().Get("Location"); got != "/grade" { + t.Fatalf("callback location = %q, want %q", got, "/grade") + } + sessionCookie := cookieByName(callbackRR.Result().Cookies(), browserSessionCookieName) + if sessionCookie == nil || sessionCookie.Value == "" { + t.Fatal("callback did not set browser session cookie") + } + // The web user must have been upserted by student_id. + found := false + for _, user := range repo.usersByID { + if user.StudentID == "2026001" { + found = true + } + } + if !found { + t.Fatal("web login did not upsert user by student_id") + } +} + +func TestWebLoginCallbackWithoutCookieFallsThroughToDeviceFlow(t *testing.T) { + repo := newRouterAuthRepo() + router := newWebLoginRouter(repo) + + // No labkit_oauth_state cookie and no matching device request → device flow + // rejects the unknown state. Crucially it must NOT mint a web session. + callbackReq := httptest.NewRequest(http.MethodGet, "/api/device/verify?state=unknown-state&code=auth-code", nil) + callbackRR := httptest.NewRecorder() + router.ServeHTTP(callbackRR, callbackReq) + + if callbackRR.Code != http.StatusBadRequest { + t.Fatalf("callback status = %d, want %d body=%s", callbackRR.Code, http.StatusBadRequest, callbackRR.Body.String()) + } + if cookie := cookieByName(callbackRR.Result().Cookies(), browserSessionCookieName); cookie != nil { + t.Fatal("device-flow fallback must not set a browser session cookie") + } +} diff --git a/apps/api/internal/service/auth/device_flow.go b/apps/api/internal/service/auth/device_flow.go index 10cf512..83109df 100644 --- a/apps/api/internal/service/auth/device_flow.go +++ b/apps/api/internal/service/auth/device_flow.go @@ -41,6 +41,7 @@ type Repository interface { GetPendingDeviceAuthRequestByOAuthState(context.Context, string) (sqlc.DeviceAuthRequests, error) CompleteDeviceAuthRequest(context.Context, sqlc.CompleteDeviceAuthRequestParams) (sqlc.CompleteDeviceAuthRequestRow, error) GetUserKeyByPublicKey(context.Context, string) (sqlc.UserKeys, error) + UpsertUser(context.Context, string) (sqlc.UpsertUserRow, error) } type Service struct { diff --git a/apps/api/internal/service/auth/device_flow_test.go b/apps/api/internal/service/auth/device_flow_test.go index afa5a89..ca8adc6 100644 --- a/apps/api/internal/service/auth/device_flow_test.go +++ b/apps/api/internal/service/auth/device_flow_test.go @@ -592,6 +592,16 @@ func (r *fakeRepository) CompleteDeviceAuthRequest(_ context.Context, arg sqlc.C }, nil } +func (r *fakeRepository) UpsertUser(_ context.Context, studentID string) (sqlc.UpsertUserRow, error) { + user, ok := r.usersByStudentID[studentID] + if !ok { + user = sqlc.Users{ID: r.nextUserID, StudentID: studentID} + r.nextUserID++ + r.usersByStudentID[studentID] = user + } + return sqlc.UpsertUserRow{ID: user.ID, StudentID: user.StudentID}, nil +} + func (r *fakeRepository) GetUserKeyByPublicKey(_ context.Context, publicKey string) (sqlc.UserKeys, error) { key, ok := r.keysByPublicKey[publicKey] if !ok { diff --git a/apps/api/internal/service/auth/repo.go b/apps/api/internal/service/auth/repo.go index 9911391..86b9c6f 100644 --- a/apps/api/internal/service/auth/repo.go +++ b/apps/api/internal/service/auth/repo.go @@ -42,6 +42,10 @@ func (r *repo) CompleteDeviceAuthRequest(ctx context.Context, arg sqlc.CompleteD return r.store.CompleteDeviceAuthRequest(ctx, arg) } +func (r *repo) UpsertUser(ctx context.Context, studentID string) (sqlc.UpsertUserRow, error) { + return r.store.UpsertUser(ctx, studentID) +} + func (r *repo) GetUserKeyByPublicKey(ctx context.Context, publicKey string) (sqlc.UserKeys, error) { var row sqlc.UserKeys err := r.pool.QueryRow(ctx, ` diff --git a/apps/api/internal/service/auth/web_login.go b/apps/api/internal/service/auth/web_login.go new file mode 100644 index 0000000..f510a75 --- /dev/null +++ b/apps/api/internal/service/auth/web_login.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "fmt" + "strings" +) + +// WebLoginResult is the outcome of a successful 微人大 web login. It carries only +// the read-only identity needed to issue a browser session — no key binding, +// no device record. +type WebLoginResult struct { + UserID int64 + StudentID string +} + +// BuildWebLoginAuthorizeURL starts a browser-only login: it mints a fresh CSRF +// state and returns the provider authorize URL alongside that state so the HTTP +// layer can persist it in a double-submit cookie. Unlike the device flow this +// does NOT touch device_auth_requests — the state lives entirely client-side. +func (s *Service) BuildWebLoginAuthorizeURL() (string, string, error) { + if s == nil { + return "", "", fmt.Errorf("auth service unavailable") + } + if s.provider == nil { + return "", "", ErrProviderNotConfigured + } + state, err := s.newState() + if err != nil { + return "", "", err + } + authorizeURL, err := s.provider.BuildAuthorizeURL(state) + if err != nil { + return "", "", err + } + return authorizeURL, state, nil +} + +// HandleWebLoginCallback exchanges the OAuth code, fetches the 微人大 identity and +// upserts the user by student_id, returning the identity for a browser session. +// CSRF/state verification is performed at the HTTP layer (double-submit cookie), +// so this method intentionally has no state-store dependency. +func (s *Service) HandleWebLoginCallback(ctx context.Context, code string) (WebLoginResult, error) { + if s == nil || s.repo == nil { + return WebLoginResult{}, fmt.Errorf("auth service unavailable") + } + if strings.TrimSpace(code) == "" { + return WebLoginResult{}, ErrInvalidCode + } + if s.provider == nil { + return WebLoginResult{}, ErrProviderNotConfigured + } + tokenSet, err := s.provider.ExchangeCode(ctx, code) + if err != nil { + return WebLoginResult{}, fmt.Errorf("%w: %v", ErrInvalidCode, err) + } + identity, err := s.provider.FetchIdentity(ctx, tokenSet) + if err != nil { + return WebLoginResult{}, err + } + studentID := strings.TrimSpace(identity.StudentID) + if studentID == "" { + return WebLoginResult{}, ErrInvalidCode + } + user, err := s.repo.UpsertUser(ctx, studentID) + if err != nil { + return WebLoginResult{}, err + } + return WebLoginResult{UserID: user.ID, StudentID: user.StudentID}, nil +} diff --git a/apps/api/internal/service/auth/web_login_test.go b/apps/api/internal/service/auth/web_login_test.go new file mode 100644 index 0000000..fc54554 --- /dev/null +++ b/apps/api/internal/service/auth/web_login_test.go @@ -0,0 +1,119 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "labkit.local/apps/api/internal/config" + "labkit.local/packages/go/db/sqlc" +) + +func TestBuildWebLoginAuthorizeURLReturnsStateAndURL(t *testing.T) { + repo := newFakeRepository() + provider := &fakeProvider{} + svc := newTestServiceWithProvider(repo, provider) + + url, state, err := svc.BuildWebLoginAuthorizeURL() + if err != nil { + t.Fatalf("BuildWebLoginAuthorizeURL() error = %v", err) + } + if state != "oauth-state" { + t.Fatalf("state = %q, want %q", state, "oauth-state") + } + if url != "https://example.invalid/auth?state=oauth-state" { + t.Fatalf("url = %q, want provider output", url) + } + if provider.authorizeCalls != 1 { + t.Fatalf("BuildAuthorizeURL called %d times, want 1", provider.authorizeCalls) + } +} + +func TestBuildWebLoginAuthorizeURLRequiresProvider(t *testing.T) { + repo := newFakeRepository() + svc := NewService(repo, nil, config.OAuthConfig{DeviceAuthTTL: time.Minute}) + + _, _, err := svc.BuildWebLoginAuthorizeURL() + if !errors.Is(err, ErrProviderNotConfigured) { + t.Fatalf("BuildWebLoginAuthorizeURL() error = %v, want ErrProviderNotConfigured", err) + } +} + +func TestHandleWebLoginCallbackUpsertsUser(t *testing.T) { + repo := newFakeRepository() + provider := &fakeProvider{ + tokenSet: TokenSet{AccessToken: "access-token"}, + identity: ExternalIdentity{StudentID: "2026001"}, + } + svc := newTestServiceWithProvider(repo, provider) + + result, err := svc.HandleWebLoginCallback(context.Background(), "auth-code") + if err != nil { + t.Fatalf("HandleWebLoginCallback() error = %v", err) + } + if result.StudentID != "2026001" { + t.Fatalf("StudentID = %q, want %q", result.StudentID, "2026001") + } + if result.UserID != 1 { + t.Fatalf("UserID = %d, want %d", result.UserID, 1) + } + if got := repo.usersByStudentID["2026001"].ID; got != 1 { + t.Fatalf("user id = %d, want %d", got, 1) + } + if provider.exchangeCalls != 1 { + t.Fatalf("ExchangeCode called %d times, want 1", provider.exchangeCalls) + } + if provider.identityCalls != 1 { + t.Fatalf("FetchIdentity called %d times, want 1", provider.identityCalls) + } + // Web login must never touch the device tables. + if repo.completeCalls != 0 { + t.Fatalf("CompleteDeviceAuthRequest called %d times, want 0", repo.completeCalls) + } + if len(repo.keysByID) != 0 { + t.Fatalf("unexpected user key inserted: %d", len(repo.keysByID)) + } +} + +func TestHandleWebLoginCallbackReusesExistingUser(t *testing.T) { + repo := newFakeRepository() + // Simulate a user that already exists (e.g. created via the CLI device flow). + repo.usersByStudentID["2026001"] = sqlc.Users{ID: 7, StudentID: "2026001"} + repo.nextUserID = 8 + provider := &fakeProvider{identity: ExternalIdentity{StudentID: "2026001"}} + svc := newTestServiceWithProvider(repo, provider) + + result, err := svc.HandleWebLoginCallback(context.Background(), "auth-code") + if err != nil { + t.Fatalf("HandleWebLoginCallback() error = %v", err) + } + if result.UserID != 7 { + t.Fatalf("UserID = %d, want %d (existing CLI user)", result.UserID, 7) + } +} + +func TestHandleWebLoginCallbackRejectsEmptyStudentID(t *testing.T) { + repo := newFakeRepository() + provider := &fakeProvider{identity: ExternalIdentity{StudentID: ""}} + svc := newTestServiceWithProvider(repo, provider) + + _, err := svc.HandleWebLoginCallback(context.Background(), "auth-code") + if !errors.Is(err, ErrInvalidCode) { + t.Fatalf("HandleWebLoginCallback() error = %v, want ErrInvalidCode", err) + } +} + +func TestHandleWebLoginCallbackRejectsEmptyCode(t *testing.T) { + repo := newFakeRepository() + provider := &fakeProvider{} + svc := newTestServiceWithProvider(repo, provider) + + _, err := svc.HandleWebLoginCallback(context.Background(), " ") + if !errors.Is(err, ErrInvalidCode) { + t.Fatalf("HandleWebLoginCallback() error = %v, want ErrInvalidCode", err) + } + if provider.exchangeCalls != 0 { + t.Fatalf("ExchangeCode called %d times, want 0", provider.exchangeCalls) + } +} diff --git a/apps/api/internal/service/grade/repo.go b/apps/api/internal/service/grade/repo.go new file mode 100644 index 0000000..44ead84 --- /dev/null +++ b/apps/api/internal/service/grade/repo.go @@ -0,0 +1,43 @@ +package grade + +import ( + "context" + + dbpkg "labkit.local/packages/go/db" + "labkit.local/packages/go/db/sqlc" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Repository is the DB surface used by the grade service. +type Repository interface { + UpsertFinalGrade(context.Context, sqlc.UpsertFinalGradeParams) (sqlc.FinalGrades, error) + GetFinalGrade(context.Context, sqlc.GetFinalGradeParams) (sqlc.FinalGrades, error) + PublishFinalGrades(context.Context, string) (int64, error) + DeleteFinalGradesByLab(context.Context, string) (int64, error) +} + +type repo struct { + store *dbpkg.Store +} + +// NewRepository builds a grade Repository backed by the shared sqlc store. +func NewRepository(pool *pgxpool.Pool) Repository { + return &repo{store: dbpkg.New(pool)} +} + +func (r *repo) UpsertFinalGrade(ctx context.Context, arg sqlc.UpsertFinalGradeParams) (sqlc.FinalGrades, error) { + return r.store.UpsertFinalGrade(ctx, arg) +} + +func (r *repo) GetFinalGrade(ctx context.Context, arg sqlc.GetFinalGradeParams) (sqlc.FinalGrades, error) { + return r.store.GetFinalGrade(ctx, arg) +} + +func (r *repo) PublishFinalGrades(ctx context.Context, labID string) (int64, error) { + return r.store.PublishFinalGrades(ctx, labID) +} + +func (r *repo) DeleteFinalGradesByLab(ctx context.Context, labID string) (int64, error) { + return r.store.DeleteFinalGradesByLab(ctx, labID) +} diff --git a/apps/api/internal/service/grade/service.go b/apps/api/internal/service/grade/service.go new file mode 100644 index 0000000..a7a754e --- /dev/null +++ b/apps/api/internal/service/grade/service.go @@ -0,0 +1,290 @@ +package grade + +import ( + "context" + "encoding/csv" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "labkit.local/packages/go/db/sqlc" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ( + // ErrGradeNotFound is returned when no published grade exists for the lookup. + ErrGradeNotFound = errors.New("final grade not found") + // ErrInvalidCSV signals a malformed grades CSV (missing required columns, + // unparseable numbers, etc). + ErrInvalidCSV = errors.New("invalid grades csv") + // ErrInvalidLab signals a blank lab id. + ErrInvalidLab = errors.New("invalid lab id") +) + +// Service reads and imports externally-computed final course grades. +type Service struct { + repo Repository +} + +// NewService builds the grade service. +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +// Grade is the student-facing view of a final course grade. +type Grade struct { + LabID string `json:"lab_id"` + StudentID string `json:"student_id"` + Total float64 `json:"total"` + Track string `json:"track,omitempty"` + Ratio *float64 `json:"ratio,omitempty"` + PerfScore *float64 `json:"perf_score,omitempty"` + Percentile *float64 `json:"percentile,omitempty"` + BoardScore *float64 `json:"board_score,omitempty"` + Remark string `json:"remark,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ImportGradesResult reports how many rows were upserted. +type ImportGradesResult struct { + LabID string `json:"lab_id"` + Imported int `json:"imported"` +} + +// PublishGradesResult reports how many rows became visible. +type PublishGradesResult struct { + LabID string `json:"lab_id"` + Published int64 `json:"published"` +} + +// GetGrade returns the published grade for a (lab, student), or ErrGradeNotFound. +func (s *Service) GetGrade(ctx context.Context, labID, studentID string) (Grade, error) { + if s == nil || s.repo == nil { + return Grade{}, fmt.Errorf("grade service unavailable") + } + labID = strings.TrimSpace(labID) + studentID = strings.TrimSpace(studentID) + if labID == "" || studentID == "" { + return Grade{}, ErrGradeNotFound + } + row, err := s.repo.GetFinalGrade(ctx, sqlc.GetFinalGradeParams{LabID: labID, StudentID: studentID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Grade{}, ErrGradeNotFound + } + return Grade{}, err + } + return gradeFromRow(row), nil +} + +// ImportGrades parses a TA-produced CSV and upserts each row into final_grades +// (left unpublished). Columns are matched by header name, so order is flexible +// and unknown columns are ignored. Required: student_id, total. +func (s *Service) ImportGrades(ctx context.Context, labID string, r io.Reader) (ImportGradesResult, error) { + if s == nil || s.repo == nil { + return ImportGradesResult{}, fmt.Errorf("grade service unavailable") + } + labID = strings.TrimSpace(labID) + if labID == "" { + return ImportGradesResult{}, ErrInvalidLab + } + + reader := csv.NewReader(r) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + + header, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + return ImportGradesResult{}, fmt.Errorf("%w: empty csv", ErrInvalidCSV) + } + return ImportGradesResult{}, fmt.Errorf("%w: %v", ErrInvalidCSV, err) + } + index := headerIndex(header) + if _, ok := index["student_id"]; !ok { + return ImportGradesResult{}, fmt.Errorf("%w: missing student_id column", ErrInvalidCSV) + } + if _, ok := index["total"]; !ok { + return ImportGradesResult{}, fmt.Errorf("%w: missing total column", ErrInvalidCSV) + } + + imported := 0 + rowNum := 1 // header consumed + for { + record, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return ImportGradesResult{}, fmt.Errorf("%w: %v", ErrInvalidCSV, err) + } + rowNum++ + if isBlankRecord(record) { + continue + } + params, err := parseGradeRecord(labID, index, record) + if err != nil { + return ImportGradesResult{}, fmt.Errorf("%w: row %d: %v", ErrInvalidCSV, rowNum, err) + } + if params == nil { + continue // blank student id → skip + } + if _, err := s.repo.UpsertFinalGrade(ctx, *params); err != nil { + return ImportGradesResult{}, err + } + imported++ + } + return ImportGradesResult{LabID: labID, Imported: imported}, nil +} + +// PublishGrades makes all currently-unpublished grades for a lab visible. +func (s *Service) PublishGrades(ctx context.Context, labID string) (PublishGradesResult, error) { + if s == nil || s.repo == nil { + return PublishGradesResult{}, fmt.Errorf("grade service unavailable") + } + labID = strings.TrimSpace(labID) + if labID == "" { + return PublishGradesResult{}, ErrInvalidLab + } + n, err := s.repo.PublishFinalGrades(ctx, labID) + if err != nil { + return PublishGradesResult{}, err + } + return PublishGradesResult{LabID: labID, Published: n}, nil +} + +func headerIndex(header []string) map[string]int { + index := make(map[string]int, len(header)) + for i, name := range header { + key := strings.ToLower(strings.TrimSpace(name)) + // Strip a UTF-8 BOM that spreadsheet exports often prepend. + key = strings.TrimPrefix(key, "\ufeff") + if key == "" { + continue + } + if _, exists := index[key]; !exists { + index[key] = i + } + } + return index +} + +func parseGradeRecord(labID string, index map[string]int, record []string) (*sqlc.UpsertFinalGradeParams, error) { + get := func(col string) string { + i, ok := index[col] + if !ok || i >= len(record) { + return "" + } + return strings.TrimSpace(record[i]) + } + + studentID := get("student_id") + if studentID == "" { + return nil, nil + } + + totalText := get("total") + if totalText == "" { + return nil, errors.New("missing total") + } + total, err := strconv.ParseFloat(totalText, 32) + if err != nil { + return nil, fmt.Errorf("invalid total %q", totalText) + } + + ratio, err := optionalFloat4(get("ratio")) + if err != nil { + return nil, fmt.Errorf("invalid ratio: %w", err) + } + perfScore, err := optionalFloat4(get("perf_score")) + if err != nil { + return nil, fmt.Errorf("invalid perf_score: %w", err) + } + percentile, err := optionalFloat4(get("percentile")) + if err != nil { + return nil, fmt.Errorf("invalid percentile: %w", err) + } + boardScore, err := optionalFloat4(get("board_score")) + if err != nil { + return nil, fmt.Errorf("invalid board_score: %w", err) + } + + return &sqlc.UpsertFinalGradeParams{ + LabID: labID, + StudentID: studentID, + Total: float32(total), + Track: optionalText(get("track")), + Ratio: ratio, + PerfScore: perfScore, + Percentile: percentile, + BoardScore: boardScore, + Remark: optionalText(get("remark")), + }, nil +} + +func optionalText(value string) pgtype.Text { + if value == "" { + return pgtype.Text{} + } + return pgtype.Text{String: value, Valid: true} +} + +func optionalFloat4(value string) (pgtype.Float4, error) { + if value == "" { + return pgtype.Float4{}, nil + } + f, err := strconv.ParseFloat(value, 32) + if err != nil { + return pgtype.Float4{}, fmt.Errorf("%q", value) + } + return pgtype.Float4{Float32: float32(f), Valid: true}, nil +} + +func isBlankRecord(record []string) bool { + for _, field := range record { + if strings.TrimSpace(field) != "" { + return false + } + } + return true +} + +func gradeFromRow(row sqlc.FinalGrades) Grade { + g := Grade{ + LabID: row.LabID, + StudentID: row.StudentID, + Total: float64(row.Total), + Ratio: float4ToPtr(row.Ratio), + PerfScore: float4ToPtr(row.PerfScore), + Percentile: float4ToPtr(row.Percentile), + BoardScore: float4ToPtr(row.BoardScore), + } + if row.Track.Valid { + g.Track = strings.TrimSpace(row.Track.String) + } + if row.Remark.Valid { + g.Remark = strings.TrimSpace(row.Remark.String) + } + if row.PublishedAt.Valid { + published := row.PublishedAt.Time.UTC() + g.PublishedAt = &published + } + if row.UpdatedAt.Valid { + g.UpdatedAt = row.UpdatedAt.Time.UTC() + } + return g +} + +func float4ToPtr(v pgtype.Float4) *float64 { + if !v.Valid { + return nil + } + f := float64(v.Float32) + return &f +} diff --git a/apps/api/internal/service/grade/service_test.go b/apps/api/internal/service/grade/service_test.go new file mode 100644 index 0000000..f905e7e --- /dev/null +++ b/apps/api/internal/service/grade/service_test.go @@ -0,0 +1,207 @@ +package grade + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "labkit.local/packages/go/db/sqlc" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type fakeGradeRepo struct { + rows map[string]sqlc.FinalGrades // key: lab|student + upsertCalls int + publishCalls int +} + +func newFakeGradeRepo() *fakeGradeRepo { + return &fakeGradeRepo{rows: make(map[string]sqlc.FinalGrades)} +} + +func gradeKey(labID, studentID string) string { return labID + "|" + studentID } + +func (r *fakeGradeRepo) UpsertFinalGrade(_ context.Context, arg sqlc.UpsertFinalGradeParams) (sqlc.FinalGrades, error) { + r.upsertCalls++ + key := gradeKey(arg.LabID, arg.StudentID) + existing := r.rows[key] + row := sqlc.FinalGrades{ + LabID: arg.LabID, + StudentID: arg.StudentID, + Total: arg.Total, + Track: arg.Track, + Ratio: arg.Ratio, + PerfScore: arg.PerfScore, + Percentile: arg.Percentile, + BoardScore: arg.BoardScore, + Remark: arg.Remark, + PublishedAt: existing.PublishedAt, // upsert preserves visibility + UpdatedAt: pgtype.Timestamptz{Time: time.Unix(1700000000, 0).UTC(), Valid: true}, + } + r.rows[key] = row + return row, nil +} + +func (r *fakeGradeRepo) GetFinalGrade(_ context.Context, arg sqlc.GetFinalGradeParams) (sqlc.FinalGrades, error) { + row, ok := r.rows[gradeKey(arg.LabID, arg.StudentID)] + if !ok || !row.PublishedAt.Valid { + return sqlc.FinalGrades{}, pgx.ErrNoRows + } + return row, nil +} + +func (r *fakeGradeRepo) PublishFinalGrades(_ context.Context, labID string) (int64, error) { + r.publishCalls++ + var n int64 + for key, row := range r.rows { + if row.LabID == labID && !row.PublishedAt.Valid { + row.PublishedAt = pgtype.Timestamptz{Time: time.Unix(1700000000, 0).UTC(), Valid: true} + r.rows[key] = row + n++ + } + } + return n, nil +} + +func (r *fakeGradeRepo) DeleteFinalGradesByLab(_ context.Context, labID string) (int64, error) { + var n int64 + for key, row := range r.rows { + if row.LabID == labID { + delete(r.rows, key) + n++ + } + } + return n, nil +} + +func TestImportGradesParsesByHeaderName(t *testing.T) { + repo := newFakeGradeRepo() + svc := NewService(repo) + csv := strings.Join([]string{ + "student_id,track,ratio,perf_score,percentile,board_score,total,remark", + "2026001,throughput,1.2,85.0,0.9,14.0,86.5,looks good", + "2026002,latency,1.0,70,0.5,10,72,", + }, "\n") + + result, err := svc.ImportGrades(context.Background(), "colab-2026-p2", strings.NewReader(csv)) + if err != nil { + t.Fatalf("ImportGrades() error = %v", err) + } + if result.Imported != 2 { + t.Fatalf("imported = %d, want 2", result.Imported) + } + row := repo.rows[gradeKey("colab-2026-p2", "2026001")] + if row.Total != 86.5 { + t.Fatalf("total = %v, want 86.5", row.Total) + } + if !row.Track.Valid || row.Track.String != "throughput" { + t.Fatalf("track = %+v, want throughput", row.Track) + } + if !row.PerfScore.Valid || row.PerfScore.Float32 != 85.0 { + t.Fatalf("perf_score = %+v, want 85", row.PerfScore) + } + if !row.Remark.Valid || row.Remark.String != "looks good" { + t.Fatalf("remark = %+v, want 'looks good'", row.Remark) + } + // Empty optional cells stay NULL. + row2 := repo.rows[gradeKey("colab-2026-p2", "2026002")] + if row2.Remark.Valid { + t.Fatalf("remark should be NULL for empty cell, got %+v", row2.Remark) + } +} + +func TestImportGradesColumnOrderFlexibleAndExtraColumnsIgnored(t *testing.T) { + repo := newFakeGradeRepo() + svc := NewService(repo) + csv := strings.Join([]string{ + "total,student_id,extra_col", + "91.0,2026003,ignored", + }, "\n") + + result, err := svc.ImportGrades(context.Background(), "lab", strings.NewReader(csv)) + if err != nil { + t.Fatalf("ImportGrades() error = %v", err) + } + if result.Imported != 1 { + t.Fatalf("imported = %d, want 1", result.Imported) + } + row := repo.rows[gradeKey("lab", "2026003")] + if row.Total != 91.0 { + t.Fatalf("total = %v, want 91", row.Total) + } +} + +func TestImportGradesRejectsMissingRequiredColumns(t *testing.T) { + svc := NewService(newFakeGradeRepo()) + _, err := svc.ImportGrades(context.Background(), "lab", strings.NewReader("student_id,track\n2026001,x\n")) + if !errors.Is(err, ErrInvalidCSV) { + t.Fatalf("error = %v, want ErrInvalidCSV (missing total)", err) + } +} + +func TestImportGradesRejectsBadNumber(t *testing.T) { + svc := NewService(newFakeGradeRepo()) + _, err := svc.ImportGrades(context.Background(), "lab", strings.NewReader("student_id,total\n2026001,notnum\n")) + if !errors.Is(err, ErrInvalidCSV) { + t.Fatalf("error = %v, want ErrInvalidCSV", err) + } + if !strings.Contains(err.Error(), "row 2") { + t.Fatalf("error should mention row number, got %v", err) + } +} + +func TestImportGradesSkipsBlankRowsAndStudentless(t *testing.T) { + repo := newFakeGradeRepo() + svc := NewService(repo) + csv := "student_id,total\n2026001,80\n\n,55\n2026002,82\n" + result, err := svc.ImportGrades(context.Background(), "lab", strings.NewReader(csv)) + if err != nil { + t.Fatalf("ImportGrades() error = %v", err) + } + if result.Imported != 2 { + t.Fatalf("imported = %d, want 2 (blank + studentless skipped)", result.Imported) + } +} + +func TestGetGradeUnpublishedThenPublished(t *testing.T) { + repo := newFakeGradeRepo() + svc := NewService(repo) + if _, err := svc.ImportGrades(context.Background(), "lab", strings.NewReader("student_id,total\n2026001,88\n")); err != nil { + t.Fatalf("ImportGrades() error = %v", err) + } + + // Unpublished → not found. + if _, err := svc.GetGrade(context.Background(), "lab", "2026001"); !errors.Is(err, ErrGradeNotFound) { + t.Fatalf("GetGrade() before publish error = %v, want ErrGradeNotFound", err) + } + + publishRes, err := svc.PublishGrades(context.Background(), "lab") + if err != nil { + t.Fatalf("PublishGrades() error = %v", err) + } + if publishRes.Published != 1 { + t.Fatalf("published = %d, want 1", publishRes.Published) + } + + grade, err := svc.GetGrade(context.Background(), "lab", "2026001") + if err != nil { + t.Fatalf("GetGrade() after publish error = %v", err) + } + if grade.Total != 88 { + t.Fatalf("total = %v, want 88", grade.Total) + } + if grade.PublishedAt == nil { + t.Fatal("published_at should be set after publish") + } +} + +func TestGetGradeMissingStudent(t *testing.T) { + svc := NewService(newFakeGradeRepo()) + if _, err := svc.GetGrade(context.Background(), "lab", "nobody"); !errors.Is(err, ErrGradeNotFound) { + t.Fatalf("GetGrade() error = %v, want ErrGradeNotFound", err) + } +} diff --git a/apps/migrate/internal/runner/runner_test.go b/apps/migrate/internal/runner/runner_test.go index 30defd0..b693919 100644 --- a/apps/migrate/internal/runner/runner_test.go +++ b/apps/migrate/internal/runner/runner_test.go @@ -38,7 +38,7 @@ func TestUpAppliesAllMigrationsOnCleanDatabase(t *testing.T) { if err != nil { t.Fatalf("Version() error = %v", err) } - if got, want := version, uint(10); got != want { + if got, want := version, uint(11); got != want { t.Fatalf("version = %d, want %d", got, want) } if dirty { @@ -47,6 +47,7 @@ func TestUpAppliesAllMigrationsOnCleanDatabase(t *testing.T) { assertTableExists(t, ctx, env.pool, "labs") assertTableExists(t, ctx, env.pool, "evaluation_jobs") assertTableExists(t, ctx, env.pool, "web_session_tickets") + assertTableExists(t, ctx, env.pool, "final_grades") } func TestUpRejectsLegacyDatabaseWithoutVersionTable(t *testing.T) { diff --git a/apps/web/src/lib/grade.ts b/apps/web/src/lib/grade.ts new file mode 100644 index 0000000..d9a7aa8 --- /dev/null +++ b/apps/web/src/lib/grade.ts @@ -0,0 +1,49 @@ +import { readAPIError } from './http'; + +// Default lab shown at the bare /grade route. Lab2 (CoLab) is the first lab to +// publish a final course grade through LabKit. +export const DEFAULT_GRADE_LAB_ID = 'colab-2026-p2'; + +export type FinalGrade = { + lab_id: string; + student_id: string; + total: number; + track?: string; + ratio?: number; + perf_score?: number; + percentile?: number; + board_score?: number; + remark?: string; + published_at?: string; + updated_at: string; +}; + +export type GradeResult = + | { status: 'ok'; grade: FinalGrade } + | { status: 'unauthorized' } + | { status: 'unpublished' } + | { status: 'error'; message: string }; + +export async function getMyGrade(labId: string): Promise { + try { + const response = await fetch(`/api/labs/${encodeURIComponent(labId)}/grade`, { + credentials: 'include' + }); + if (response.status === 401) { + return { status: 'unauthorized' }; + } + if (response.status === 404) { + return { status: 'unpublished' }; + } + if (!response.ok) { + return { status: 'error', message: await readAPIError(response, '加载成绩失败') }; + } + const grade = (await response.json()) as FinalGrade; + return { status: 'ok', grade }; + } catch (error) { + return { + status: 'error', + message: error instanceof Error ? error.message : '加载成绩失败' + }; + } +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 937438b..64678f1 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -76,6 +76,7 @@ const App = defineComponent({ ]), h('nav', { class: 'app-shell__utility', 'aria-label': 'Utility' }, [ showAdmin.value ? h(RouterLink, { to: '/admin', class: 'app-shell__utility-link' }, { default: () => 'Admin' }) : null, + h(RouterLink, { to: '/grade', class: 'app-shell__utility-link' }, { default: () => 'Grade' }), h(RouterLink, { to: '/profile', class: 'app-shell__utility-link' }, { default: () => 'Profile' }), ]), statusPhase.value diff --git a/apps/web/src/router.test.ts b/apps/web/src/router.test.ts index 395ab55..7d69f8f 100644 --- a/apps/web/src/router.test.ts +++ b/apps/web/src/router.test.ts @@ -53,4 +53,34 @@ describe('router', () => { expect(router.currentRoute.value.name).toBe('profile'); expect(router.currentRoute.value.path).toBe('/profile'); }); + + it('routes /login to the login screen', async () => { + const router = createAppRouter(createMemoryHistory()); + + await router.push('/login?next=/grade'); + await router.isReady(); + + expect(router.currentRoute.value.name).toBe('login'); + expect(router.currentRoute.value.query.next).toBe('/grade'); + }); + + it('routes /grade to the grade screen', async () => { + const router = createAppRouter(createMemoryHistory()); + + await router.push('/grade'); + await router.isReady(); + + expect(router.currentRoute.value.name).toBe('grade'); + expect(router.currentRoute.value.path).toBe('/grade'); + }); + + it('routes lab-scoped grade URLs to the grade screen', async () => { + const router = createAppRouter(createMemoryHistory()); + + await router.push('/labs/colab-2026-p2/grade'); + await router.isReady(); + + expect(router.currentRoute.value.name).toBe('lab-grade'); + expect(router.currentRoute.value.params.labID).toBe('colab-2026-p2'); + }); }); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 3e68cbf..73a618e 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -4,8 +4,10 @@ import AdminLoginView from './views/AdminLoginView.vue'; import AdminQueueView from './views/AdminQueueView.vue'; import AuthConfirmView from './views/AuthConfirmView.vue'; import DeviceAuthView from './views/DeviceAuthView.vue'; +import GradeView from './views/GradeView.vue'; import HistoryView from './views/HistoryView.vue'; import LabListView from './views/LabListView.vue'; +import LoginView from './views/LoginView.vue'; import ProfileView from './views/ProfileView.vue'; import LeaderboardView from './views/LeaderboardView.vue'; import { readAdminToken, sessionToken } from './lib/admin'; @@ -45,6 +47,24 @@ export function createAppRouter(history = createWebHistory()) { name: 'auth-confirm', component: AuthConfirmView }, + { + path: '/login', + name: 'login', + component: LoginView + }, + { + path: '/grade', + name: 'grade', + component: GradeView + }, + { + path: '/labs/:labID/grade', + name: 'lab-grade', + component: GradeView, + props: (route) => ({ + labId: String(route.params.labID) + }) + }, { path: '/profile', name: 'profile', diff --git a/apps/web/src/views/GradeView.test.ts b/apps/web/src/views/GradeView.test.ts new file mode 100644 index 0000000..a597b2f --- /dev/null +++ b/apps/web/src/views/GradeView.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import GradeView from './GradeView.vue'; + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +} + +function jsonResponse(payload: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload) + } as Response; +} + +async function mountGradeView(url = '/grade') { + const el = document.createElement('div'); + document.body.appendChild(el); + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/grade', component: GradeView }, + { path: '/labs/:labID/grade', component: GradeView, props: (route) => ({ labId: String(route.params.labID) }) }, + { path: '/login', component: { render: () => null } } + ] + }); + const app = createApp(defineComponent({ render: () => h('div', [h(GradeView)]) })); + app.use(router); + await router.push(url); + await router.isReady(); + app.mount(el); + await flush(); + return { + router, + unmount() { + app.unmount(); + el.remove(); + } + }; +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('GradeView', () => { + it('renders the total and breakdown for a published grade', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + lab_id: 'colab-2026-p2', + student_id: '2026001', + total: 86.5, + track: 'throughput', + ratio: 1.2, + perf_score: 85, + percentile: 0.91, + board_score: 14, + remark: '复核无误', + updated_at: '2026-06-20T10:00:00Z' + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const view = await mountGradeView('/grade'); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/labs/colab-2026-p2/grade', + expect.objectContaining({ credentials: 'include' }) + ); + expect(document.body.textContent).toContain('86.50'); + expect(document.body.textContent).toContain('throughput'); + expect(document.body.textContent).toContain('总评 = 0.85 × 性能分 + 0.15 × 打榜分'); + expect(document.body.textContent).toContain('复核无误'); + + view.unmount(); + }); + + it('uses the labID route param when present', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ lab_id: 'sorting', student_id: '2026001', total: 90, updated_at: '2026-06-20T10:00:00Z' }) + ); + vi.stubGlobal('fetch', fetchMock); + + const view = await mountGradeView('/labs/sorting/grade'); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/labs/sorting/grade', + expect.objectContaining({ credentials: 'include' }) + ); + + view.unmount(); + }); + + it('shows the not-published state on 404', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: '成绩尚未发布' } }, 404))); + + const view = await mountGradeView('/grade'); + + expect(document.body.textContent).toContain('成绩尚未发布'); + + view.unmount(); + }); + + it('shows a login prompt on 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'unauthorized' } }, 401))); + + const view = await mountGradeView('/grade'); + + expect(document.body.textContent).toContain('需要登录'); + expect(document.body.textContent).toContain('前往登录'); + + view.unmount(); + }); +}); diff --git a/apps/web/src/views/GradeView.vue b/apps/web/src/views/GradeView.vue new file mode 100644 index 0000000..0522ba9 --- /dev/null +++ b/apps/web/src/views/GradeView.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/apps/web/src/views/LoginView.test.ts b/apps/web/src/views/LoginView.test.ts new file mode 100644 index 0000000..70fa6f7 --- /dev/null +++ b/apps/web/src/views/LoginView.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import LoginView from './LoginView.vue'; + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +} + +function jsonResponse(payload: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload) + } as Response; +} + +async function mountLoginView(url = '/login') { + const el = document.createElement('div'); + document.body.appendChild(el); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/login', component: LoginView }] + }); + const app = createApp(defineComponent({ render: () => h('div', [h(LoginView)]) })); + app.use(router); + await router.push(url); + await router.isReady(); + app.mount(el); + await flush(); + return { + unmount() { + app.unmount(); + el.remove(); + } + }; +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('LoginView', () => { + it('shows the 微人大 login button when not signed in', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'unauthorized' } }, 401))); + + const view = await mountLoginView('/login'); + + expect(document.querySelector('[data-testid="login-cas"]')).not.toBeNull(); + expect(document.body.textContent).toContain('微人大登录'); + + view.unmount(); + }); + + it('shows a continue action when already signed in', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ user_id: 7, student_id: '2026001', nickname: 'Aki' }))); + + const view = await mountLoginView('/login'); + + expect(document.body.textContent).toContain('已登录为 2026001'); + expect(document.body.textContent).toContain('继续查看成绩'); + + view.unmount(); + }); +}); diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue new file mode 100644 index 0000000..fbb5cc4 --- /dev/null +++ b/apps/web/src/views/LoginView.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1561a3e..97af1f4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ '/api': { target: apiProxyTarget, changeOrigin: true + }, + // Browser-only 微人大 login start endpoint lives on the API under /auth. + '/auth/login': { + target: apiProxyTarget, + changeOrigin: true } } }, diff --git a/db/migrations/0011_final_grades.down.sql b/db/migrations/0011_final_grades.down.sql new file mode 100644 index 0000000..d4f8194 --- /dev/null +++ b/db/migrations/0011_final_grades.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE final_grades; + +COMMIT; diff --git a/db/migrations/0011_final_grades.up.sql b/db/migrations/0011_final_grades.up.sql new file mode 100644 index 0000000..b6bdb33 --- /dev/null +++ b/db/migrations/0011_final_grades.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE final_grades ( + lab_id TEXT NOT NULL, + student_id TEXT NOT NULL, + total REAL NOT NULL, -- 总评 + track TEXT, -- 选定赛道 + ratio REAL, -- r(赛道倍率) + perf_score REAL, -- 性能分(85%) + percentile REAL, -- p(赛道内百分位) + board_score REAL, -- 打榜分(15%) + remark TEXT, -- 申诉 / 备注 + published_at TIMESTAMPTZ, -- NULL=暂存不可见;有值=学生可见 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (lab_id, student_id) +); + +COMMIT; diff --git a/db/queries/auth.sql b/db/queries/auth.sql index bc696f5..c64104e 100644 --- a/db/queries/auth.sql +++ b/db/queries/auth.sql @@ -3,6 +3,12 @@ INSERT INTO users (student_id) VALUES ($1) RETURNING *; +-- name: UpsertUser :one +INSERT INTO users (student_id) +VALUES ($1) +ON CONFLICT (student_id) DO UPDATE SET student_id = EXCLUDED.student_id +RETURNING id, student_id; + -- name: GetUserByID :one SELECT * FROM users diff --git a/db/queries/grades.sql b/db/queries/grades.sql new file mode 100644 index 0000000..aa3d873 --- /dev/null +++ b/db/queries/grades.sql @@ -0,0 +1,35 @@ +-- name: UpsertFinalGrade :one +INSERT INTO final_grades ( + lab_id, student_id, total, track, ratio, perf_score, percentile, board_score, remark, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) +ON CONFLICT (lab_id, student_id) DO UPDATE SET + total = EXCLUDED.total, + track = EXCLUDED.track, + ratio = EXCLUDED.ratio, + perf_score = EXCLUDED.perf_score, + percentile = EXCLUDED.percentile, + board_score = EXCLUDED.board_score, + remark = EXCLUDED.remark, + updated_at = NOW() +RETURNING *; + +-- name: GetFinalGrade :one +SELECT * +FROM final_grades +WHERE lab_id = $1 + AND student_id = $2 + AND published_at IS NOT NULL + AND published_at <= NOW() +LIMIT 1; + +-- name: PublishFinalGrades :execrows +UPDATE final_grades +SET published_at = NOW(), + updated_at = NOW() +WHERE lab_id = $1 + AND published_at IS NULL; + +-- name: DeleteFinalGradesByLab :execrows +DELETE FROM final_grades +WHERE lab_id = $1; diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 00d4403..da9581f 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -13,6 +13,10 @@ reverse_proxy api:8080 } + handle /auth/login* { + reverse_proxy api:8080 + } + handle { reverse_proxy web:80 } diff --git a/packages/go/db/sqlc/auth.sql.go b/packages/go/db/sqlc/auth.sql.go index 4e649df..4a44d66 100644 --- a/packages/go/db/sqlc/auth.sql.go +++ b/packages/go/db/sqlc/auth.sql.go @@ -483,3 +483,22 @@ func (q *Queries) UpdateUserNickname(ctx context.Context, arg UpdateUserNickname ) return i, err } + +const upsertUser = `-- name: UpsertUser :one +INSERT INTO users (student_id) +VALUES ($1) +ON CONFLICT (student_id) DO UPDATE SET student_id = EXCLUDED.student_id +RETURNING id, student_id +` + +type UpsertUserRow struct { + ID int64 `json:"id"` + StudentID string `json:"student_id"` +} + +func (q *Queries) UpsertUser(ctx context.Context, studentID string) (UpsertUserRow, error) { + row := q.db.QueryRow(ctx, upsertUser, studentID) + var i UpsertUserRow + err := row.Scan(&i.ID, &i.StudentID) + return i, err +} diff --git a/packages/go/db/sqlc/grades.sql.go b/packages/go/db/sqlc/grades.sql.go new file mode 100644 index 0000000..1e63202 --- /dev/null +++ b/packages/go/db/sqlc/grades.sql.go @@ -0,0 +1,133 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: grades.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteFinalGradesByLab = `-- name: DeleteFinalGradesByLab :execrows +DELETE FROM final_grades +WHERE lab_id = $1 +` + +func (q *Queries) DeleteFinalGradesByLab(ctx context.Context, labID string) (int64, error) { + result, err := q.db.Exec(ctx, deleteFinalGradesByLab, labID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getFinalGrade = `-- name: GetFinalGrade :one +SELECT lab_id, student_id, total, track, ratio, perf_score, percentile, board_score, remark, published_at, updated_at +FROM final_grades +WHERE lab_id = $1 + AND student_id = $2 + AND published_at IS NOT NULL + AND published_at <= NOW() +LIMIT 1 +` + +type GetFinalGradeParams struct { + LabID string `json:"lab_id"` + StudentID string `json:"student_id"` +} + +func (q *Queries) GetFinalGrade(ctx context.Context, arg GetFinalGradeParams) (FinalGrades, error) { + row := q.db.QueryRow(ctx, getFinalGrade, arg.LabID, arg.StudentID) + var i FinalGrades + err := row.Scan( + &i.LabID, + &i.StudentID, + &i.Total, + &i.Track, + &i.Ratio, + &i.PerfScore, + &i.Percentile, + &i.BoardScore, + &i.Remark, + &i.PublishedAt, + &i.UpdatedAt, + ) + return i, err +} + +const publishFinalGrades = `-- name: PublishFinalGrades :execrows +UPDATE final_grades +SET published_at = NOW(), + updated_at = NOW() +WHERE lab_id = $1 + AND published_at IS NULL +` + +func (q *Queries) PublishFinalGrades(ctx context.Context, labID string) (int64, error) { + result, err := q.db.Exec(ctx, publishFinalGrades, labID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const upsertFinalGrade = `-- name: UpsertFinalGrade :one +INSERT INTO final_grades ( + lab_id, student_id, total, track, ratio, perf_score, percentile, board_score, remark, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) +ON CONFLICT (lab_id, student_id) DO UPDATE SET + total = EXCLUDED.total, + track = EXCLUDED.track, + ratio = EXCLUDED.ratio, + perf_score = EXCLUDED.perf_score, + percentile = EXCLUDED.percentile, + board_score = EXCLUDED.board_score, + remark = EXCLUDED.remark, + updated_at = NOW() +RETURNING lab_id, student_id, total, track, ratio, perf_score, percentile, board_score, remark, published_at, updated_at +` + +type UpsertFinalGradeParams struct { + LabID string `json:"lab_id"` + StudentID string `json:"student_id"` + Total float32 `json:"total"` + Track pgtype.Text `json:"track"` + Ratio pgtype.Float4 `json:"ratio"` + PerfScore pgtype.Float4 `json:"perf_score"` + Percentile pgtype.Float4 `json:"percentile"` + BoardScore pgtype.Float4 `json:"board_score"` + Remark pgtype.Text `json:"remark"` +} + +func (q *Queries) UpsertFinalGrade(ctx context.Context, arg UpsertFinalGradeParams) (FinalGrades, error) { + row := q.db.QueryRow(ctx, upsertFinalGrade, + arg.LabID, + arg.StudentID, + arg.Total, + arg.Track, + arg.Ratio, + arg.PerfScore, + arg.Percentile, + arg.BoardScore, + arg.Remark, + ) + var i FinalGrades + err := row.Scan( + &i.LabID, + &i.StudentID, + &i.Total, + &i.Track, + &i.Ratio, + &i.PerfScore, + &i.Percentile, + &i.BoardScore, + &i.Remark, + &i.PublishedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/packages/go/db/sqlc/models.go b/packages/go/db/sqlc/models.go index 7004c8a..1a52222 100644 --- a/packages/go/db/sqlc/models.go +++ b/packages/go/db/sqlc/models.go @@ -38,6 +38,20 @@ type EvaluationJobs struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type FinalGrades struct { + LabID string `json:"lab_id"` + StudentID string `json:"student_id"` + Total float32 `json:"total"` + Track pgtype.Text `json:"track"` + Ratio pgtype.Float4 `json:"ratio"` + PerfScore pgtype.Float4 `json:"perf_score"` + Percentile pgtype.Float4 `json:"percentile"` + BoardScore pgtype.Float4 `json:"board_score"` + Remark pgtype.Text `json:"remark"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type LabProfiles struct { UserID int64 `json:"user_id"` LabID string `json:"lab_id"` From 3004c99e1cab619623857cb3141add2f8550edbd Mon Sep 17 00:00:00 2001 From: HuanCheng65 <22636177+HuanCheng65@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:22:10 +0800 Subject: [PATCH 2/2] feat(api): make web sessions read-only, add grade reset; fix migrate baseline test - Reject read-only web-login sessions (Source=="web") on profile write endpoints (UpdateProfile/Nickname/Track) with 403; device-paired sessions and CLI signatures still write. Submit/keys/board were already key-only. - Add DELETE /api/admin/labs/{labID}/grades (DeleteGrades) under adminGuard so a corrected grade CSV can be re-imported cleanly. - Fix pre-existing failure in apps/migrate runner: applyLegacyMigrations now applies only up to a given version, so Baseline(7)+Up() replays 8+ as the normal forward sequence instead of re-running non-idempotent migration 0008. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/http/grade_handler.go | 20 +++++++++ apps/api/internal/http/grade_handler_test.go | 43 +++++++++++++++++++ apps/api/internal/http/personal_auth.go | 16 +++++++ apps/api/internal/http/profile_handler.go | 6 +-- .../api/internal/http/profile_handler_test.go | 26 +++++++++++ apps/api/internal/http/router.go | 1 + apps/api/internal/service/grade/service.go | 22 ++++++++++ .../internal/service/grade/service_test.go | 26 +++++++++++ apps/migrate/internal/runner/runner_test.go | 38 ++++++++++++++-- 9 files changed, 191 insertions(+), 7 deletions(-) diff --git a/apps/api/internal/http/grade_handler.go b/apps/api/internal/http/grade_handler.go index e351dc7..dd02ad3 100644 --- a/apps/api/internal/http/grade_handler.go +++ b/apps/api/internal/http/grade_handler.go @@ -17,6 +17,7 @@ type GradeService interface { GetGrade(context.Context, string, string) (gradesvc.Grade, error) ImportGrades(context.Context, string, io.Reader) (gradesvc.ImportGradesResult, error) PublishGrades(context.Context, string) (gradesvc.PublishGradesResult, error) + DeleteGrades(context.Context, string) (gradesvc.DeleteGradesResult, error) } // GradeHandler serves the student-facing grade view and the admin grade import. @@ -93,6 +94,25 @@ func (h *GradeHandler) PublishGrades(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } +// DeleteGrades handles DELETE /api/admin/labs/{labID}/grades, clearing all +// grade rows for a lab so a corrected CSV can be re-imported cleanly. +func (h *GradeHandler) DeleteGrades(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil { + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return + } + result, err := h.Service.DeleteGrades(r.Context(), r.PathValue("labID")) + if err != nil { + if errors.Is(err, gradesvc.ErrInvalidLab) { + middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "lab id is required") + return + } + middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError)) + return + } + writeJSON(w, http.StatusOK, result) +} + func (h *GradeHandler) writeImportError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, gradesvc.ErrInvalidCSV): diff --git a/apps/api/internal/http/grade_handler_test.go b/apps/api/internal/http/grade_handler_test.go index 789c757..ff66154 100644 --- a/apps/api/internal/http/grade_handler_test.go +++ b/apps/api/internal/http/grade_handler_test.go @@ -18,6 +18,7 @@ type fakeGradeService struct { importRes gradesvc.ImportGradesResult importErr error publishRes gradesvc.PublishGradesResult + deleteRes gradesvc.DeleteGradesResult lastLabID string lastStudentID string lastImportCSV string @@ -47,6 +48,11 @@ func (f *fakeGradeService) PublishGrades(_ context.Context, labID string) (grade return f.publishRes, nil } +func (f *fakeGradeService) DeleteGrades(_ context.Context, labID string) (gradesvc.DeleteGradesResult, error) { + f.lastLabID = labID + return f.deleteRes, nil +} + func TestGradeRouteReturnsGradeForBrowserSession(t *testing.T) { svc := &fakeGradeService{grade: gradesvc.Grade{LabID: "colab-2026-p2", StudentID: "2026001", Total: 86.5}} router := NewRouter(WithGradeService(svc)) @@ -150,6 +156,43 @@ func TestAdminGradeImportRequiresAdminToken(t *testing.T) { } } +func TestAdminGradeDeleteRoute(t *testing.T) { + svc := &fakeGradeService{deleteRes: gradesvc.DeleteGradesResult{LabID: "colab-2026-p2", Deleted: 4}} + router := NewRouter(WithGradeService(svc), WithAdminToken("secret")) + + req := httptest.NewRequest(http.MethodDelete, "/api/admin/labs/colab-2026-p2/grades", nil) + req.Header.Set("Authorization", "Bearer secret") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if svc.lastLabID != "colab-2026-p2" { + t.Fatalf("deleted lab id = %q, want %q", svc.lastLabID, "colab-2026-p2") + } + var payload gradesvc.DeleteGradesResult + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.Deleted != 4 { + t.Fatalf("deleted = %d, want 4", payload.Deleted) + } +} + +func TestAdminGradeDeleteRequiresAdminToken(t *testing.T) { + svc := &fakeGradeService{} + router := NewRouter(WithGradeService(svc), WithAdminToken("secret")) + + req := httptest.NewRequest(http.MethodDelete, "/api/admin/labs/colab-2026-p2/grades", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusUnauthorized, rr.Body.String()) + } +} + func TestAdminGradePublishRoute(t *testing.T) { svc := &fakeGradeService{publishRes: gradesvc.PublishGradesResult{LabID: "colab-2026-p2", Published: 5}} router := NewRouter(WithGradeService(svc), WithAdminToken("secret")) diff --git a/apps/api/internal/http/personal_auth.go b/apps/api/internal/http/personal_auth.go index 22f6c38..a1818f0 100644 --- a/apps/api/internal/http/personal_auth.go +++ b/apps/api/internal/http/personal_auth.go @@ -62,6 +62,22 @@ func authenticateBrowserSessionOrPersonalRequest(w http.ResponseWriter, r *http. return authenticatePersonalRequest(w, r, auth, body) } +// authenticateWritableRequest authenticates a mutating request. Read-only +// web-login sessions (Source=="web", not bound to a device key) are rejected; +// device-paired browser sessions and CLI signatures are accepted. This keeps +// the web login strictly read-only without touching the key-only write paths +// (submit/keys/board), which never accept a browser session at all. +func authenticateWritableRequest(w http.ResponseWriter, r *http.Request, auth personalAuthenticator, body []byte) (personal.AuthenticatedUser, bool) { + if session, ok := browserSessionFromRequest(r); ok { + if session.Source == browserSessionSourceWeb { + middleware.WriteError(w, r, http.StatusForbidden, "read_only_session", "Web login sessions are read-only") + return personal.AuthenticatedUser{}, false + } + return personal.AuthenticatedUser{UserID: session.UserID, KeyID: session.KeyID}, true + } + return authenticatePersonalRequest(w, r, auth, body) +} + func authenticateBrowserSessionRequest(r *http.Request) (personal.AuthenticatedUser, bool) { session, ok := browserSessionFromRequest(r) if !ok { diff --git a/apps/api/internal/http/profile_handler.go b/apps/api/internal/http/profile_handler.go index 1292699..a0cc321 100644 --- a/apps/api/internal/http/profile_handler.go +++ b/apps/api/internal/http/profile_handler.go @@ -54,7 +54,7 @@ func (h *ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "invalid request body") return } - user, ok := authenticateBrowserSessionOrPersonalRequest(w, r, h.Service, body) + user, ok := authenticateWritableRequest(w, r, h.Service, body) if !ok { return } @@ -81,7 +81,7 @@ func (h *ProfileHandler) UpdateNickname(w http.ResponseWriter, r *http.Request) middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "invalid request body") return } - user, ok := authenticateBrowserSessionOrPersonalRequest(w, r, h.Service, body) + user, ok := authenticateWritableRequest(w, r, h.Service, body) if !ok { return } @@ -109,7 +109,7 @@ func (h *ProfileHandler) UpdateTrack(w http.ResponseWriter, r *http.Request) { middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "invalid request body") return } - user, ok := authenticateBrowserSessionOrPersonalRequest(w, r, h.Service, body) + user, ok := authenticateWritableRequest(w, r, h.Service, body) if !ok { return } diff --git a/apps/api/internal/http/profile_handler_test.go b/apps/api/internal/http/profile_handler_test.go index f31a483..f21e646 100644 --- a/apps/api/internal/http/profile_handler_test.go +++ b/apps/api/internal/http/profile_handler_test.go @@ -454,6 +454,32 @@ func TestBrowserSessionAuthorizesMutatingPersonalRequests(t *testing.T) { } } +func TestWebLoginSessionCannotMutateProfile(t *testing.T) { + repo := newPersonalTestRepo(t, true) + svc := personal.NewService(repo) + handler := &ProfileHandler{Service: svc} + + // A read-only web-login session (Source=="web") must be rejected on writes. + sessionToken, err := issueWebBrowserSession(7, "2026001") + if err != nil { + t.Fatalf("issueWebBrowserSession() error = %v", err) + } + + req := httptest.NewRequest(http.MethodPut, "/api/labs/sorting/nickname", bytes.NewBufferString(`{"nickname":"Cat"}`)) + req.SetPathValue("labID", "sorting") + req.AddCookie(&http.Cookie{Name: browserSessionCookieName, Value: sessionToken}) + rr := httptest.NewRecorder() + + handler.UpdateNickname(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusForbidden, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "read_only_session") { + t.Fatalf("body = %s, want read_only_session", rr.Body.String()) + } +} + func wantStringField(t *testing.T, payload map[string]any, key, want string) { t.Helper() got, ok := payload[key].(string) diff --git a/apps/api/internal/http/router.go b/apps/api/internal/http/router.go index 30ce112..b823c03 100644 --- a/apps/api/internal/http/router.go +++ b/apps/api/internal/http/router.go @@ -225,6 +225,7 @@ func registerV1APIRoutes( mux.Handle("PUT "+apiPrefix+"/admin/labs/{labID}", adminGuard(http.HandlerFunc(labsHandler.UpdateLab))) mux.Handle("GET "+apiPrefix+"/admin/labs/{labID}", adminGuard(http.HandlerFunc(adminHandler.GetLabDetail))) mux.Handle("GET "+apiPrefix+"/admin/labs/{labID}/grades", adminGuard(http.HandlerFunc(adminHandler.ExportGrades))) + mux.Handle("DELETE "+apiPrefix+"/admin/labs/{labID}/grades", adminGuard(http.HandlerFunc(gradeHandler.DeleteGrades))) mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/grades/import", adminGuard(http.HandlerFunc(gradeHandler.ImportGrades))) mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/grades/publish", adminGuard(http.HandlerFunc(gradeHandler.PublishGrades))) mux.Handle("POST "+apiPrefix+"/admin/labs/{labID}/reeval", adminGuard(http.HandlerFunc(adminHandler.Reevaluate))) diff --git a/apps/api/internal/service/grade/service.go b/apps/api/internal/service/grade/service.go index a7a754e..7518165 100644 --- a/apps/api/internal/service/grade/service.go +++ b/apps/api/internal/service/grade/service.go @@ -63,6 +63,12 @@ type PublishGradesResult struct { Published int64 `json:"published"` } +// DeleteGradesResult reports how many rows were removed. +type DeleteGradesResult struct { + LabID string `json:"lab_id"` + Deleted int64 `json:"deleted"` +} + // GetGrade returns the published grade for a (lab, student), or ErrGradeNotFound. func (s *Service) GetGrade(ctx context.Context, labID, studentID string) (Grade, error) { if s == nil || s.repo == nil { @@ -159,6 +165,22 @@ func (s *Service) PublishGrades(ctx context.Context, labID string) (PublishGrade return PublishGradesResult{LabID: labID, Published: n}, nil } +// DeleteGrades removes every grade row for a lab, supporting a clean re-import. +func (s *Service) DeleteGrades(ctx context.Context, labID string) (DeleteGradesResult, error) { + if s == nil || s.repo == nil { + return DeleteGradesResult{}, fmt.Errorf("grade service unavailable") + } + labID = strings.TrimSpace(labID) + if labID == "" { + return DeleteGradesResult{}, ErrInvalidLab + } + n, err := s.repo.DeleteFinalGradesByLab(ctx, labID) + if err != nil { + return DeleteGradesResult{}, err + } + return DeleteGradesResult{LabID: labID, Deleted: n}, nil +} + func headerIndex(header []string) map[string]int { index := make(map[string]int, len(header)) for i, name := range header { diff --git a/apps/api/internal/service/grade/service_test.go b/apps/api/internal/service/grade/service_test.go index f905e7e..36578e1 100644 --- a/apps/api/internal/service/grade/service_test.go +++ b/apps/api/internal/service/grade/service_test.go @@ -205,3 +205,29 @@ func TestGetGradeMissingStudent(t *testing.T) { t.Fatalf("GetGrade() error = %v, want ErrGradeNotFound", err) } } + +func TestDeleteGradesClearsLabRows(t *testing.T) { + repo := newFakeGradeRepo() + svc := NewService(repo) + if _, err := svc.ImportGrades(context.Background(), "lab", strings.NewReader("student_id,total\n2026001,80\n2026002,90\n")); err != nil { + t.Fatalf("ImportGrades() error = %v", err) + } + + result, err := svc.DeleteGrades(context.Background(), "lab") + if err != nil { + t.Fatalf("DeleteGrades() error = %v", err) + } + if result.Deleted != 2 { + t.Fatalf("deleted = %d, want 2", result.Deleted) + } + if len(repo.rows) != 0 { + t.Fatalf("rows remaining = %d, want 0", len(repo.rows)) + } +} + +func TestDeleteGradesRejectsBlankLab(t *testing.T) { + svc := NewService(newFakeGradeRepo()) + if _, err := svc.DeleteGrades(context.Background(), " "); !errors.Is(err, ErrInvalidLab) { + t.Fatalf("DeleteGrades() error = %v, want ErrInvalidLab", err) + } +} diff --git a/apps/migrate/internal/runner/runner_test.go b/apps/migrate/internal/runner/runner_test.go index b693919..1c649f9 100644 --- a/apps/migrate/internal/runner/runner_test.go +++ b/apps/migrate/internal/runner/runner_test.go @@ -6,6 +6,7 @@ import ( "net" "os" "path/filepath" + "strconv" "strings" "testing" @@ -55,7 +56,7 @@ func TestUpRejectsLegacyDatabaseWithoutVersionTable(t *testing.T) { ctx := context.Background() env := openTestEnv(t) - applyLegacyMigrations(t, ctx, env.pool) + applyLegacyMigrations(t, ctx, env.pool, 0) r := New(Config{ DatabaseURL: env.databaseURL, @@ -77,7 +78,9 @@ func TestBaselineMarksLegacyDatabaseWithoutReplayingMigrations(t *testing.T) { ctx := context.Background() env := openTestEnv(t) - applyLegacyMigrations(t, ctx, env.pool) + // Legacy DB stuck at the schema of migration 7 (no version table). Baseline + // at 7, then Up() must apply 8+ — i.e. the normal forward sequence. + applyLegacyMigrations(t, ctx, env.pool, 7) r := New(Config{ DatabaseURL: env.databaseURL, @@ -149,7 +152,7 @@ func TestVersionDoesNotCreateVersionTableOnLegacyDatabase(t *testing.T) { ctx := context.Background() env := openTestEnv(t) - applyLegacyMigrations(t, ctx, env.pool) + applyLegacyMigrations(t, ctx, env.pool, 0) r := New(Config{ DatabaseURL: env.databaseURL, @@ -221,7 +224,12 @@ func openTestEnv(t *testing.T) testEnv { } } -func applyLegacyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { +// applyLegacyMigrations simulates a database created before version tracking by +// applying up migrations directly. throughVersion bounds how far to apply (the +// schema a legacy DB is "stuck" at); pass 0 to apply every migration. Applying +// only up to the baseline version keeps a subsequent Baseline()+Up() equivalent +// to the normal forward sequence, instead of replaying non-idempotent DDL. +func applyLegacyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool, throughVersion int) { t.Helper() if _, err := pool.Exec(ctx, ` @@ -238,6 +246,9 @@ func applyLegacyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool t.Fatalf("Glob() error = %v", err) } for _, path := range paths { + if throughVersion > 0 && migrationVersion(t, path) > throughVersion { + continue + } body, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%q) error = %v", path, err) @@ -248,6 +259,25 @@ func applyLegacyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool } } +// migrationVersion extracts the numeric prefix of a migration filename, e.g. +// "0008_user_profile_nickname.up.sql" -> 8. +func migrationVersion(t *testing.T, path string) int { + t.Helper() + base := filepath.Base(path) + digits := 0 + for digits < len(base) && base[digits] >= '0' && base[digits] <= '9' { + digits++ + } + if digits == 0 { + t.Fatalf("migration %q has no numeric version prefix", base) + } + version, err := strconv.Atoi(base[:digits]) + if err != nil { + t.Fatalf("parse migration version from %q: %v", base, err) + } + return version +} + func migrationsDir(t *testing.T) string { t.Helper() wd, err := os.Getwd()