Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
143 changes: 143 additions & 0 deletions hledger-web/Hledger/Web/Test.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


3 changes: 3 additions & 0 deletions hledger-web/test/browser/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
58 changes: 58 additions & 0 deletions hledger-web/test/browser/README.md
Original file line number Diff line number Diff line change
@@ -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"
34 changes: 34 additions & 0 deletions hledger-web/test/browser/fixture.journal
Original file line number Diff line number Diff line change
@@ -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 <img src=x onerror="window.__xss=1"> | desc <img src=x onerror="window.__xss=1"> ; comment <img src=x onerror="window.__xss=1">
expenses:food:dining:xss<script>alert(1)</script> 1.00
assets:bank:checking
54 changes: 54 additions & 0 deletions hledger-web/test/browser/global-setup.js
Original file line number Diff line number Diff line change
@@ -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);
};
15 changes: 15 additions & 0 deletions hledger-web/test/browser/global-teardown.js
Original file line number Diff line number Diff line change
@@ -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 */ }
};
13 changes: 13 additions & 0 deletions hledger-web/test/browser/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions hledger-web/test/browser/playwright.config.js
Original file line number Diff line number Diff line change
@@ -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']],
});
Loading