9 Commits
17 changed files with 641 additions and 47 deletions
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## 1.1.0 — 2026-09-27
Hardening and operational improvements on top of the first release. The report
surface is unchanged; this release tightens how untrusted journals are executed
and gives administrators more control over resource use.
### Sandbox
hledger now also runs under a memory cap (`RLIMIT_DATA`) and a file-size limit,
and core dumps are disabled. The child's `PATH` is reduced to the directory of
the resolved executable. When hledger produces more output than the configured
cap, the plugin reports a distinct "report too large" error instead of failing
later while parsing truncated JSON.
### Rate limiting
A global rate limit (`hledger global rate limit per minute`, default 120) now
complements the existing per-IP limit, bounding report generation across all
users even when requests are spread over many IPs. Set it to 0 to disable it.
### Journal validation
Directive rejection now also covers the `! include` spelling, and journals with
invalid encoding are reported as such instead of reaching hledger. The line
limit is configurable (`hledger max journal lines`, default 5000).
### Caching and logging
Cached reports are keyed on the detected hledger version as well as the post
revision, so an in-place hledger upgrade no longer serves stale results. Failed
executions are logged server-side with the report type, topic and user plus a
truncated stderr excerpt, without logging the journal itself.
### Frontend
The dashboard no longer issues duplicate requests for the same report and
parameters, and discards responses from superseded requests when reports or
filters change quickly.
### Equity
The equity distribution is documented and tested as based on absolute equity
balances, and the in-app note states this explicitly.
### Documentation
The README now covers the recommended default Docker deployment (installing the
plugin and `hledger` via the `app.yml` `after_code` hook), the layered security
model, an OS/container sandbox recommendation for public instances, and the
relevant site settings.
## 1.0.0 — 2026-09-27
The first release of the hledger plugin for Discourse. Put an
+65 -10
View File
@@ -12,17 +12,50 @@ by the `hledger` command line tool:
- 📋 Accounts and balances (a full account tree, each name capitalized)
- ⚖️ Balance sheet (indented account tree under each section)
- 📈 Profit & loss / P&L (indented account tree under each section)
- 👥 Equity distribution (account tree with per-commodity shares; contributed
capital, not legal ownership)
- 👥 Equity distribution (account tree with per-commodity shares from absolute
balances; contributed capital, not legal ownership)
- 🧾 Journal (a detailed transaction log: date, description and postings)
## Installation
Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157).
The plugin requires the `hledger` executable on the server.
The plugin requires the `hledger` executable on the server. Install it with
your package manager (for example `apt-get install hledger`) and point the
`hledger path` site setting at it if it is not on the default `PATH`.
### Default Docker deployment
The recommended self-hosted setup runs Discourse in a container built from an
`app.yml` file ([install guide](https://github.com/discourse/discourse/blob/main/docs/INSTALL-cloud.md)).
Add the plugin and the `hledger` package to `/var/discourse/containers/app.yml`:
```yaml
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
- git clone https://gitea.kosmos.org/raucao/discourse-hledger.git
- exec:
cd: $home
cmd:
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get install -y hledger
```
Then rebuild the container to apply the changes:
```sh
cd /var/discourse
./launcher rebuild app
```
The `after_code` hook runs on every rebuild, so the plugin and `hledger` stay
installed across rebuilds.
### Other installs
Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157).
Install `hledger` with your package manager (for example
`apt-get install hledger`) and point the `hledger path` site setting at it if it
is not on the default `PATH`.
### Docker development environment
@@ -60,10 +93,32 @@ are linked; anything else stays plain text.
## Security
Journals are untrusted input. The plugin runs `hledger` with a scrubbed
environment, a private working directory, a hard timeout, resource limits and
an output cap, and it rejects `include` directives so a journal cannot read
arbitrary server files.
Journals are untrusted input. The plugin runs `hledger` as an unprivileged
subprocess with a scrubbed environment, a private working directory, a hard
timeout, CPU, file-size and memory limits and an output cap, and it rejects
`include` directives so a journal cannot read arbitrary server files.
Defenses are layered: Discourse authorization, a per-IP rate limit and the
global rate limit, the journal size and line limits, the execution timeout, the
resource limits and the output cap.
For installations where untrusted users can create public topics, additionally
sandbox `hledger` at the OS or container level — for example a systemd unit with
`ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`,
`NoNewPrivileges=true`, `RestrictAddressFamilies=AF_UNIX`, `MemoryMax=` and
`TasksMax=`, or a dedicated container runtime. Those controls are stronger than
in-process resource limits.
### Relevant settings
- `hledger max journal bytes` and `hledger max journal lines` limit the size of
an accepted journal.
- `hledger timeout seconds` limits the wall-clock time of each `hledger`
invocation.
- `hledger memory limit mb` caps the process memory (`0` disables it).
- `hledger max output bytes` caps the captured output.
- `hledger global rate limit per minute` throttles report generation across all
users (`0` disables it).
## Development
+28 -5
View File
@@ -11,6 +11,7 @@ module ::Hledger
before_action :ensure_enabled
before_action :find_topic
before_action :enforce_rate_limit
before_action :enforce_global_rate_limit
def show
report_type = params[:report_type].to_s
@@ -27,7 +28,8 @@ module ::Hledger
end
first_post = @topic.first_post
cache_key = cache_key_for(first_post, report_type, begin_date, end_date)
runner = Hledger::Runner.new
cache_key = cache_key_for(runner, first_post, report_type, begin_date, end_date)
if (cached = Discourse.cache.read(cache_key))
return render(json: cached)
@@ -40,7 +42,7 @@ module ::Hledger
begin_date: begin_date,
end_date: end_date,
},
runner: Hledger::Runner.new,
runner: runner,
) do
on_success do |report:|
Discourse.cache.write(cache_key, report, expires_in: CACHE_TTL)
@@ -50,10 +52,16 @@ module ::Hledger
on_failed_contract do |contract|
render_json_error(contract.errors.full_messages, status: 422)
end
on_exceptions(Hledger::UnavailableError) do
on_exceptions(Hledger::UnavailableError) do |error|
log_failure(error)
render_json_error(I18n.t("hledger.errors.unavailable"), status: 503)
end
on_exceptions(Hledger::Error) do
on_exceptions(Hledger::OutputLimitError) do |error|
log_failure(error)
render_json_error(I18n.t("hledger.errors.output_too_large"), status: 422)
end
on_exceptions(Hledger::Error) do |error|
log_failure(error)
render_json_error(I18n.t("hledger.errors.execution"), status: 422)
end
on_failure { render_json_error(I18n.t("hledger.errors.generic"), status: 422) }
@@ -81,6 +89,13 @@ module ::Hledger
).performed!
end
def enforce_global_rate_limit
max = SiteSetting.hledger_global_rate_limit_per_minute.to_i
return if max <= 0
RateLimiter.new(nil, "hledger-reports-global", max, 60, global: true).performed!
end
def parse_date(value)
return nil if value.blank?
@@ -89,7 +104,7 @@ module ::Hledger
:invalid
end
def cache_key_for(first_post, report_type, begin_date, end_date)
def cache_key_for(runner, first_post, report_type, begin_date, end_date)
[
"hledger",
@topic.id,
@@ -98,6 +113,7 @@ module ::Hledger
begin_date,
end_date,
SiteSetting.hledger_path,
runner.version,
].join(":")
end
@@ -107,5 +123,12 @@ module ::Hledger
render_json_error(I18n.t(key), status: error == :none ? 404 : 422)
end
def log_failure(error)
Rails.logger.warn(
"hledger report failed: report=#{params[:report_type]} topic=#{@topic&.id} " \
"user=#{current_user&.id} error=#{error.class} detail=#{error.message.to_s.truncate(500)}",
)
end
end
end
@@ -28,6 +28,10 @@ export default class HledgerDashboard extends Component {
@tracked beginDate = null;
@tracked endDate = null;
#inflight = null;
#lastKey = null;
#requestId = 0;
constructor() {
super(...arguments);
this.loadReport();
@@ -103,6 +107,16 @@ export default class HledgerDashboard extends Component {
return;
}
const key = `${this.selectedReport}|${this.beginDate}|${this.endDate}`;
if (key === this.#lastKey && !this.error) {
return;
}
this.#lastKey = key;
const requestId = ++this.#requestId;
this.#inflight?.abort();
this.loading = true;
this.error = null;
@@ -115,16 +129,27 @@ export default class HledgerDashboard extends Component {
}
try {
this.report = await ajax(
const promise = ajax(
`/hledger/topics/${topicId}/reports/${this.selectedReport}`,
{ data }
);
this.#inflight = promise;
const report = await promise;
if (requestId === this.#requestId) {
this.report = report;
}
} catch (e) {
this.report = null;
this.error =
e?.jqXHR?.responseJSON?.errors?.[0] || i18n("hledger.errors.generic");
if (requestId === this.#requestId) {
this.report = null;
this.error =
e?.jqXHR?.responseJSON?.errors?.[0] || i18n("hledger.errors.generic");
}
} finally {
this.loading = false;
if (requestId === this.#requestId) {
this.loading = false;
this.#inflight = null;
}
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ en:
holder: "Holder"
amount: "Amount"
share: "Share"
note: "Contributed-capital distribution, not legal ownership."
note: "Based on absolute equity balances: a contributed-capital distribution, not legal ownership."
errors:
generic: "Could not load the report."
+4
View File
@@ -3,14 +3,18 @@ en:
hledger_enabled: "Enable the hledger plugin"
hledger_path: "Path to the hledger executable"
hledger_max_journal_bytes: "Maximum size of an hledger journal in bytes"
hledger_max_journal_lines: "Maximum number of lines in an hledger journal"
hledger_timeout_seconds: "Maximum number of seconds an hledger command may run"
hledger_max_output_bytes: "Maximum number of bytes captured from hledger output"
hledger_memory_limit_mb: "Maximum memory in megabytes the hledger process may use; set to 0 to disable the limit"
hledger_global_rate_limit_per_minute: "Maximum number of hledger reports that may be generated per minute across all users; set to 0 to disable the limit"
hledger:
errors:
unsupported_report: "Unsupported report type."
invalid_date: "Invalid date; expected YYYY-MM-DD."
unavailable: "hledger is not available on this server."
execution: "hledger could not generate the report."
output_too_large: "The report is too large to generate."
generic: "The report could not be generated."
journal:
none: "No hledger journal was found in this topic."
+9
View File
@@ -6,7 +6,16 @@ hledger:
default: hledger
hledger_max_journal_bytes:
default: 262144
hledger_max_journal_lines:
default: 5000
min: 1
hledger_timeout_seconds:
default: 10
hledger_max_output_bytes:
default: 1048576
hledger_memory_limit_mb:
default: 256
min: 0
hledger_global_rate_limit_per_minute:
default: 120
min: 0
+4 -3
View File
@@ -6,8 +6,7 @@ module Hledger
# control.
class Journal
FENCE = /^[ \t]*`{3,}hledger[ \t]*\r?\n(?<source>.*?)^[ \t]*`{3,}[ \t]*$/m
INCLUDE = /^[ \t]*!?include\b/i
MAX_LINES = 5_000
INCLUDE = /^[ \t]*!?[ \t]*include\b/i
Result =
Struct.new(:source, :error, keyword_init: true) do
@@ -29,6 +28,8 @@ module Hledger
end
def extract
return Result.new(source: nil, error: :invalid_encoding) unless @raw.valid_encoding?
matches = @raw.scan(FENCE).flatten
return Result.new(source: nil, error: :none) if matches.empty?
return Result.new(source: nil, error: :ambiguous) if matches.length > 1
@@ -44,7 +45,7 @@ module Hledger
def validate(source)
return :too_large if source.bytesize > SiteSetting.hledger_max_journal_bytes
return :too_many_lines if source.lines.size > MAX_LINES
return :too_many_lines if source.lines.size > SiteSetting.hledger_max_journal_lines
return :invalid_encoding unless source.valid_encoding?
return :include_not_allowed if source.match?(INCLUDE)
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
module Hledger
# Raised when hledger produced more output than the configured cap, which
# means any captured JSON would be truncated.
class OutputLimitError < Error
end
end
+5 -2
View File
@@ -152,8 +152,11 @@ module Hledger
def run(argv, journal)
result = @runner.execute([*base_flags, *argv], stdin_data: journal)
raise ExecutionError, result.stderr if result.timed_out
raise ExecutionError, result.stderr unless result.success?
raise OutputLimitError if result.output_limit_exceeded
raise ExecutionError, "hledger timed out: #{result.stderr}" if result.timed_out
unless result.success?
raise ExecutionError, "hledger failed (exit #{result.exit_status}): #{result.stderr}"
end
result
end
+58 -10
View File
@@ -10,18 +10,28 @@ module Hledger
# a hard wall-clock timeout.
class Runner
Result =
Struct.new(:success, :stdout, :stderr, :exit_status, :timed_out, keyword_init: true) do
Struct.new(
:success,
:stdout,
:stderr,
:exit_status,
:timed_out,
:output_limit_exceeded,
keyword_init: true,
) do
def success?
success
end
end
READ_CHUNK = 16_384
MAX_FILE_SIZE = 8 * 1024 * 1024
def initialize(path: nil, timeout: nil, max_output: nil)
def initialize(path: nil, timeout: nil, max_output: nil, memory_limit: nil)
@path = (path || SiteSetting.hledger_path).to_s
@timeout = (timeout || SiteSetting.hledger_timeout_seconds).to_i
@max_output = (max_output || SiteSetting.hledger_max_output_bytes).to_i
@memory_limit_mb = (memory_limit || SiteSetting.hledger_memory_limit_mb).to_i
end
def available?
@@ -31,7 +41,7 @@ module Hledger
def version
return @version if defined?(@version)
@version = detect_version
@version = detect_version_cached
end
def execute(argv, stdin_data: nil)
@@ -46,7 +56,7 @@ module Hledger
def run(binary, argv, stdin_data)
tmpdir = Dir.mktmpdir("hledger-")
env = {
"PATH" => ENV["PATH"].to_s,
"PATH" => File.dirname(binary),
"HOME" => tmpdir,
"TMPDIR" => tmpdir,
"XDG_CACHE_HOME" => tmpdir,
@@ -61,12 +71,19 @@ module Hledger
chdir: tmpdir,
rlimit_cpu: @timeout + 5,
rlimit_nofile: 64,
rlimit_fsize: MAX_FILE_SIZE,
rlimit_core: 0,
}
# hledger is a GHC binary, so cap memory via RLIMIT_DATA (which since
# Linux 4.7 also covers mmap). The GHC runtime options distro packages
# ship with are disabled ("Most RTS options are disabled").
spawn_opts[:rlimit_data] = @memory_limit_mb * 1024 * 1024 if @memory_limit_mb.positive?
status = nil
timed_out = false
stdout = +""
stderr = +""
output_limit_exceeded = false
Open3.popen3(env, binary, *argv, **spawn_opts) do |stdin, out, err, wait_thr|
writer = Thread.new { write_stdin(stdin, stdin_data) }
@@ -82,8 +99,9 @@ module Hledger
end
writer.join(1)
stdout = out_reader.value
stderr = err_reader.value
stdout, stdout_truncated = out_reader.value
stderr, stderr_truncated = err_reader.value
output_limit_exceeded = stdout_truncated || stderr_truncated
end
Result.new(
@@ -92,6 +110,7 @@ module Hledger
stderr: stderr,
exit_status: status&.exitstatus,
timed_out: timed_out,
output_limit_exceeded: output_limit_exceeded,
)
rescue SystemCallError
failure_result
@@ -107,15 +126,23 @@ module Hledger
def read_capped(io)
buffer = +""
truncated = false
while (chunk = io.read(READ_CHUNK))
break if chunk.empty?
remaining = @max_output - buffer.bytesize
buffer << chunk.byteslice(0, remaining) if remaining.positive?
if remaining <= 0
truncated = true
elsif chunk.bytesize <= remaining
buffer << chunk
else
buffer << chunk.byteslice(0, remaining)
truncated = true
end
end
buffer
[buffer, truncated]
rescue IOError, Errno::EPIPE
buffer
[buffer, truncated]
ensure
io.close
end
@@ -156,8 +183,29 @@ module Hledger
result.stdout[/\d+\.\d+(?:\.\d+)?/]
end
# The version is part of the report cache key, so remember it to avoid a
# `--version` subprocess on every request.
def detect_version_cached
return detect_version if @path.blank?
key = "hledger:version:#{@path}"
cached = Discourse.cache.read(key)
return cached if cached
detected = detect_version
Discourse.cache.write(key, detected, expires_in: 10.minutes) if detected
detected
end
def failure_result
Result.new(success: false, stdout: "", stderr: "", exit_status: nil, timed_out: false)
Result.new(
success: false,
stdout: "",
stderr: "",
exit_status: nil,
timed_out: false,
output_limit_exceeded: false,
)
end
end
end
+1 -1
View File
@@ -3,7 +3,7 @@
# name: hledger
# about: Render hledger journals in topics
# meta_topic_id: TODO
# version: 1.0.0
# version: 1.1.0
# authors: Râu Cao
# url: https://gitea.kosmos.org/raucao/discourse-hledger
# required_version: 2.7.0
+53
View File
@@ -39,6 +39,32 @@ RSpec.describe Hledger::Journal do
expect(result.error).to eq(:include_not_allowed)
end
[
"include /etc/passwd",
"include ../foo",
"include ~/foo",
"include *.journal",
"include **/*",
" include foo",
"\tinclude foo",
"INCLUDE foo",
"!include foo",
"! include foo",
"! include foo",
].each do |directive|
it "rejects the include directive #{directive.inspect}" do
result = described_class.extract(raw_with(directive))
expect(result.error).to eq(:include_not_allowed)
end
end
it "allows the word include inside a transaction" do
result = described_class.extract(raw_with("2024-01-01 include the foo\n a 1\n b -1"))
expect(result).to be_ok
end
it "rejects oversized journals" do
SiteSetting.hledger_max_journal_bytes = 10
@@ -48,4 +74,31 @@ RSpec.describe Hledger::Journal do
ensure
SiteSetting.hledger_max_journal_bytes = 262_144
end
it "rejects journals with too many lines" do
SiteSetting.hledger_max_journal_lines = 2
result = described_class.extract(raw_with("a\nb\nc"))
expect(result.error).to eq(:too_many_lines)
ensure
SiteSetting.hledger_max_journal_lines = 5000
end
it "extracts a fence with trailing whitespace and a longer fence" do
raw = "Intro\n\n````hledger \n2024-01-01 x\n a 1\n b -1\n````\n\nOutro\n"
result = described_class.extract(raw)
expect(result).to be_ok
expect(result.source).to include("2024-01-01 x")
end
it "rejects invalid UTF-8" do
raw = "```hledger\n2024-01-01 \xFF\n```\n"
result = described_class.extract(raw)
expect(result.error).to eq(:invalid_encoding)
end
end
+81
View File
@@ -43,6 +43,25 @@ end
RSpec.describe Hledger::ReportBuilder do
subject(:builder) { described_class.new(runner: FakeHledgerRunner.new) }
def equity_amount(mantissa, places: 2)
{
"acommodity" => "EUR",
"acost" => nil,
"acostbasis" => nil,
"aquantity" => {
"decimalMantissa" => mantissa,
"decimalPlaces" => places,
"floatingPoint" => mantissa / 100.0,
},
"astyle" => {
"ascommodityside" => "R",
"ascommodityspaced" => true,
"asdecimalmark" => ".",
"asprecision" => 2,
},
}
end
describe "accounts" do
it "returns the full account tree with aggregated totals" do
report = builder.build("accounts", journal: "journal")
@@ -113,6 +132,50 @@ RSpec.describe Hledger::ReportBuilder do
expect(group["rows"].map { |row| row["percentage"] }).to eq(%w[60.00 40.00])
expect(group["rows"].first["amounts"].first["display"]).to eq("600.00 EUR")
end
it "uses absolute balances for the distribution" do
runner = FakeHledgerRunner.new
rows = [
["equity:alice", "equity:alice", 0, [equity_amount(-10_000)]],
["equity:bob", "equity:bob", 0, [equity_amount(2_000)]],
]
allow(runner).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: true,
stdout: [rows].to_json,
stderr: "",
exit_status: 0,
timed_out: false,
output_limit_exceeded: false,
),
)
report = described_class.new(runner: runner).build("equity", journal: "journal")
group = report["groups"].first
expect(group["total"]["quantity"]).to eq("120.0")
expect(group["rows"].map { |row| row["percentage"] }).to eq(%w[83.33 16.67])
expect(group["rows"].first["amounts"].first["quantity"]).to eq("100.0")
end
it "does not divide by zero when the total is zero" do
runner = FakeHledgerRunner.new
rows = [["equity:alice", "equity:alice", 0, [equity_amount(0)]]]
allow(runner).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: true,
stdout: [rows].to_json,
stderr: "",
exit_status: 0,
timed_out: false,
output_limit_exceeded: false,
),
)
report = described_class.new(runner: runner).build("equity", journal: "journal")
expect(report["groups"].first["rows"].first["percentage"]).to eq("0.00")
end
end
describe "transactions" do
@@ -159,4 +222,22 @@ RSpec.describe Hledger::ReportBuilder do
described_class.new(runner: unavailable).build("accounts", journal: "x")
}.to raise_error(Hledger::UnavailableError)
end
it "raises when the captured output was truncated" do
runner = FakeHledgerRunner.new
allow(runner).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: true,
stdout: "",
stderr: "",
exit_status: 0,
timed_out: false,
output_limit_exceeded: true,
),
)
expect { described_class.new(runner: runner).build("accounts", journal: "x") }.to raise_error(
Hledger::OutputLimitError,
)
end
end
+41 -4
View File
@@ -1,8 +1,13 @@
# frozen_string_literal: true
RSpec.describe Hledger::Runner do
def runner(path:, timeout: 5, max_output: 1_000)
described_class.new(path: path, timeout: timeout, max_output: max_output)
def runner(path:, timeout: 5, max_output: 1_000, memory_limit: 0)
described_class.new(
path: path,
timeout: timeout,
max_output: max_output,
memory_limit: memory_limit,
)
end
it "captures stdout and exit status" do
@@ -25,9 +30,41 @@ RSpec.describe Hledger::Runner do
end
it "caps captured output" do
result = runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf '%0.sa' {1..100}"])
result =
runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf 'aaaaaaaaaaaaaaaaaaaa'"])
expect(result.stdout.bytesize).to be <= 10
expect(result.stdout.bytesize).to eq(10)
end
it "flags truncated output" do
result =
runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf 'aaaaaaaaaaaaaaaaaaaa'"])
expect(result.output_limit_exceeded).to be(true)
end
it "does not flag output within the cap" do
result = runner(path: "/bin/echo").execute(["hello"])
expect(result.output_limit_exceeded).to be(false)
end
it "enforces the memory limit" do
result =
runner(path: RbConfig.ruby, memory_limit: 64).execute(
["-e", "x = 'a' * (200 * 1024 * 1024); puts x.bytesize"],
)
expect(result).not_to be_success
end
it "does not limit memory when set to zero" do
result =
runner(path: RbConfig.ruby, memory_limit: 0).execute(
["-e", "x = 'a' * (200 * 1024 * 1024); puts x.bytesize"],
)
expect(result).to be_success
end
it "returns a failure when the binary is missing" do
+174
View File
@@ -129,6 +129,25 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(422)
end
it "includes the hledger version in the cache key" do
get_report("accounts")
expect(response.parsed_body.dig("meta", "hledger_version")).to eq("1.52.1")
allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new(version: "1.53.0"))
get_report("accounts")
expect(response.parsed_body.dig("meta", "hledger_version")).to eq("1.53.0")
end
it "rejects journals with include directives" do
post.update!(raw: "```hledger\ninclude /etc/passwd\n```")
get_report("accounts")
expect(response.status).to eq(422)
expect(response.body).not_to include("/etc/passwd")
end
it "returns service unavailable when hledger is missing" do
allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new(version: nil))
@@ -137,6 +156,28 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(503)
end
it "logs hledger failures without the journal" do
failing = StubHledgerRunner.new
allow(failing).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: false,
stdout: "",
stderr: "boom",
exit_status: 1,
timed_out: false,
output_limit_exceeded: false,
),
)
allow(Hledger::Runner).to receive(:new).and_return(failing)
allow(Rails.logger).to receive(:warn).and_call_original
get_report("accounts")
expect(response.status).to eq(422)
expect(Rails.logger).to have_received(:warn).with(/hledger report failed.*boom/m)
expect(Rails.logger).not_to have_received(:warn).with(/equity:alice/m)
end
it "hides topics the user cannot see" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
hidden_post = Fabricate(:post, topic: Fabricate(:topic, category: private_category))
@@ -146,6 +187,123 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(404)
end
describe "authorization" do
def journal_raw
<<~RAW
```hledger
2024-01-01 Opening balances
assets:bank:checking 1000.00 EUR
equity:alice -600.00 EUR
equity:bob -400.00 EUR
```
RAW
end
def report_for(topic)
get "/hledger/topics/#{topic.id}/reports/accounts.json"
end
it "allows anonymous access to a public topic" do
report_for(post.topic)
expect(response.status).to eq(200)
end
it "allows access to a locked and archived topic the user can see" do
post.topic.update!(closed: true, archived: true)
report_for(post.topic)
expect(response.status).to eq(200)
end
it "returns not found for a deleted topic" do
topic = Fabricate(:post, raw: journal_raw).topic
topic.trash!
report_for(topic)
expect(response.status).to eq(404)
expect(response.body).not_to include("assets:bank:checking")
end
it "returns not found when the first post is deleted" do
topic = Fabricate(:post, raw: journal_raw).topic
topic.first_post.trash!
report_for(topic)
expect(response.status).to eq(404)
end
context "with a group-restricted category" do
fab!(:group)
fab!(:restricted_category) { Fabricate(:private_category, group: group) }
fab!(:restricted_topic) do
Fabricate(
:post,
raw: journal_raw,
topic: Fabricate(:topic, category: restricted_category),
).topic
end
it "hides it from anonymous users" do
report_for(restricted_topic)
expect(response.status).to eq(404)
expect(response.body).not_to include("assets:bank:checking")
end
it "hides it from logged in non-members" do
sign_in(Fabricate(:user))
report_for(restricted_topic)
expect(response.status).to eq(404)
end
it "allows members" do
user = Fabricate(:user)
group.add(user)
sign_in(user)
report_for(restricted_topic)
expect(response.status).to eq(200)
end
end
context "with a private message" do
fab!(:member, :user)
fab!(:private_message_post) do
Fabricate(:private_message_post, recipient: member, raw: journal_raw)
end
it "hides it from anonymous users" do
report_for(private_message_post.topic)
expect(response.status).to eq(404)
end
it "hides it from non-members" do
sign_in(Fabricate(:user))
report_for(private_message_post.topic)
expect(response.status).to eq(404)
expect(response.body).not_to include("assets:bank:checking")
end
it "allows members" do
sign_in(member)
report_for(private_message_post.topic)
expect(response.status).to eq(200)
end
end
end
it "is not found when the plugin is disabled" do
SiteSetting.hledger_enabled = false
@@ -153,4 +311,20 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(404)
end
it "enforces the global rate limit" do
RateLimiter.enable
RateLimiter.clear_all_global!
SiteSetting.hledger_global_rate_limit_per_minute = 1
get_report("accounts")
expect(response.status).to eq(200)
get_report("accounts")
expect(response.status).to eq(429)
ensure
RateLimiter.disable
RateLimiter.clear_all_global!
SiteSetting.hledger_global_rate_limit_per_minute = 120
end
end
+28 -6
View File
@@ -260,6 +260,8 @@ const transactionsReport = {
acceptance("Hledger plugin", function (needs) {
needs.settings({ hledger_enabled: true });
const requestCounts = { accounts: 0, equity: 0 };
needs.pretender((server, helper) => {
server.get("/t/45.json", () => {
const topic = cloneJSON(topicFixtures["/t/28830/1.json"]);
@@ -267,12 +269,14 @@ acceptance("Hledger plugin", function (needs) {
return helper.response(topic);
});
server.get(`/hledger/topics/${TOPIC_ID}/reports/accounts`, () =>
helper.response(accountsReport)
);
server.get(`/hledger/topics/${TOPIC_ID}/reports/equity`, () =>
helper.response(equityReport)
);
server.get(`/hledger/topics/${TOPIC_ID}/reports/accounts`, () => {
requestCounts.accounts++;
return helper.response(accountsReport);
});
server.get(`/hledger/topics/${TOPIC_ID}/reports/equity`, () => {
requestCounts.equity++;
return helper.response(equityReport);
});
server.get(`/hledger/topics/${TOPIC_ID}/reports/balance_sheet`, () =>
helper.response(balanceSheetReport)
);
@@ -452,6 +456,24 @@ acceptance("Hledger plugin", function (needs) {
.hasText("Server");
});
test("does not issue duplicate requests for the same report", async function (assert) {
requestCounts.accounts = 0;
requestCounts.equity = 0;
await visit("/t/-/45");
assert.strictEqual(requestCounts.accounts, 1, "loads accounts once");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)");
assert.strictEqual(requestCounts.equity, 1, "loads equity once");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)");
assert.strictEqual(requestCounts.equity, 1, "ignores the current report");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(1)");
assert.strictEqual(requestCounts.accounts, 2, "reloads on report change");
});
test("shows the transaction log", async function (assert) {
await visit("/t/-/45");