diff --git a/Justfile b/Justfile
index 63a5ea89f25..03f1432588d 100644
--- a/Justfile
+++ b/Justfile
@@ -478,6 +478,12 @@ SHELLTEST := STACK + ' exec -- shelltest --execdir --threads=40'
# --test so that subsequent `stack test` won't recompile everything
# --no-run-tests to avoid running the slow doctest suite every time
+# run hledger-web's browser tests against the current build, with any playwright OPTS (needs setup, see hledger-web/test/browser/README.md)
+@browsertest *PWOPTS:
+ {{ STACK }} build hledger-web
+ cd hledger-web/test/browser && \
+ HLEDGER_WEB="{{ STACK }} exec -- hledger-web" pnpm test {{ PWOPTS }}
+
# too fragile:
# echo
# just perftest {{ STOPTS }}
diff --git a/hledger-web/Hledger/Web/Test.hs b/hledger-web/Hledger/Web/Test.hs
index 2048264679e..9fa7e89a0aa 100644
--- a/hledger-web/Hledger/Web/Test.hs
+++ b/hledger-web/Hledger/Web/Test.hs
@@ -44,6 +44,11 @@ module Hledger.Web.Test (
import Data.String (fromString)
import Data.Function ((&))
import Data.Text qualified as T
+import Data.Text.IO qualified as TIO
+import Data.Text.Lazy qualified as TL
+import Data.Text.Lazy.Encoding qualified as TLE
+import System.Directory (getTemporaryDirectory)
+import System.FilePath ((>))
import Test.Hspec (hspec)
import Yesod.Default.Config
import Yesod.Test
@@ -85,6 +90,31 @@ runTests testsdesc rawopts j tests = do
app <- makeAppWith j yconf wopts
hspec $ yesodSpec app $ ydescribe testsdesc tests -- https://hackage.haskell.org/package/yesod-test/docs/Yesod-Test.html
+-- | Assert that a journal file on disk does not contain the given text,
+-- ie that a request which should have been refused did not write to it.
+journalFileLacks :: FilePath -> T.Text -> YesodExample App ()
+journalFileLacks f t = do
+ txt <- liftIO $ TIO.readFile f
+ assertEq (f ++ " should not contain " ++ T.unpack t) (T.isInfixOf t txt) False
+
+-- | The name of the edit form's textarea in the current page. The edit form
+-- does not name that field, so yesod generates one (eg "f1"); find it rather
+-- than hardcode it, or a post can silently do nothing.
+editFieldName :: YesodExample App T.Text
+editFieldName = do
+ els <- htmlQuery "textarea"
+ case els of
+ [] -> error' "no textarea in the edit form"
+ (e:_) -> do
+ let needle = "name=\""
+ html = TL.toStrict (TLE.decodeUtf8 e)
+ (_, fromneedle) = T.breakOn needle html
+ afterneedle = T.drop (T.length needle) fromneedle
+ fieldname = T.takeWhile (/= '"') afterneedle
+ if T.null fromneedle
+ then error' "the edit form's textarea has no name"
+ else return fieldname
+
-- | Run hledger-web's built-in tests using the hspec test runner.
hledgerWebTest :: IO ()
hledgerWebTest = do
@@ -168,3 +198,116 @@ hledgerWebTest = do
-- bodyContains "href=\"https://base"
-- bodyContains "src=\"https://files"
+ -- Tests for the write side: yesod's CSRF protection, and the restriction of
+ -- file access to the journal's own files. These use a journal in a temp file,
+ -- so that if one of these protections ever fails, the test writes there
+ -- rather than to the journal the developer happens to have configured.
+ tmpdir <- getTemporaryDirectory
+ let
+ jfile = tmpdir > "hledger-web-test.journal"
+ jtext = T.pack $ unlines
+ ["2025-01-01 gift"
+ ," assets:bank:checking 10"
+ ," income:gifts"
+ ]
+ -- A path is only editable if it is one of the journal's own files, so
+ -- these must all be refused however they are spelled.
+ otherfiles =
+ ["/etc/passwd"
+ ,"../../../../etc/passwd"
+ ,"....//....//etc/passwd"
+ ,jfile ++ "/../../etc/passwd"
+ ]
+ TIO.writeFile jfile jtext
+ let wiopts = rawOptsToInputOpts d usecolor $ mkRawOpts [("file", jfile)]
+ wpj <- readJournal'' jtext
+ wj <- fmap (either error' id) . runExceptT $ journalFinalise wiopts jfile jtext wpj
+ runTests "hledger-web write requests" [("file", jfile), ("allow", "edit")] wj $ do
+
+ yit "puts a CSRF token in the add form" $ do
+ get JournalR
+ statusIs 200
+ bodyContains "name=\"_token\""
+
+ -- These three post the same valid, balanced transaction, and differ only
+ -- in the CSRF token, so that the two failures can only be about the token.
+ -- The form is wrapped in identifyForm, so _formid must be sent too, or the
+ -- post is ignored as FormMissing and these would pass either way.
+ let postTransaction desc = do
+ setMethod "POST"
+ setUrl AddR
+ addPostParam "_formid" "identify-add"
+ addPostParam "date" "2025-02-02"
+ addPostParam "description" desc
+ addPostParam "account" "assets:bank:checking"
+ addPostParam "amount" "1"
+ addPostParam "account" "income:gifts"
+ addPostParam "amount" ""
+
+ yit "does not add a transaction when the CSRF token is missing" $ do
+ request $ postTransaction "CsrfNoToken"
+ bodyNotContains "Transaction added"
+ journalFileLacks jfile "CsrfNoToken"
+
+ yit "does not add a transaction when the CSRF token is wrong" $ do
+ request $ do
+ postTransaction "CsrfBadToken"
+ addPostParam "_token" "not-the-token"
+ bodyNotContains "Transaction added"
+ journalFileLacks jfile "CsrfBadToken"
+
+ -- The control for the two tests above: the same request, with a real
+ -- token, is accepted. Without this they could pass for the wrong reason.
+ yit "adds a transaction when the CSRF token is present" $ do
+ get JournalR
+ statusIs 200
+ request $ do
+ postTransaction "CsrfGoodToken"
+ addToken -- from the page just fetched
+ statusIs 303 -- a successful add redirects to the journal
+ _ <- followRedirect
+ bodyContains "Transaction added"
+ txt <- liftIO $ TIO.readFile jfile
+ assertEq "journal should contain the added transaction"
+ (T.isInfixOf "CsrfGoodToken" txt) True
+
+ -- Likewise for the edit form: the same save, with and without the token.
+ let editJournal fld desc = do
+ setMethod "POST"
+ setUrl (EditR jfile)
+ addPostParam "_formid" "identify-edit"
+ addPostParam fld $
+ "2025-03-03 " <> desc <> "\n assets:bank:checking 1\n income:gifts\n"
+
+ yit "does not save the journal when the CSRF token is missing" $ do
+ get (EditR jfile)
+ statusIs 200
+ fld <- editFieldName
+ request $ editJournal fld "CsrfEdit"
+ bodyNotContains "Saved journal"
+ journalFileLacks jfile "CsrfEdit"
+
+ yit "saves the journal when the CSRF token is present" $ do
+ get (EditR jfile)
+ statusIs 200
+ fld <- editFieldName
+ request $ do
+ editJournal fld "CsrfEditOk"
+ addToken -- from the page just fetched
+ txt <- liftIO $ TIO.readFile jfile
+ assertEq "journal should contain the saved text"
+ (T.isInfixOf "CsrfEditOk" txt) True
+
+ yit "serves its own journal file for editing" $ do
+ get (EditR jfile)
+ statusIs 200
+
+ forM_ otherfiles $ \otherfile -> do
+ yit ("refuses to edit " ++ otherfile) $ do
+ get (EditR otherfile)
+ statusIs 404
+ yit ("refuses to download " ++ otherfile) $ do
+ get (DownloadR otherfile)
+ statusIs 404
+
+
diff --git a/hledger-web/test/browser/.gitignore b/hledger-web/test/browser/.gitignore
new file mode 100644
index 00000000000..dbd64df8309
--- /dev/null
+++ b/hledger-web/test/browser/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+test-results/
+playwright-report/
diff --git a/hledger-web/test/browser/README.md b/hledger-web/test/browser/README.md
new file mode 100644
index 00000000000..874630fb8d0
--- /dev/null
+++ b/hledger-web/test/browser/README.md
@@ -0,0 +1,58 @@
+# hledger-web browser tests
+
+End-to-end tests for the hledger-web UI, using [Playwright](https://playwright.dev).
+Unlike the yesod-test suite (`Hledger/Web/Test.hs`), these run a real browser, so
+they cover the parts of hledger-web that only exist once javascript runs: the add
+form, autocomplete, the date picker, keyboard shortcuts, sidebar state, and hash
+highlighting.
+
+- `webui.spec.js` — the UI's current behavior, so that changes to it are deliberate.
+- `security.spec.js` — journal data is rendered as text and not markup, including the
+ data handed to the autocomplete's javascript. `fixture.journal` deliberately
+ contains html/javascript payloads for this.
+
+Nothing here is part of `stack build` or `stack test`; the suite is opt-in and needs
+node only to run it.
+
+Each run starts its own hledger-web on port 5099 (override with `HLEDGER_WEB_PORT`)
+against a scratch copy of `fixture.journal`, and stops it afterwards. The tests add
+and edit transactions, so they need `--allow=edit`, which the setup passes.
+
+## Setup (once)
+
+Note: running these tests means installing and running javascript tooling from
+the npm registry, and a browser it downloads. That is more exposure than the
+rest of hledger's test suites carry, and the npm ecosystem has a history of
+compromised packages. The settings below reduce the risk but do not remove it;
+if that is not a trade you want to make on a machine you care about, run these
+tests in a container or a throwaway environment, or not at all. The Haskell
+suites (`stack test`) need none of this.
+
+Uses [pnpm](https://pnpm.io) (>= 10). Config is in `pnpm-workspace.yaml`:
+- `onlyBuiltDependencies: []` (no dependency build scripts run) and
+- `minimumReleaseAge: 1440` (nothing published in the last 24h).
+- `pnpm-lock.yaml` is committed. The one dependency is `@playwright/test`.
+
+```sh
+corepack enable # use the pnpm version pinned in package.json
+cd hledger-web/test/browser
+pnpm install --frozen-lockfile # install exactly what pnpm-lock.yaml pins
+pnpm exec playwright install chromium # download the test browser
+```
+
+## Run
+
+ # using a hledger-web binary on $PATH:
+ pnpm test
+
+ # or say how to run hledger-web (eg to test your working copy):
+ HLEDGER_WEB="stack exec -- hledger-web" pnpm test
+
+ # just the security tests:
+ pnpm test security
+
+ # watch the browser while it runs:
+ pnpm test:headed
+
+ # run one test:
+ pnpm exec playwright test -g "adds a transaction"
diff --git a/hledger-web/test/browser/fixture.journal b/hledger-web/test/browser/fixture.journal
new file mode 100644
index 00000000000..5465b5f0def
--- /dev/null
+++ b/hledger-web/test/browser/fixture.journal
@@ -0,0 +1,34 @@
+; Test journal for the e2e suite. Tests add to and edit a scratch copy of
+; this file, so it is safe to modify.
+;
+; The last transaction deliberately carries html/javascript payloads in a
+; description, an account name and a comment. security.spec.js asserts that
+; every view renders them as text. Please keep them here.
+
+2025-01-01 opening balances
+ assets:bank:checking 1000.00
+ equity:opening
+
+2025-01-05 Grocer Green | weekly shop
+ expenses:food:groceries 52.10
+ assets:bank:checking
+
+2025-01-10 Metro Transit
+ expenses:transport:transit 21.50
+ assets:bank:checking
+
+2025-02-01 * Employer Inc | payroll
+ assets:bank:checking 3200.00
+ income:salary
+
+2025-02-03 Grocer Green | weekly shop
+ expenses:food:groceries 48.75
+ assets:bank:checking
+
+2025-02-14 Cafe Luna
+ expenses:food:dining 18.00
+ assets:bank:checking
+
+2025-03-01 Payee
| desc
; comment
+ expenses:food:dining:xss 1.00
+ assets:bank:checking
diff --git a/hledger-web/test/browser/global-setup.js b/hledger-web/test/browser/global-setup.js
new file mode 100644
index 00000000000..1f1451484ae
--- /dev/null
+++ b/hledger-web/test/browser/global-setup.js
@@ -0,0 +1,54 @@
+// Start hledger-web for the browser test run, on a scratch copy of fixture.journal
+// (tests add and edit transactions, so the journal must be disposable).
+//
+// The binary is located by, in order:
+// 1. $HLEDGER_WEB (a command, may contain spaces, e.g. "stack exec -- hledger-web")
+// 2. `stack exec -- hledger-web` if a stack project is detected two dirs up
+// 3. plain `hledger-web` from $PATH
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const PORT = process.env.HLEDGER_WEB_PORT || '5099';
+const URL = process.env.HLEDGER_WEB_URL || `http://127.0.0.1:${PORT}`;
+
+function serverCommand() {
+ if (process.env.HLEDGER_WEB) return process.env.HLEDGER_WEB.split(/\s+/);
+ const repoRoot = path.resolve(__dirname, '..', '..', '..');
+ if (fs.existsSync(path.join(repoRoot, 'stack.yaml')))
+ return ['stack', 'exec', '--', 'hledger-web'];
+ return ['hledger-web'];
+}
+
+function waitForServer(url, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolve, reject) => {
+ (function poll() {
+ http.get(url + '/journal', res => {
+ res.resume();
+ res.statusCode < 500 ? resolve() : retry();
+ }).on('error', retry);
+ function retry() {
+ if (Date.now() > deadline) return reject(new Error(`hledger-web did not start at ${url}`));
+ setTimeout(poll, 300);
+ }
+ })();
+ });
+}
+
+module.exports = async () => {
+ const journal = path.join(os.tmpdir(), `hledger-web-browser-${process.pid}.journal`);
+ fs.copyFileSync(path.join(__dirname, 'fixture.journal'), journal);
+ process.env.BROWSER_JOURNAL = journal;
+
+ const [cmd, ...args] = serverCommand();
+ const child = spawn(cmd, [...args,
+ '-f', journal, '--serve', '--host', '127.0.0.1', '--port', PORT, '--allow=edit',
+ ], { stdio: 'ignore', detached: true });
+ child.unref();
+ fs.writeFileSync(path.join(os.tmpdir(), 'hledger-web-browser.pid'), String(child.pid));
+
+ await waitForServer(URL, 60000);
+};
diff --git a/hledger-web/test/browser/global-teardown.js b/hledger-web/test/browser/global-teardown.js
new file mode 100644
index 00000000000..2a7d03ea212
--- /dev/null
+++ b/hledger-web/test/browser/global-teardown.js
@@ -0,0 +1,15 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+module.exports = async () => {
+ const pidFile = path.join(os.tmpdir(), 'hledger-web-browser.pid');
+ try {
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
+ if (pid) process.kill(pid);
+ fs.unlinkSync(pidFile);
+ } catch (e) { /* already gone */ }
+ try {
+ if (process.env.BROWSER_JOURNAL) fs.unlinkSync(process.env.BROWSER_JOURNAL);
+ } catch (e) { /* already gone */ }
+};
diff --git a/hledger-web/test/browser/package.json b/hledger-web/test/browser/package.json
new file mode 100644
index 00000000000..02163113df3
--- /dev/null
+++ b/hledger-web/test/browser/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "hledger-web-browser",
+ "private": true,
+ "description": "Browser end-to-end tests for hledger-web (Playwright). See README.md.",
+ "scripts": {
+ "test": "playwright test",
+ "test:headed": "playwright test --headed"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.49.0"
+ },
+ "packageManager": "pnpm@10.33.0"
+}
diff --git a/hledger-web/test/browser/playwright.config.js b/hledger-web/test/browser/playwright.config.js
new file mode 100644
index 00000000000..ec8689b388b
--- /dev/null
+++ b/hledger-web/test/browser/playwright.config.js
@@ -0,0 +1,17 @@
+// Playwright config for hledger-web browser tests.
+// The web server is started per-run by global-setup.js (see there for
+// how the hledger-web binary is located and the test journal prepared).
+const { defineConfig } = require('@playwright/test');
+
+module.exports = defineConfig({
+ testDir: '.',
+ globalSetup: './global-setup.js',
+ globalTeardown: './global-teardown.js',
+ timeout: 30000,
+ // hledger-web mutates one shared journal file; keep tests serial.
+ workers: 1,
+ use: {
+ baseURL: process.env.HLEDGER_WEB_URL || 'http://127.0.0.1:5099',
+ },
+ reporter: [['list']],
+});
diff --git a/hledger-web/test/browser/pnpm-lock.yaml b/hledger-web/test/browser/pnpm-lock.yaml
new file mode 100644
index 00000000000..0d9c688d9be
--- /dev/null
+++ b/hledger-web/test/browser/pnpm-lock.yaml
@@ -0,0 +1,52 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.49.0
+ version: 1.62.1
+
+packages:
+
+ '@playwright/test@1.62.1':
+ resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+snapshots:
+
+ '@playwright/test@1.62.1':
+ dependencies:
+ playwright: 1.62.1
+
+ fsevents@2.3.2:
+ optional: true
+
+ playwright-core@1.62.1: {}
+
+ playwright@1.62.1:
+ dependencies:
+ playwright-core: 1.62.1
+ optionalDependencies:
+ fsevents: 2.3.2
diff --git a/hledger-web/test/browser/pnpm-workspace.yaml b/hledger-web/test/browser/pnpm-workspace.yaml
new file mode 100644
index 00000000000..7959e356a5d
--- /dev/null
+++ b/hledger-web/test/browser/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+minimumReleaseAge: 1440
+onlyBuiltDependencies: []
diff --git a/hledger-web/test/browser/security.spec.js b/hledger-web/test/browser/security.spec.js
new file mode 100644
index 00000000000..3d0d573fc11
--- /dev/null
+++ b/hledger-web/test/browser/security.spec.js
@@ -0,0 +1,83 @@
+// Security regression tests for the hledger-web UI.
+//
+// hledger-web is meant to be run locally, but journal data is not always
+// trusted (imported CSV, shared files), and the browser is a hostile place to
+// interpolate text. Yesod escapes template output by default; these tests
+// exist so that a future refactor cannot quietly opt out of it.
+//
+// Run with: npx playwright test security (see README.md)
+const { test, expect } = require('@playwright/test');
+
+// A payload that executes if it is ever inserted as markup rather than text.
+// Tests assert window.__xss stays undefined and the text is shown literally.
+const PAYLOAD = '
';
+
+let pageErrors;
+test.beforeEach(({ page }) => {
+ pageErrors = [];
+ page.on('pageerror', err => pageErrors.push(String(err)));
+});
+
+async function xssFired(page) {
+ return page.evaluate(() => window.__xss !== undefined);
+}
+
+test.describe('journal data is rendered as text, not markup', () => {
+
+ // fixture.journal carries the payload in a description, an account name and
+ // a comment, so every view that renders them is covered.
+ test('the journal view escapes a payload in a description', async ({ page }) => {
+ await page.goto('/journal');
+ expect(await xssFired(page)).toBe(false);
+ await expect(page.locator('#main-content')).toContainText(PAYLOAD);
+ expect(await page.locator('#main-content img[src="x"]').count()).toBe(0);
+ });
+
+ test('the register view escapes a payload in a description', async ({ page }) => {
+ await page.goto('/register?q=inacct:expenses:food:dining');
+ expect(await xssFired(page)).toBe(false);
+ expect(await page.locator('#main-content img[src="x"]').count()).toBe(0);
+ });
+
+ test('the sidebar escapes a payload in an account name', async ({ page }) => {
+ await page.goto('/journal');
+ await expect(page.locator('#sidebar-menu')).toContainText('xss