654 lines
27 KiB
Go
654 lines
27 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"cmbuyer/admin/internal/auth"
|
|
"cmbuyer/admin/internal/deviceauth"
|
|
"cmbuyer/admin/internal/evidence"
|
|
"cmbuyer/admin/internal/server"
|
|
"cmbuyer/admin/internal/taskclaim"
|
|
"cmbuyer/admin/internal/taskdetail"
|
|
"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{
|
|
`<label for="username">`,
|
|
`<label for="password">`,
|
|
`autocomplete="username"`,
|
|
`autocomplete="current-password"`,
|
|
`min-height:44px`,
|
|
`:focus-visible`,
|
|
`prefers-reduced-motion`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("login page is missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
|
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
|
}
|
|
|
|
failure := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, body)},
|
|
"username": {"admin"},
|
|
"password": {"wrong"},
|
|
}, sessionCookie(t, page))
|
|
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
|
t.Fatal("login failure must announce its error")
|
|
}
|
|
}
|
|
|
|
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
cookie := sessionCookie(t, page)
|
|
|
|
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
|
"username": {"admin"},
|
|
"password": {"test-password"},
|
|
}, cookie)
|
|
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
|
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
|
}
|
|
|
|
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
|
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
"username": {"unknown"},
|
|
"password": {"wrong"},
|
|
}, cookie)
|
|
if badCredentials.Code != http.StatusUnauthorized {
|
|
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
|
}
|
|
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
|
t.Fatalf("invalid login leaked account detail: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
cookie := sessionCookie(t, page)
|
|
|
|
tampered := *cookie
|
|
tampered.Value = flipCookieValue(t, cookie.Value)
|
|
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
|
}
|
|
|
|
}
|
|
|
|
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
cookie := authenticate(t, router)
|
|
|
|
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
|
if modal.Code != http.StatusOK {
|
|
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
|
|
}
|
|
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
|
|
if fullPage.Code != http.StatusOK {
|
|
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
|
|
}
|
|
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
|
|
if !strings.Contains(modal.Body.String(), want) {
|
|
t.Fatalf("dialog form is missing %q", want)
|
|
}
|
|
}
|
|
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
|
|
if !strings.Contains(fullPage.Body.String(), want) {
|
|
t.Fatalf("full-page form is missing %q", want)
|
|
}
|
|
}
|
|
|
|
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
"csrf_token": {csrfToken(t, modal.Body.String())},
|
|
"create_key": {createKey(t, modal.Body.String())},
|
|
"title": {`<script>alert(1)</script>`},
|
|
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
|
|
"sku_color": {"black"},
|
|
"sku_size": {"M"},
|
|
"quantity": {"0"},
|
|
"max_total_price": {"12.80"},
|
|
"form_mode": {"dialog"},
|
|
}, cookie)
|
|
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
|
|
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
|
|
}
|
|
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) {
|
|
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
|
|
}
|
|
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
|
|
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
|
|
}
|
|
|
|
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
|
key := createKey(t, createPage.Body.String())
|
|
created := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
|
"create_key": {key},
|
|
"title": {"<b>夏季上衣</b>"},
|
|
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
|
"sku_color": {"black"},
|
|
"sku_size": {"M"},
|
|
"quantity": {"2"},
|
|
"max_total_price": {"12.8"},
|
|
"form_mode": {"dialog"},
|
|
}, cookie)
|
|
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
|
|
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
|
|
}
|
|
replay := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
|
"create_key": {key},
|
|
"title": {"<b>夏季上衣</b>"},
|
|
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
|
"sku_color": {"black"},
|
|
"sku_size": {"M"},
|
|
"quantity": {"2"},
|
|
"max_total_price": {"12.8"},
|
|
"form_mode": {"dialog"},
|
|
}, cookie)
|
|
if replay.Code != http.StatusSeeOther {
|
|
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
|
|
}
|
|
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
|
"create_key": {key},
|
|
"title": {"different task"},
|
|
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
|
|
"sku_color": {"black"},
|
|
"sku_size": {"M"},
|
|
"quantity": {"2"},
|
|
"max_total_price": {"12.80"},
|
|
"form_mode": {"dialog"},
|
|
}, cookie)
|
|
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
|
|
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
|
|
}
|
|
|
|
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
|
|
if list.Code != http.StatusOK {
|
|
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
|
|
}
|
|
body := list.Body.String()
|
|
for _, want := range []string{`任务已创建,已显示在列表首行。`, `<b>夏季上衣</b>`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("task list is missing %q", want)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("task list exposed deferred scope %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
|
|
store := &memoryStore{rows: []tasks.TaskRow{
|
|
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
|
|
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
|
|
}}
|
|
router, _ := newRouterWithStore(t, store)
|
|
cookie := authenticate(t, router)
|
|
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
|
|
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
|
|
}
|
|
body := response.Body.String()
|
|
for _, want := range []string{
|
|
`<a class="skip" href="#main">`,
|
|
`:focus-visible`,
|
|
`min-height:44px`,
|
|
`@media(max-width:420px)`,
|
|
`prefers-reduced-motion`,
|
|
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
|
|
`name="keyword" type="search" value="衬衫"`,
|
|
`name="created_from" type="date" value="2026-08-04"`,
|
|
`name="created_to" type="date" value="2026-08-04"`,
|
|
`data-start-purchases`,
|
|
`data-selection-summary aria-live="polite"`,
|
|
`系统不会付款`,
|
|
`开始采购(只创建待付款订单)`,
|
|
`采购结果`,
|
|
`创建时间(上海)`,
|
|
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
|
|
`target="_blank" rel="noopener noreferrer"`,
|
|
`data-task-row data-detail-url="/tasks/b3c9f507-7473-4fa6-8d71-8786c34c6301" tabindex="0"`,
|
|
`data-open-detail>查看详情</button>`,
|
|
`.detail-link-button{display:block;min-height:44px`,
|
|
`data-detail-drawer aria-modal="true"`,
|
|
`待开始`,
|
|
`已授权待领取`,
|
|
`datetime="2026-08-04T09:02:03+08:00">2026-08-04 09:02`,
|
|
`<script src="/static/tasks.js" defer></script>`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("workbench is missing %q", want)
|
|
}
|
|
}
|
|
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
|
|
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
|
|
}
|
|
if count := strings.Count(body, `data-task-id=`); count != 1 {
|
|
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
|
|
}
|
|
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
|
|
}
|
|
}
|
|
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
|
|
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
|
|
}
|
|
}
|
|
|
|
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
|
|
store := &memoryStore{}
|
|
router, _ := newRouterWithStore(t, store)
|
|
cookie := authenticate(t, router)
|
|
query := url.Values{
|
|
"keyword": {`保留%_\`},
|
|
"status": {"UNKNOWN"},
|
|
"created_from": {"2026-02-30"},
|
|
"created_to": {"not-a-date"},
|
|
}
|
|
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid filter status = %d, want 400", response.Code)
|
|
}
|
|
body := response.Body.String()
|
|
for _, want := range []string{
|
|
`role="alert" aria-live="assertive"`,
|
|
`href="#filter-status"`,
|
|
`href="#filter-created-from"`,
|
|
`href="#filter-created-to"`,
|
|
`name="keyword" type="search" value="保留%_\"`,
|
|
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
|
|
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
|
|
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
|
|
`id="filter-status-error"`,
|
|
`id="filter-created-from-error"`,
|
|
`id="filter-created-to-error"`,
|
|
`筛选条件有误`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("invalid filter page is missing %q", want)
|
|
}
|
|
}
|
|
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
|
|
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
|
|
}
|
|
assertSecurityHeaders(t, response)
|
|
}
|
|
|
|
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous POST /tasks = %d, want 401", response.Code)
|
|
}
|
|
cookie := authenticate(t, router)
|
|
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
|
|
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
|
|
}
|
|
}
|
|
|
|
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
cookie := authenticate(t, router)
|
|
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
|
base := url.Values{
|
|
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
"create_key": {createKey(t, page.Body.String())},
|
|
"title": {"title"},
|
|
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
|
|
"sku_color": {"black"},
|
|
"sku_size": {"M"},
|
|
"quantity": {"1"},
|
|
"max_total_price": {"1.00"},
|
|
"form_mode": {"dialog"},
|
|
}
|
|
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
|
|
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
|
|
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
|
|
}
|
|
|
|
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
|
|
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversized form status = %d, want 413", response.Code)
|
|
}
|
|
}
|
|
|
|
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
want := map[string]string{
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "no-referrer",
|
|
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
}
|
|
for name, expected := range want {
|
|
if got := response.Header().Get(name); got != expected {
|
|
t.Fatalf("%s = %q, want %q", name, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func flipCookieValue(t *testing.T, value string) string {
|
|
t.Helper()
|
|
if value == "" {
|
|
t.Fatal("cannot tamper with an empty cookie")
|
|
}
|
|
if value[0] == 'A' {
|
|
return "B" + value[1:]
|
|
}
|
|
return "A" + value[1:]
|
|
}
|
|
|
|
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
|
loginCookie := sessionCookie(t, loginPage)
|
|
login := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
|
"username": {"admin"},
|
|
"password": {"test-password"},
|
|
}, loginCookie)
|
|
authenticatedCookie := sessionCookie(t, login)
|
|
|
|
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
|
if missingCSRF.Code != http.StatusForbidden {
|
|
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
|
}
|
|
|
|
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
|
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
|
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
|
}, authenticatedCookie)
|
|
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
|
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
|
}
|
|
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
|
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
|
}
|
|
|
|
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
|
if reused.Code != http.StatusSeeOther {
|
|
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
|
}
|
|
}
|
|
|
|
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
|
return newRouterWithStore(t, &memoryStore{})
|
|
}
|
|
|
|
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
|
|
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
|
|
}
|
|
|
|
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
|
|
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
|
|
}
|
|
|
|
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatalf("generate bcrypt hash: %v", err)
|
|
}
|
|
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
|
router, err := server.NewRouter(server.Options{
|
|
AdminUsername: "admin",
|
|
AdminPasswordBcrypt: string(hash),
|
|
Sessions: manager,
|
|
Tasks: store,
|
|
TaskDetails: details,
|
|
Evidence: evidenceStore,
|
|
DeviceAuthenticator: deviceAuthenticator,
|
|
TaskClaims: claims,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRouter: %v", err)
|
|
}
|
|
return router, manager
|
|
}
|
|
|
|
type emptyDetailStore struct{}
|
|
|
|
type emptyTaskClaimService struct{}
|
|
|
|
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
|
return taskclaim.ClaimResponse{}, false, nil
|
|
}
|
|
|
|
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
|
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
|
|
}
|
|
|
|
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
|
return taskdetail.Detail{}, taskdetail.ErrNotFound
|
|
}
|
|
|
|
type emptyEvidenceStore struct{}
|
|
|
|
func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error) {
|
|
return evidence.StagedFile{}, evidence.ErrInvalid
|
|
}
|
|
func (emptyEvidenceStore) Discard(evidence.StagedFile) {}
|
|
func (emptyEvidenceStore) Commit(context.Context, deviceauth.Principal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
|
|
return evidence.Asset{}, false, evidence.ErrInvalid
|
|
}
|
|
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
|
|
return evidence.Asset{}, nil, evidence.ErrNotFound
|
|
}
|
|
|
|
type memoryStore struct {
|
|
drafts []tasks.Draft
|
|
rows []tasks.TaskRow
|
|
listDraftsCalls int
|
|
listTasksCalls int
|
|
startCalls int
|
|
}
|
|
|
|
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
|
for _, existing := range store.drafts {
|
|
if existing.ID == draft.ID {
|
|
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
|
|
return tasks.Draft{}, tasks.ErrCreateKeyConflict
|
|
}
|
|
return existing, nil
|
|
}
|
|
}
|
|
store.drafts = append(store.drafts, draft)
|
|
return draft, nil
|
|
}
|
|
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
|
|
store.listDraftsCalls++
|
|
return append([]tasks.Draft(nil), store.drafts...), nil
|
|
}
|
|
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
|
|
store.listTasksCalls++
|
|
if store.rows != nil {
|
|
return append([]tasks.TaskRow(nil), store.rows...), nil
|
|
}
|
|
result := make([]tasks.TaskRow, 0, len(store.drafts))
|
|
for _, draft := range store.drafts {
|
|
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
|
|
}
|
|
return result, nil
|
|
}
|
|
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
|
|
store.startCalls++
|
|
return tasks.StartResult{}, tasks.ErrInvalidStart
|
|
}
|
|
|
|
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
var body *strings.Reader
|
|
if form == nil {
|
|
body = strings.NewReader("")
|
|
} else {
|
|
body = strings.NewReader(form.Encode())
|
|
}
|
|
request := httptest.NewRequest(method, target, body)
|
|
if form != nil {
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
if cookie != nil {
|
|
request.AddCookie(cookie)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
|
t.Helper()
|
|
for _, cookie := range response.Result().Cookies() {
|
|
if cookie.Name == auth.CookieName {
|
|
return cookie
|
|
}
|
|
}
|
|
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
|
return nil
|
|
}
|
|
|
|
func csrfToken(t *testing.T, body string) string {
|
|
t.Helper()
|
|
matches := csrfPattern.FindStringSubmatch(body)
|
|
if len(matches) != 2 || matches[1] == "" {
|
|
t.Fatalf("no CSRF token in response body: %q", body)
|
|
}
|
|
return matches[1]
|
|
}
|
|
|
|
func createKey(t *testing.T, body string) string {
|
|
t.Helper()
|
|
matches := createKeyPattern.FindStringSubmatch(body)
|
|
if len(matches) != 2 || matches[1] == "" {
|
|
t.Fatalf("no create key in response body: %q", body)
|
|
}
|
|
return matches[1]
|
|
}
|
|
|
|
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
|
|
t.Helper()
|
|
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
login := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
"username": {"admin"},
|
|
"password": {"test-password"},
|
|
}, sessionCookie(t, page))
|
|
if login.Code != http.StatusSeeOther {
|
|
t.Fatalf("authenticate status = %d, want 303", login.Code)
|
|
}
|
|
return sessionCookie(t, login)
|
|
}
|