Before Fine Tune
This commit is contained in:
100
Backend/internal/handlers/api/admin/handler.go
Normal file
100
Backend/internal/handlers/api/admin/handler.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/database"
|
||||
"boostai-backend/internal/http/respond"
|
||||
"boostai-backend/internal/seeddata"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
ReseedHeaderName = "X-Admin-Reseed-Secret"
|
||||
ReseedConfirm = "RESEED"
|
||||
)
|
||||
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, mockDataDir string) (seeddata.Summary, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
runner runFunc
|
||||
}
|
||||
|
||||
type runFunc func(timeoutCtx context.Context, mockDataDir string) (seeddata.Summary, error)
|
||||
|
||||
type reseedRequest struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
|
||||
type reseedResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Environment string `json:"environment"`
|
||||
TriggeredBy string `json:"triggered_by,omitempty"`
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
Summary seeddata.Summary `json:"summary"`
|
||||
}
|
||||
|
||||
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
runner: func(timeoutCtx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Run(timeoutCtx, db, mockDataDir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ReseedDatabase(c *fiber.Ctx) error {
|
||||
if !h.cfg.AdminReseedEnabled {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "The requested endpoint does not exist")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(h.cfg.AdminReseedSecret) == "" {
|
||||
return respond.Error(c, fiber.StatusServiceUnavailable, "admin_reseed_unavailable", "Admin reseed is not configured")
|
||||
}
|
||||
|
||||
providedSecret := strings.TrimSpace(c.Get(ReseedHeaderName))
|
||||
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(h.cfg.AdminReseedSecret)) != 1 {
|
||||
return respond.Error(c, fiber.StatusForbidden, "forbidden", "Valid reseed secret required")
|
||||
}
|
||||
|
||||
var req reseedRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
}
|
||||
if strings.TrimSpace(req.Confirm) != ReseedConfirm {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "confirm must equal RESEED")
|
||||
}
|
||||
|
||||
triggeredBy, _ := c.Locals("auth.email").(string)
|
||||
userID, _ := c.Locals("auth.user_id").(int64)
|
||||
startedAt := time.Now().UTC()
|
||||
log.Printf("admin reseed requested environment=%s user_id=%d email=%s ip=%s", h.cfg.Environment, userID, triggeredBy, c.IP())
|
||||
|
||||
timeoutCtx, cancelTimeout := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancelTimeout()
|
||||
|
||||
summary, err := h.runner(timeoutCtx, h.cfg.MockDataDir)
|
||||
if err != nil {
|
||||
log.Printf("admin reseed failed environment=%s user_id=%d email=%s err=%v", h.cfg.Environment, userID, triggeredBy, err)
|
||||
return respond.Error(c, fiber.StatusInternalServerError, "admin_reseed_failed", err.Error())
|
||||
}
|
||||
|
||||
log.Printf("admin reseed completed environment=%s user_id=%d email=%s users=%d assignments=%d student_answers=%d", h.cfg.Environment, userID, triggeredBy, summary.Users, summary.Assignments, summary.StudentAnswers)
|
||||
|
||||
return c.JSON(reseedResponse{
|
||||
OK: true,
|
||||
Environment: h.cfg.Environment,
|
||||
TriggeredBy: triggeredBy,
|
||||
TriggeredAt: startedAt,
|
||||
Summary: summary,
|
||||
})
|
||||
}
|
||||
108
Backend/internal/handlers/api/admin/handler_test.go
Normal file
108
Backend/internal/handlers/api/admin/handler_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/seeddata"
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestReseedDatabaseRequiresEnableFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := &Handler{cfg: &config.Config{Environment: "production"}}
|
||||
status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "secret", true)
|
||||
if status != fiber.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReseedDatabaseRequiresSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Summary{}, nil
|
||||
})
|
||||
status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "wrong", true)
|
||||
if status != fiber.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReseedDatabaseRequiresConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Summary{}, nil
|
||||
})
|
||||
status := performReseedRequest(t, h, map[string]any{"confirm": "nope"}, "secret", true)
|
||||
if status != fiber.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReseedDatabaseReturnsSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
if mockDataDir != "/app/Mock-Data" {
|
||||
t.Fatalf("expected mock data dir /app/Mock-Data, got %q", mockDataDir)
|
||||
}
|
||||
return seeddata.Summary{Users: 13, Assignments: 8, StudentAnswers: 588}, nil
|
||||
})
|
||||
status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "secret", true)
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReseedDatabaseSurfacesRunnerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Summary{}, errors.New("boom")
|
||||
})
|
||||
status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "secret", true)
|
||||
if status != fiber.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(fn func(context.Context, string) (seeddata.Summary, error)) *Handler {
|
||||
return &Handler{
|
||||
cfg: &config.Config{Environment: "production", AdminReseedEnabled: true, AdminReseedSecret: "secret", MockDataDir: "/app/Mock-Data"},
|
||||
runner: fn,
|
||||
}
|
||||
}
|
||||
|
||||
func performReseedRequest(t *testing.T, handler *Handler, payload map[string]any, secret string, authenticated bool) int {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
app.Post("/internal/admin/reseed", func(c *fiber.Ctx) error {
|
||||
if authenticated {
|
||||
c.Locals("auth.user_id", int64(42))
|
||||
c.Locals("auth.role", sqlc.UserRoleTeacher)
|
||||
c.Locals("auth.email", "teacher@example.com")
|
||||
}
|
||||
return handler.ReseedDatabase(c)
|
||||
})
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/admin/reseed", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if secret != "" {
|
||||
req.Header.Set(ReseedHeaderName, secret)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
11
Backend/internal/handlers/api/admin/routes.go
Normal file
11
Backend/internal/handlers/api/admin/routes.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
authmw "boostai-backend/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
|
||||
app.Post("/internal/admin/reseed", auth.RequireTeacher(), h.ReseedDatabase)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"boostai-backend/internal/assignmentgen"
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/database"
|
||||
adminhandler "boostai-backend/internal/handlers/api/admin"
|
||||
answershandler "boostai-backend/internal/handlers/api/answers"
|
||||
assignmentshandler "boostai-backend/internal/handlers/api/assignments"
|
||||
classroomshandler "boostai-backend/internal/handlers/api/classrooms"
|
||||
@@ -22,6 +23,7 @@ type Handler struct {
|
||||
questions *questionshandler.Handler
|
||||
assignments *assignmentshandler.Handler
|
||||
answers *answershandler.Handler
|
||||
admin *adminhandler.Handler
|
||||
}
|
||||
|
||||
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||
@@ -37,5 +39,6 @@ func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||
questions: questionshandler.NewHandler(queries, questionGenerator),
|
||||
assignments: assignmentshandler.NewHandler(queries, aiReviewService, assignmentGenerator),
|
||||
answers: answershandler.NewHandler(queries, aiReviewService),
|
||||
admin: adminhandler.NewHandler(db, cfg),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/handlers/api/admin"
|
||||
"boostai-backend/internal/handlers/api/answers"
|
||||
"boostai-backend/internal/handlers/api/assignments"
|
||||
"boostai-backend/internal/handlers/api/classrooms"
|
||||
@@ -19,4 +20,5 @@ func (h *Handler) Register(app fiber.Router, auth *authmw.AuthMiddleware) {
|
||||
questions.RegisterRoutes(app, auth, h.questions)
|
||||
assignments.RegisterRoutes(app, auth, h.assignments)
|
||||
answers.RegisterRoutes(app, auth, h.answers)
|
||||
admin.RegisterRoutes(app, auth, h.admin)
|
||||
}
|
||||
|
||||
338
Backend/internal/handlers/web/reseed/reseed.go
Normal file
338
Backend/internal/handlers/web/reseed/reseed.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package reseed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/database"
|
||||
"boostai-backend/internal/seeddata"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const reseedCookieName = "boostai_reseed_auth"
|
||||
|
||||
type runFunc func(ctx context.Context, mockDataDir string) (seeddata.Summary, error)
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
runner runFunc
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Authorized bool
|
||||
Environment string
|
||||
MockDataDir string
|
||||
Error string
|
||||
Success string
|
||||
Summary *seeddata.Summary
|
||||
}
|
||||
|
||||
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
runner: func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Run(ctx, db, mockDataDir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Page(c *fiber.Ctx) error {
|
||||
if !h.cfg.AdminReseedEnabled {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
return h.renderPage(c, pageData{
|
||||
Authorized: h.isAuthorized(c),
|
||||
Environment: h.cfg.Environment,
|
||||
MockDataDir: h.cfg.MockDataDir,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *fiber.Ctx) error {
|
||||
if !h.cfg.AdminReseedEnabled {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
password := strings.TrimSpace(c.FormValue("password"))
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(h.cfg.ReseedPagePassword)) != 1 {
|
||||
return h.renderPage(c.Status(fiber.StatusUnauthorized), pageData{
|
||||
Authorized: false,
|
||||
Environment: h.cfg.Environment,
|
||||
MockDataDir: h.cfg.MockDataDir,
|
||||
Error: "Invalid password",
|
||||
})
|
||||
}
|
||||
|
||||
h.setAuthCookie(c)
|
||||
return c.Redirect("/reseed", fiber.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) Run(c *fiber.Ctx) error {
|
||||
if !h.cfg.AdminReseedEnabled {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
if !h.isAuthorized(c) {
|
||||
return h.renderPage(c.Status(fiber.StatusUnauthorized), pageData{
|
||||
Authorized: false,
|
||||
Environment: h.cfg.Environment,
|
||||
MockDataDir: h.cfg.MockDataDir,
|
||||
Error: "Please unlock the reseed page first",
|
||||
})
|
||||
}
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
log.Printf("browser reseed requested environment=%s ip=%s", h.cfg.Environment, c.IP())
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
summary, err := h.runner(timeoutCtx, h.cfg.MockDataDir)
|
||||
if err != nil {
|
||||
log.Printf("browser reseed failed environment=%s ip=%s err=%v", h.cfg.Environment, c.IP(), err)
|
||||
return h.renderPage(c.Status(fiber.StatusInternalServerError), pageData{
|
||||
Authorized: true,
|
||||
Environment: h.cfg.Environment,
|
||||
MockDataDir: h.cfg.MockDataDir,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("browser reseed completed environment=%s ip=%s users=%d assignments=%d student_answers=%d", h.cfg.Environment, c.IP(), summary.Users, summary.Assignments, summary.StudentAnswers)
|
||||
|
||||
return h.renderPage(c, pageData{
|
||||
Authorized: true,
|
||||
Environment: h.cfg.Environment,
|
||||
MockDataDir: h.cfg.MockDataDir,
|
||||
Success: fmt.Sprintf("Reseed completed at %s UTC", startedAt.Format("2006-01-02 15:04:05")),
|
||||
Summary: &summary,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *fiber.Ctx) error {
|
||||
if !h.cfg.AdminReseedEnabled {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
h.clearAuthCookie(c)
|
||||
return c.Redirect("/reseed", fiber.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) isAuthorized(c *fiber.Ctx) bool {
|
||||
provided := strings.TrimSpace(c.Cookies(reseedCookieName))
|
||||
expected := h.authCookieValue()
|
||||
if provided == "" || expected == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func (h *Handler) setAuthCookie(c *fiber.Ctx) {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: reseedCookieName,
|
||||
Value: h.authCookieValue(),
|
||||
HTTPOnly: true,
|
||||
Secure: h.cfg.IsProduction(),
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
Path: "/",
|
||||
Expires: time.Now().UTC().Add(12 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) clearAuthCookie(c *fiber.Ctx) {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: reseedCookieName,
|
||||
Value: "",
|
||||
HTTPOnly: true,
|
||||
Secure: h.cfg.IsProduction(),
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) authCookieValue() string {
|
||||
sum := sha256.Sum256([]byte(h.cfg.JWTSecret + "|" + h.cfg.ReseedPagePassword))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (h *Handler) renderPage(c *fiber.Ctx, data pageData) error {
|
||||
content := reseedPageHTML(data)
|
||||
c.Type("html", "utf-8")
|
||||
return c.SendString(content)
|
||||
}
|
||||
|
||||
func reseedPageHTML(data pageData) string {
|
||||
var statusHTML strings.Builder
|
||||
if data.Error != "" {
|
||||
statusHTML.WriteString(`<div class="notice notice-error">` + html.EscapeString(data.Error) + `</div>`)
|
||||
}
|
||||
if data.Success != "" {
|
||||
statusHTML.WriteString(`<div class="notice notice-success">` + html.EscapeString(data.Success) + `</div>`)
|
||||
}
|
||||
if data.Summary != nil {
|
||||
statusHTML.WriteString(`<pre class="summary">`)
|
||||
statusHTML.WriteString(html.EscapeString(fmt.Sprintf(
|
||||
"users: %d\nclassrooms: %d\nquestions: %d\ntags: %d\nassignments: %d\nassignment_links: %d\nstudent_answers: %d\nmock_data_dir: %s",
|
||||
data.Summary.Users,
|
||||
data.Summary.Classrooms,
|
||||
data.Summary.Questions,
|
||||
data.Summary.Tags,
|
||||
data.Summary.Assignments,
|
||||
data.Summary.AssignmentLinks,
|
||||
data.Summary.StudentAnswers,
|
||||
data.Summary.MockDataDir,
|
||||
)))
|
||||
statusHTML.WriteString(`</pre>`)
|
||||
}
|
||||
|
||||
var body strings.Builder
|
||||
if data.Authorized {
|
||||
body.WriteString(`
|
||||
<div class="card">
|
||||
<h2>Reseed database</h2>
|
||||
<p>This will clear seeded app data and repopulate it from Mock-Data.</p>
|
||||
<form method="post" action="/reseed/run">
|
||||
<button class="danger" type="submit">Reseed now</button>
|
||||
</form>
|
||||
<form method="post" action="/reseed/logout">
|
||||
<button type="submit">Lock page</button>
|
||||
</form>
|
||||
</div>
|
||||
`)
|
||||
} else {
|
||||
body.WriteString(`
|
||||
<div class="card">
|
||||
<h2>Unlock reseed</h2>
|
||||
<form method="post" action="/reseed/login">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
||||
<button type="submit">Unlock</button>
|
||||
</form>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>BoostAI Reseed</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 48px auto;
|
||||
padding: 0 20px 48px;
|
||||
}
|
||||
h1, h2 { margin-top: 0; }
|
||||
.card {
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 8px 12px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.meta strong { color: #93c5fd; }
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
input {
|
||||
width: 100%%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #475569;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
button {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
background: #38bdf8;
|
||||
color: #082f49;
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
button.danger {
|
||||
background: #f87171;
|
||||
color: #450a0a;
|
||||
}
|
||||
.notice {
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.notice-error {
|
||||
background: rgba(220, 38, 38, 0.18);
|
||||
border: 1px solid rgba(248, 113, 113, 0.45);
|
||||
color: #fecaca;
|
||||
}
|
||||
.notice-success {
|
||||
background: rgba(22, 163, 74, 0.18);
|
||||
border: 1px solid rgba(74, 222, 128, 0.45);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.summary {
|
||||
background: #020617;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>BoostAI reseed</h1>
|
||||
<div class="meta">
|
||||
<strong>Environment</strong><span>%s</span>
|
||||
<strong>Mock data path</strong><span>%s</span>
|
||||
<strong>Mode</strong><span>Browser-protected destructive reseed</span>
|
||||
</div>
|
||||
%s
|
||||
</div>
|
||||
%s
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
html.EscapeString(data.Environment),
|
||||
html.EscapeString(data.MockDataDir),
|
||||
statusHTML.String(),
|
||||
body.String(),
|
||||
)
|
||||
}
|
||||
137
Backend/internal/handlers/web/reseed/reseed_test.go
Normal file
137
Backend/internal/handlers/web/reseed/reseed_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package reseed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/seeddata"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestPageRequiresEnableFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := &Handler{cfg: &config.Config{Environment: "production"}}
|
||||
status, _ := performRequest(t, h, http.MethodGet, "/reseed", "", "")
|
||||
if status != fiber.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSetsCookieAndRedirects(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Summary{}, nil
|
||||
})
|
||||
status, resp := performRequest(t, h, http.MethodPost, "/reseed/login", "password=1588", "")
|
||||
if status != fiber.StatusSeeOther {
|
||||
t.Fatalf("expected 303, got %d", status)
|
||||
}
|
||||
if location := resp.Header.Get("Location"); location != "/reseed" {
|
||||
t.Fatalf("expected redirect to /reseed, got %q", location)
|
||||
}
|
||||
if cookie := resp.Header.Get("Set-Cookie"); !strings.Contains(cookie, reseedCookieName+"=") {
|
||||
t.Fatalf("expected auth cookie, got %q", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresAuthCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Summary{}, nil
|
||||
})
|
||||
status, body := performRequestBody(t, h, http.MethodPost, "/reseed/run", "", "")
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", status)
|
||||
}
|
||||
if !strings.Contains(body, "Please unlock the reseed page first") {
|
||||
t.Fatalf("expected unlock message, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecutesReseed(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
if mockDataDir != "/app/Mock-Data" {
|
||||
t.Fatalf("expected mock data dir /app/Mock-Data, got %q", mockDataDir)
|
||||
}
|
||||
return seeddata.Summary{Users: 13, Assignments: 8, StudentAnswers: 588, MockDataDir: mockDataDir}, nil
|
||||
})
|
||||
status, body := performRequestBody(t, h, http.MethodPost, "/reseed/run", "", reseedCookieName+"="+h.authCookieValue())
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
if !strings.Contains(body, "Reseed completed") || !strings.Contains(body, "student_answers: 588") {
|
||||
t.Fatalf("expected success summary, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSurfacesRunnerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
||||
return seeddata.Summary{}, errors.New("boom")
|
||||
})
|
||||
status, body := performRequestBody(t, h, http.MethodPost, "/reseed/run", "", reseedCookieName+"="+h.authCookieValue())
|
||||
if status != fiber.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", status)
|
||||
}
|
||||
if !strings.Contains(body, "boom") {
|
||||
t.Fatalf("expected error body, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(fn func(context.Context, string) (seeddata.Summary, error)) *Handler {
|
||||
return &Handler{
|
||||
cfg: &config.Config{
|
||||
Environment: "production",
|
||||
AdminReseedEnabled: true,
|
||||
MockDataDir: "/app/Mock-Data",
|
||||
JWTSecret: "jwt-secret",
|
||||
ReseedPagePassword: "1588",
|
||||
},
|
||||
runner: fn,
|
||||
}
|
||||
}
|
||||
|
||||
func performRequest(t *testing.T, handler *Handler, method, path, formBody, cookieHeader string) (int, *http.Response) {
|
||||
t.Helper()
|
||||
app := testApp(handler)
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(formBody))
|
||||
if formBody != "" {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if cookieHeader != "" {
|
||||
req.Header.Set("Cookie", cookieHeader)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
return resp.StatusCode, resp
|
||||
}
|
||||
|
||||
func performRequestBody(t *testing.T, handler *Handler, method, path, formBody, cookieHeader string) (int, string) {
|
||||
t.Helper()
|
||||
status, resp := performRequest(t, handler, method, path, formBody, cookieHeader)
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
return status, string(bodyBytes)
|
||||
}
|
||||
|
||||
func testApp(handler *Handler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Get("/reseed", handler.Page)
|
||||
app.Post("/reseed/login", handler.Login)
|
||||
app.Post("/reseed/run", handler.Run)
|
||||
app.Post("/reseed/logout", handler.Logout)
|
||||
return app
|
||||
}
|
||||
Reference in New Issue
Block a user