Files
sm-user-plugin/backend_link_show_test.go
Jakub Zych 92676ba14e fix: the backend link show and confirm POSTs ignore a code in the query string
readFields falls back to r.Form, which includes the query string, so
POST /backend-link/show?code=... and /confirm?code=... worked and put the
code into access logs. Both routes now read the code from the body only
(JSON or a form body).
2026-10-23 21:08:00 +02:00

142 lines
5.8 KiB
Go

package user
import (
"bytes"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TestBackendLinkShowPost pins plan 08's link code transport (C-WR-01,
// C-IN-06): POST /backend-link/show answers exactly like the GET route (the
// data for a live code, 404 for an unknown, a used or an expired one), both
// with Cache-Control no-store, private; a code in the query string of the
// show or confirm POST is ignored (RC-IN-01); My account answers the link as
// <frontend_url>/link-admin#code=<64 hex> with no query string;
// golem15.user.backend_links.page_path changes the path, and a value
// without a leading slash gets one. Postgres only: under -short nothing
// runs.
func TestBackendLinkShowPost(t *testing.T) {
if testing.Short() {
return
}
env := newAdminEnv(t)
show := func(t *testing.T, code string) (get, post *struct {
status int
body string
cache string
}) {
t.Helper()
g := env.call(t, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
p := env.call(t, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code})
return &struct {
status int
body string
cache string
}{g.Code, g.Body.String(), g.Header().Get("Cache-Control")}, &struct {
status int
body string
cache string
}{p.Code, p.Body.String(), p.Header().Get("Cache-Control")}
}
same := func(t *testing.T, code string, status int) {
t.Helper()
get, post := show(t, code)
if get.status != status || post.status != status {
t.Fatalf("show %s: GET %d, POST %d, want %d", code[:8], get.status, post.status, status)
}
if !bytes.Equal([]byte(get.body), []byte(post.body)) {
t.Fatalf("POST show differs from GET:\nGET %s\nPOST %s", get.body, post.body)
}
if get.cache != "no-store, private" || post.cache != "no-store, private" {
t.Fatalf("Cache-Control: GET %q, POST %q", get.cache, post.cache)
}
}
t.Run("POST show answers like GET", func(t *testing.T) {
token := env.admin(t)
live := env.startLink(t, token)
same(t, live, http.StatusOK)
if _, post := show(t, live); !strings.Contains(post.body, `"admin"`) || !strings.Contains(post.body, `"expires_at"`) {
t.Fatalf("POST show data = %s", post.body)
}
same(t, strings.Repeat("a", 64), http.StatusNotFound)
used := env.startLink(t, env.admin(t))
if status, body := env.confirmLink(t, freshUserToken(t, env.seedUser(t, "show-used", nil)), used); status != http.StatusOK {
t.Fatalf("confirm = %d %s", status, body)
}
same(t, used, http.StatusNotFound)
expiring := env.admin(t)
expired := env.startLink(t, expiring)
if err := env.gdb.Model(&models.BackendLinkRequest{}).Where("backend_user_id = ?", adminID(t, expiring)).
Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
same(t, expired, http.StatusNotFound)
})
t.Run("a code in the query string is ignored", func(t *testing.T) {
live := env.startLink(t, env.admin(t))
path := "/_user/api/v1/backend-link/show?code=" + live
for name, send := range map[string]func() *httptest.ResponseRecorder{
"no body": func() *httptest.ResponseRecorder { return env.send(t, http.MethodPost, path, "", nil, "", nil) },
"a JSON body without a code": func() *httptest.ResponseRecorder { return env.call(t, http.MethodPost, path, "", map[string]string{}) },
"a form body without a code": func() *httptest.ResponseRecorder {
return env.send(t, http.MethodPost, path, "", []byte("other=1"), "application/x-www-form-urlencoded", nil)
},
} {
if got := send(); got.Code != http.StatusNotFound {
t.Fatalf("show with ?code= and %s = %d %s, want 404", name, got.Code, got.Body.String())
}
}
user := env.seedUser(t, "show-query", nil)
rec := env.send(t, http.MethodPost, "/_user/api/v1/backend-link/confirm?code="+live, freshUserToken(t, user), []byte("other=1"), "application/x-www-form-urlencoded", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("confirm with ?code= = %d %s, want 404", rec.Code, rec.Body.String())
}
// A form body still carries the code, and the query string never did.
form := env.send(t, http.MethodPost, "/_user/api/v1/backend-link/show", "", []byte("code="+live), "application/x-www-form-urlencoded", nil)
if form.Code != http.StatusOK {
t.Fatalf("show with the code in a form body = %d %s", form.Code, form.Body.String())
}
if status, body := env.confirmLink(t, freshUserToken(t, user), live); status != http.StatusOK {
t.Fatalf("confirm with the code in the body after the refusals = %d %s", status, body)
}
})
t.Run("the My account URL carries the code in the fragment only", func(t *testing.T) {
var out struct {
URL string `json:"url"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), env.admin(t), map[string]any{}), &out)
if !regexp.MustCompile(`^https://app\.example\.test/link-admin#code=[a-f0-9]{64}$`).MatchString(out.URL) || strings.Contains(out.URL, "?") {
t.Fatalf("link URL = %q", out.URL)
}
})
for _, c := range []struct{ value, want string }{{"/pl/polacz", "/pl/polacz"}, {"pl/polacz", "/pl/polacz"}} {
t.Run("page_path "+c.value, func(t *testing.T) {
custom := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.backend_links.page_path", c.value); err != nil {
t.Fatal(err)
}
})
var out struct {
URL string `json:"url"`
}
adminInto(t, custom.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), custom.admin(t), map[string]any{}), &out)
if !regexp.MustCompile(`^https://app\.example\.test` + regexp.QuoteMeta(c.want) + `#code=[a-f0-9]{64}$`).MatchString(out.URL) {
t.Fatalf("link URL with page_path %q = %q", c.value, out.URL)
}
})
}
}