Files
remark42/e2e/widgets_test.go
Dmitry VerkhoturovandGitHub 23be25d84a Fix seven widget defects, including the cookies the separate-domain setup needs (#2197)
* Drop the frontend workspace root and re-resolve the lockfile

`frontend/` carried a `package.json`, a `pnpm-workspace.yaml` and the lockfile
for a workspace of exactly one package. Two manifests meant two places to
declare a version, and the app pin was the one that did not win: `preact` and
`@babel/core` were each written twice, and a bump to the app manifest alone
would have been a silent no-op, since `pnpm.overrides` decides and it lived at
the root.

Everything pnpm reads now lives in `frontend/apps/remark42`: dependencies,
`packageManager`, `engines` and the overrides. `frontend/` keeps `.nvmrc`,
`.husky` and `CLAUDE.md`, none of which pnpm reads. The directory nesting
stays: every path in the repository points at `frontend/apps/remark42`,
including the published contributing docs, so moving the package up would have
rewritten 14 files to no benefit.

Moving the manifest kept the old resolutions verbatim, which left optional peer
subtrees the tree no longer reaches: `ts-node` under jest, `@swc/core` under
webpack, `vitest` under `@testing-library/jest-dom`, `tslib` under
`webpack-dev-server`. None is referenced by any config or source file here.
Re-resolving drops 137 packages and moves 59 to versions already permitted by
the ranges in the manifest, 1446 to 1308, with no direct dependency changing
version: the five that look changed differ only in their peer suffix. Every
file `pnpm build` produces is identical in size before and after.

The frontend-deps stage of the Dockerfile sets `CI=true` so the `prepare`
script skips husky, which has no git repository to install hooks into there.

* Stop markdown-only changes triggering heavy workflows, and check the documented versions

`ci-backend.yml`, `ci-build.yml` and `ci-frontend.yml` all end their path
filters with `!**.md`. The e2e workflow did not, so a change to any markdown
file under `frontend/` or `backend/` matched its `frontend/**` and `backend/**`
entries and started a docker build and the whole browser suite. The release
filter had the same hole and two of its own: it names `README.md` and `LICENSE`
on purpose, since `.goreleaser.yml` packages both, so it now excludes markdown
under `backend/` and `frontend/` only. `CLAUDE.md` and the installation page
were listed as well, and neither is packaged.

`ci-site.yml` goes on matching markdown, which is right, since the site is
built from it. It excludes `CLAUDE.md`, so a future `site/CLAUDE.md` cannot
start a site build, and `site/README.md`, which documents how to build the site
rather than being part of it.

The installation page tells a reader that a source build needs Go 1.25, Node
24+ and PNPM 10. Nothing kept those in step with `backend/go.mod`,
`engines.node`, `packageManager` and `.nvmrc`, and the drift is silent: a wrong
version in the docs builds and tests exactly as well as a right one. `.nvmrc`
is the pin with form here, having sat at 16 through the whole node 20 migration
because nothing red ever pointed at it. The check compares each stated version
against its source and holds `.nvmrc` to `engines.node`, and it fails when the
page states no version at all, so removing the claims cannot turn it into a
check that passes by comparing nothing.

Its own workflow rather than a step in an existing one, since the inputs span
the backend module, the frontend manifest and the site.

* Fix the cookie fallback page, asset path, message senders, auth teardown and cookies

Two defects with the same origin: 5825a55b, the January 2021 frontend
rewrite, first released in v1.7.0.

It removed the build entry for comments.html while leaving both the
template and the link to it in place, so the page the auth panel offers
when third-party cookies are blocked has been a 404 ever since, for
exactly the reader who has no other way in. The template needed no
changes; it is built again, and an e2e case now opens it on a thread
carrying a comment and waits for that comment, so the page being served,
its inline script running and it asking for the thread named in its own
query string are all covered. Against an image built without the plugin
entry that case fails on the 404, which is the regression it exists for.

It also fixed the public path to the domain root, so an instance mounted
under a prefix, which manuals/separate-domain documents, asked for
/web/google.svg when its own icons live under that prefix. Fifteen
provider icons in remark.mjs and one in last-comments.mjs. The path is
now derived from the url the bundle was loaded from, which is correct for
both arrangements, and the file loader no longer overrides it.

The host page also accepted postMessage from any window: every frame on a
page can reach window.parent, and the handler resizes the widget, scrolls
the page and opens the profile overlay. It now ignores anything that did
not come from a frame this module created.

A fourth, in the same family: the OAuth flow never tore its polling down.
`subscribed` was declared, checked and cleared but never set, so the guard
against a second subscription was dead code and every provider click
attached another listener pair. The five minute deadline then rejected
without unsubscribing, leaving those listeners and a retry that
reschedules itself for as long as getUser returns null. Cross-domain is
where getUser never stops returning null, so a reader on the arrangement
manuals/separate-domain documents was left polling /auth/user once a
minute for the life of the page, against a route capped at 2 req/s. It
also rejected with no argument, and the caller stores that as the error
state, so the interface had undefined to render. The deadline now tears
the subscription down and rejects with an error.

The message check had a second half. Hardening the parent left the widget
document trusting any sender, and it acts on signout and theme, so
anything holding a reference to the frame could sign a reader out.
`auth.hooks` already checked `event.source !== window.parent`; that check
is now a shared `isFromParent` and the three listeners that lacked it use
it too. The origin cannot stand in for it, since the host page is
whatever site embeds the widget and `ALLOWED_HOSTS` is enforced server
side through `frame-ancestors`.

And createInstance stacked its listeners. It reuses the marked iframe
instead of building one, but installed three listeners plus a title
observer on every call, while destroy could only reach the newest
closure, so a second call without a destroy stranded a set for good. The
listeners of the current instance are now detached before the next set
goes on. Reuse and the ignored config are unchanged: that contract is
open in the backlog note and not settled here.

The auth cookies the embedded case needs were not being delivered, in
both halves of the client's own writer. The name was decorated:
setAuthCookie prefixed with __Host- whenever the page was https, so a
real deployment wrote __Host-JWT and __Host-XSRF-TOKEN while the backend
looks for JWT and the fetcher reads XSRF-TOKEN, and nothing anywhere
reads a prefixed name. Nothing caught it because the prefix is applied
from the page protocol and every test and the dev server run on http;
there is now a second suite pinned to an https page, which is the only
condition that shows it. And the attributes could not be delivered: both
were SameSite=Strict, judged against the top-level site and not the
request's own origin, so a Strict cookie is never sent from a
third-party frame, which is the entire configuration this code exists
for. They now follow the embedding, Strict while the widget shares its
page origin and None with Secure and Partitioned once it does not, since
that is the only third-party form browsers still accept. Over http in a
third-party frame no combination works, and the strict form is written
instead of one the browser would reject outright.

That leaves the client half of #1877 working, whose reporter wanted
AUTH_SEND_JWT_HEADER for exactly this arrangement, and whose first half
merged as #1929. The server's own cookies still carry no Partitioned;
that is upstream work in go-pkgz/auth.

Two plan changes. A review pass corrected its central Path B premise,
which said the first document render is anonymous permanently, in every
configuration: it is anonymous in the configuration remark42 ships,
go-pkgz/auth exposing XSRFIgnoreMethods and remark42 leaving it unset.
The door is not shut, it is closed by a setting, and opening it is
scoped security work and not a flag flip, because GET /deleteme
deletes every comment a user has written and is a GET so the emailed
link works. And the separate-domain arrangement is promoted from a
constraint bullet to a named requirement with acceptance criteria, since
a test that signs in and posts without reloading passes while
persistence is entirely broken.

Review found a seventh, and it was reachable only because of the first:
comments.ejs built its title with innerHTML from the url query
parameter, so restoring the build entry made a reflected XSS live on the
instance origin, where the page is a top-level document, frame-ancestors
does not apply and the /web CSP allows unsafe-inline. The anchor is now
built through the DOM with textContent, and only http and https reach
href, since escaping alone leaves a javascript: url working. Two e2e
subtests pin both halves, and mutation testing separates them: restoring
innerHTML fails four assertions, while keeping the escaping and dropping
only the scheme guard fails the href one alone.

Review also found the poll teardown test did not exercise the poll.
handleWindowVisibilityChange is reachable only from the two listeners
and from the retry it schedules itself, and the test dispatched neither,
so no request was ever made and the assertion compared zero to zero; it
passed with the teardown reverted. It now dispatches focus, asserts
requests are being made and keep coming, and only then that they stop.
And the teardown could not cancel an in-flight getUser: a null resolving
after the deadline ran the code past the await and scheduled a fresh
retry with nothing left to clear it. A closure-local flag checked after
the await stops that, chosen over a second guard at the top of the
handler because only one of the two is detectable by mutation and this
is the one that prevents the stray timer rather than neutering it.

The inline handler in the iframe template accepted messages from any
window while acting on them through location.replace and document.title.
It now takes only the parent, the same check the host page side makes.
2026-08-22 12:34:24 -05:00

410 lines
16 KiB
Go

//go:build e2e
package e2e
import (
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWidgets_LastCommentsRendersIntoTheHostPage covers the one component that writes into the
// embedding page and not into the widget's iframe, so a change to the embed script can
// break it without any iframe test noticing
func TestWidgets_LastCommentsRendersIntoTheHostPage(t *testing.T) {
text := "last comments " + runID
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, poster, frame, "lastcommenter")
postComment(t, frame, text)
page := newPage(t)
// the stylesheet is appended at runtime and nothing waits for it, so the comments render
// whether or not it arrives. wait for the response itself instead of sampling afterwards,
// which reads whatever has landed by then and passes when the miss is still in flight
pauseForAuthLimit()
css, err := page.ExpectResponse("**/last-comments.css", func() error {
_, gerr := page.Goto(baseURL + "/web/last-comments.html")
return gerr
}, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))})
require.NoError(t, err, "the page never asked for its stylesheet")
assert.Equal(t, 200, css.Status(), "the last-comments stylesheet did not load")
list := page.Locator(".remark42__last-comments")
waitVisible(t, list)
waitVisible(t, list.Locator("text="+text))
}
// TestWidgets_DeleteMePageServesAndRuns covers the GDPR delete page, which nothing else opens. It
// needs an admin token to do its work, so this drives the branch it takes without one: reaching
// that message proves the html and its bundle were both served and executed.
func TestWidgets_DeleteMePageServesAndRuns(t *testing.T) {
page := newPage(t)
pauseForAuthLimit()
resp, err := page.Goto(baseURL + "/web/deleteme.html")
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, 200, resp.Status())
waitVisible(t, page.Locator("text=You are not logged in"))
}
// TestWidgets_CounterFillsInTheCommentCount covers the other host-page script.
//
// The demo page hard-codes both counters to one fixed url, so "some digits appeared" would
// hold just as well if the script always wrote a constant. Post to that same url and assert
// the rendered number moves by exactly as many comments as were added.
func TestWidgets_CounterFillsInTheCommentCount(t *testing.T) {
const counted = "https://remark42.com/demo/"
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, poster, frame, "countertester")
before := commentCount(t, poster, counted)
for i := range 2 {
status, body := pageFetch(t, poster, "POST", baseURL+"/api/v1/comment?site=remark", map[string]any{
"text": fmt.Sprintf("counted %d %s", i, runID),
"locator": map[string]string{"site": "remark", "url": counted},
})
require.Equal(t, 201, status, "could not seed a comment: %s", body)
}
page := newPage(t)
// the demo page points both of its counters at the same url, one through data-url and one
// through remark_config, so as shipped the two branches are indistinguishable. add a third
// node carrying this test's own thread, which only the data-url branch can resolve
thread := threadURL(t)
err := page.AddInitScript(playwright.Script{
Content: playwright.String(`document.addEventListener('DOMContentLoaded', () => {
const node = document.createElement('span');
node.className = 'remark42__counter';
node.id = 'own-thread-counter';
node.dataset.url = ` + fmt.Sprintf("%q", thread) + `;
document.body.appendChild(node);
})`),
})
require.NoError(t, err)
pauseForAuthLimit()
_, err = page.Goto(baseURL + "/web/counter.html")
require.NoError(t, err)
counters := page.Locator(".remark42__counter")
count, err := counters.Count()
require.NoError(t, err)
require.NotZero(t, count, "the counter demo page should carry at least one counter node")
want := strconv.Itoa(before + 2)
for i := range count {
node := counters.Nth(i)
id, aerr := node.GetAttribute("id")
require.NoError(t, aerr)
if id == "own-thread-counter" {
continue // asserted separately below, it counts a different url
}
eventually(t, waitTimeout, "counter never reported the seeded comments", func() bool {
txt, ierr := pollText(node)
return ierr == nil && txt == want
})
}
// and the data-url branch resolves to its own thread and not the page's
own := strconv.Itoa(commentCount(t, poster, thread))
eventually(t, waitTimeout, "the data-url counter did not report its own thread", func() bool {
txt, ierr := pollText(page.Locator("#own-thread-counter"))
return ierr == nil && txt == own
})
}
func TestWidgets_LegacyJSURLLoadsAsAClassicScript(t *testing.T) {
thread := threadURL(t)
poster := newPage(t)
frame := openThread(t, poster)
signInAnon(t, poster, frame, "aliastester")
postComment(t, frame, "alias "+runID)
want := strconv.Itoa(commentCount(t, poster, thread))
page := newPage(t)
pauseForAuthLimit()
_, err := page.Goto(baseURL + "/web/privacy.html")
require.NoError(t, err)
_, err = page.Evaluate(`([host, url]) => {
window.remark_config = { host, site_id: 'remark' };
const node = document.createElement('span');
node.className = 'remark42__counter';
node.dataset.url = url;
document.body.appendChild(node);
}`, []any{baseURL, thread})
require.NoError(t, err)
counter := page.Locator(".remark42__counter")
blank, err := pollText(counter)
require.NoError(t, err)
require.Empty(t, blank, "the counter must start blank or the assertion below is vacuous")
_, err = page.AddScriptTag(playwright.PageAddScriptTagOptions{URL: playwright.String(baseURL + "/web/counter.js")})
require.NoError(t, err)
eventually(t, waitTimeout, "the legacy counter.js url never filled the counter", func() bool {
txt, ierr := pollText(counter)
return ierr == nil && txt == want
})
}
// commentCount asks the API what the counter should be showing
func commentCount(t *testing.T, page playwright.Page, url string) int {
t.Helper()
status, body := pageFetch(t, page, "POST", baseURL+"/api/v1/counts?site=remark", []string{url})
require.Equal(t, 200, status, "counts: %s", body)
var counts []struct {
Count int `json:"count"`
}
require.NoError(t, json.Unmarshal([]byte(body), &counts))
require.Len(t, counts, 1)
return counts[0].Count
}
// TestWidgets_ProfileOpensInItsOwnIframe covers the postMessage handoff: the widget asks the
// parent to open the profile, and the parent creates a second iframe outside #remark42
func TestWidgets_ProfileOpensInItsOwnIframe(t *testing.T) {
page := newPage(t)
frame := openThread(t, page)
signInDev(t, page, frame)
// match on the page parameter and not the word: the widget's own src carries the
// thread url, which contains this test's name
profile := page.Locator(`iframe[src*="page=profile"]`)
before, err := profile.Count()
require.NoError(t, err)
require.Zero(t, before, "no profile iframe should exist before it is asked for")
require.NoError(t, frame.Locator(`[title="Open My Profile"]`).Click())
waitVisible(t, profile.First())
src, err := profile.First().GetAttribute("src")
require.NoError(t, err)
assert.Contains(t, src, "current=1", "the profile should open on the signed-in user")
// the frame reveals itself after five seconds whether or not its bundle ran, so a 404 or a
// dead script would satisfy a visibility check on its own. look inside it
profileFrame := page.FrameLocator(`iframe[src*="page=profile"]`)
waitVisible(t, profileFrame.Locator("text=dev_user").First())
// it lives outside the widget's own container, appended straight to the body
inWidget, err := page.Locator(`#remark42 iframe[src*="page=profile"]`).Count()
require.NoError(t, err)
assert.Zero(t, inWidget)
}
// TestWidgets_EveryLocaleLoadsAndRenders drives the dynamic import behind every translation, once
// per catalog. The catalogs are separate chunks the bundle fetches at runtime, so a change to
// how chunks are named or emitted breaks them without touching a line of widget code, and the
// failure is silent: loadLocale falls back to english instead of throwing, which is also what an
// unrecognized name does. Each case therefore compares against the catalog on disk, so english
// coming back is a failure and not a pass.
//
// The catalog set comes from the locales directory and not a list here, so a language added to
// the app is covered without an edit, and injected into a plain page and not the demo one,
// which is the only way to hand the widget a remark_config of this test's choosing
func TestWidgets_EveryLocaleLoadsAndRenders(t *testing.T) {
const localesDir = "../frontend/apps/remark42/app/locales"
entries, err := os.ReadDir(localesDir)
require.NoError(t, err, "reading %s", localesDir)
require.NotEmpty(t, entries, "no catalogs in %s, so this test would assert nothing", localesDir)
for _, entry := range entries {
locale, found := strings.CutSuffix(entry.Name(), ".json")
if !found {
continue
}
t.Run(locale, func(t *testing.T) {
raw, rerr := os.ReadFile(filepath.Join(localesDir, entry.Name())) //nolint:gosec // name from the walk
require.NoError(t, rerr)
var catalog map[string]string
require.NoError(t, json.Unmarshal(raw, &catalog))
want := catalog["commentForm.input-placeholder"]
require.NotEmpty(t, want, "%s carries no placeholder message to compare against", entry.Name())
page := newPage(t)
// this case never signs in, and the widget probes /auth/status on every load. that
// probe is capped at 2/s for the whole suite, so twenty four of them would spend a
// budget the sign-in cases need and manufacture 429s for whichever test runs next
require.NoError(t, page.Route("**/auth/status**", func(route playwright.Route) {
require.NoError(t, route.Fulfill(playwright.RouteFulfillOptions{
Status: playwright.Int(http.StatusOK),
ContentType: playwright.String("application/json"),
Body: playwright.String(`{"status":"not logged in"}`),
}))
}))
_, gerr := page.Goto(baseURL + "/web/privacy.html")
require.NoError(t, gerr)
_, eerr := page.Evaluate(`([host, url, locale]) => {
window.remark_config = { host, site_id: 'remark', url, locale };
const node = document.createElement('div');
node.id = 'remark42';
document.body.appendChild(node);
}`, []any{baseURL, threadURL(t), locale})
require.NoError(t, eerr)
_, aerr := page.AddScriptTag(playwright.PageAddScriptTagOptions{URL: playwright.String(baseURL + "/web/embed.mjs")})
require.NoError(t, aerr)
// not widget(): commentFormSel matches the form's aria-label, which is itself
// translated, so the shared helper only ever finds an english widget
frame := page.FrameLocator("#remark42 iframe")
textarea := frame.Locator("form textarea").First()
waitVisible(t, textarea)
got, perr := textarea.GetAttribute("placeholder")
require.NoError(t, perr)
assert.Equal(t, want, got, "the %s catalog did not render, so the widget fell back to english", locale)
})
}
}
// TestWidgets_SimpleViewHidesTheEditingFurniture covers simple_view, the one mode of this kind
// that is a query parameter and not a server flag, so both branches run against the same
// instance. It takes the markdown toolbar and the preview away and leaves everything else,
// including the markdown help line, which is why that is not asserted either way. The full-view
// branch is the control: without it these would hold on a widget that never had a toolbar
func TestWidgets_SimpleViewHidesTheEditingFurniture(t *testing.T) {
for _, tc := range []struct {
name string
simple bool
}{
{"full view", false},
{"simple view", true},
} {
t.Run(tc.name, func(t *testing.T) {
page := newPage(t)
config := map[string]any{}
if tc.simple {
config["simple_view"] = true
}
embedConfig(t, page, config)
frame := widget(t, page)
// signed in, since the preview button is only offered to somebody who could post
signInAnon(t, page, frame, anonName("simpleview"))
// by element name and not by test id, which the production bundle strips, or by
// class, which it hashes
toolbar := frame.Locator(`md-bold`).First()
preview := frame.Locator(`button:has-text("Preview")`).First()
if tc.simple {
waitHidden(t, toolbar, "simple_view left the markdown toolbar in place")
waitHidden(t, preview, "simple_view left the preview button in place")
return
}
waitVisible(t, toolbar)
waitVisible(t, preview)
})
}
}
// TestWidgets_CommentsPageOpensAThreadOnItsOwnOrigin covers /web/comments.html, which is where the
// widget sends a reader whose browser blocks third-party storage: the auth panel links it, and the
// page mounts the widget on the instance's own origin, where the storage is first-party.
//
// The page is built from templates/comments.ejs by HtmlWebpackPlugin, and the build stopped
// emitting it while the link went on pointing at it, so readers who followed it reached a 404.
// Nothing noticed, because it is the one page no other test opens.
func TestWidgets_CommentsPageOpensAThreadOnItsOwnOrigin(t *testing.T) {
thread := threadURL(t)
text := "cookie fallback " + runID
poster := newPage(t)
posted := openURL(t, poster, thread)
signInAnon(t, poster, posted, "fallbackposter")
postComment(t, posted, text)
page := newPage(t)
pauseForAuthLimit()
resp, err := page.Goto(fmt.Sprintf("%s/web/comments.html?site_id=remark&url=%s",
baseURL, neturl.QueryEscape(thread)))
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, 200, resp.Status(),
"the page the auth panel links to when third-party storage is blocked is not served")
// it reads the thread out of its own query string and mounts the widget itself, so reaching
// the comment proves the page was served, its inline script ran, and it asked for the right
// thread. a 200 alone would be satisfied by any page the server happened to return
frame := widget(t, page)
waitVisible(t, comment(frame, text))
}
// TestWidgets_CommentsPageRefusesInjectedMarkup covers the reflected injection the fallback page
// carried. It puts the url from its own query string into the title, and building that with
// innerHTML let a crafted url run script in a top-level document on the instance's own origin,
// which is where the reader's session lives; inside the widget frame the same payload would be
// far less use. The hole and the page arrived together, since it is only reachable at all now
// that the build emits it again
func TestWidgets_CommentsPageRefusesInjectedMarkup(t *testing.T) {
for _, tc := range []struct {
name, url string
}{
{"markup", `"><img src=x onerror=window.__xss=1>`},
{"javascript scheme", "javascript:window.__xss=1"},
} {
t.Run(tc.name, func(t *testing.T) {
page := newPage(t)
pauseForAuthLimit()
// %20 and not +, which is what a browser produces and what the page's own parser
// reads back: it decodes with decodeURIComponent, which leaves a + as a plus
escaped := strings.ReplaceAll(neturl.QueryEscape(tc.url), "+", "%20")
_, err := page.Goto(fmt.Sprintf("%s/web/comments.html?site_id=remark&url=%s", baseURL, escaped))
require.NoError(t, err)
waitVisible(t, page.Locator("#title"))
// nothing the url asked for became an element
imgs, err := page.Locator("#title img").Count()
require.NoError(t, err)
assert.Zero(t, imgs, "the url reached the page as markup, so a crafted one runs script "+
"on the instance's own origin")
ran, err := page.Evaluate(`() => Boolean(window.__xss)`)
require.NoError(t, err)
assert.Equal(t, false, ran, "the injected script ran")
// and the title still shows what it was given, so what changed is the escaping and
// not the feature
txt, err := page.Locator("#title").InnerText()
require.NoError(t, err)
assert.Contains(t, txt, tc.url, "the title should carry the url as text")
// only http(s) reaches href, or the anchor itself becomes the payload
href, err := page.Locator("#title a").First().GetAttribute("href")
require.NoError(t, err)
assert.Empty(t, href, "a url the page will not navigate to should not become a link")
})
}
}