package server_test import ( "context" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/server" "cmbuyer/admin/internal/tasks" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" ) var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`) func TestHealthzIsPublic(t *testing.T) { router, _ := newRouter(t) request := httptest.NewRequest(http.MethodGet, "/healthz", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK) } if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" { t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType) } if body := response.Body.String(); body != "{\"status\":\"ok\"}" { t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body) } assertSecurityHeaders(t, response) } func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) { router, _ := newRouter(t) tasks := serve(router, http.MethodGet, "/tasks", nil, nil) if tasks.Code != http.StatusSeeOther { t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther) } if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" { t.Fatalf("GET /tasks location = %q, want login return path", location) } for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} { response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil) if response.Code != http.StatusOK { t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code) } if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) { t.Fatalf("GET /login accepted unsafe return_to %q", target) } } encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil) if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) { t.Fatal("encoded parent path was accepted as return_to") } encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil) if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) { t.Fatal("encoded query bypass was accepted as return_to") } } func TestLoginRotatesSessionAndCSRF(t *testing.T) { router, _ := newRouter(t) initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil) oldCookie := sessionCookie(t, initial) oldCSRF := csrfToken(t, initial.Body.String()) login := serve(router, http.MethodPost, "/login", url.Values{ "csrf_token": {oldCSRF}, "return_to": {"/tasks?view=mine"}, "username": {"admin"}, "password": {"test-password"}, }, oldCookie) if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" { t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location")) } newCookie := sessionCookie(t, login) if newCookie.Value == oldCookie.Value { t.Fatal("successful login reused the anonymous session cookie") } tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie) if tasks.Code != http.StatusOK { t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code) } if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF { t.Fatal("successful login reused the anonymous CSRF token") } for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} { if strings.Contains(tasks.Body.String(), forbidden) { t.Fatalf("task shell must not expose deferred feature content %q", forbidden) } } assertSecurityHeaders(t, initial) assertSecurityHeaders(t, tasks) } func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) { router, _ := newRouter(t) page := serve(router, http.MethodGet, "/login", nil, nil) body := page.Body.String() for _, want := range []string{ `