audit 4: log paging at its edges, NaN refused, a red rate limit, recalculation asks, reload writes all
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):
- The Outbound log counts first and clamps the page to the last; a number
too large for an int (strconv's ErrRange with the largest int) is the
last page on both logs. Tested at the edges: first, last, past the last
however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first, 643892a).
- data-unsaved on both recalculate buttons: panel.js asks on submit
(ev.submitter) when a field of the page differs from what it was drawn
with; testdata/unsaved_test.js under TestPanelJS.
- HandleReload writes what a restore's first start writes - the filter
lists and the DMARC maps too - and a failed step is an error flash on
Health that names it, not plain text; the success flash says what was
written.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 695 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,9 @@ var statusClass = map[string]string{
|
||||
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
||||
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
||||
"reject": "tag is-danger is-light",
|
||||
// A message refused under a level-2 limit, beside its two siblings that
|
||||
// are red (audit 2026-10-12, L18).
|
||||
"rate-limited": "tag is-danger is-light",
|
||||
|
||||
"quarantine": "tag is-primary is-light", "held": "tag is-primary is-light",
|
||||
"global": "tag is-primary is-light",
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestStatusTagMapsEveryStatusOfTheKitTable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatusTagTreatsAnUnknownStatusAsNeutral(t *testing.T) {
|
||||
for _, status := range []string{"", "rate-limited", "3", "DKIM", "something new"} {
|
||||
for _, status := range []string{"", "limited", "3", "DKIM", "something new"} {
|
||||
if got := StatusTag(status); got != "tag is-light" {
|
||||
t.Errorf("StatusTag(%q) = %q, want the neutral tag", status, got)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func KitPage() *Kit {
|
||||
StatusRows: []KitStatusRow{
|
||||
{"ok, published, running, sent, accept", Tag{Status: "ok"}},
|
||||
{"warn, deferred, quiet, tempfail", Tag{Status: "warn"}},
|
||||
{"error, fail, mismatch, bounced, reject", Tag{Status: "fail"}},
|
||||
{"error, fail, mismatch, bounced, reject, rate-limited", Tag{Status: "fail"}},
|
||||
{"unknown, off, neutral counts", Tag{Status: "off"}},
|
||||
{"held: quarantine · role: global", Tag{Status: "quarantine"}},
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
`<th>Time, UTC</th>`, `09-21 14:01:52`,
|
||||
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
|
||||
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
|
||||
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
|
||||
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-danger is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
|
||||
`href="/outbound/log/184220">Details</a>`,
|
||||
`Page 1 of 412`, `<a href="/outbound/log?p=2">Older →</a>`, `New rows appear on their own`,
|
||||
)
|
||||
|
||||
@@ -6,17 +6,23 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The refreshes of the monitoring boxes survive a failure and back off,
|
||||
// checked by running panel.js in node against a stand-in for the DOM and
|
||||
// htmx (testdata/poll_test.js; audit 2026-10-12, M11). Skipped where node is
|
||||
// not installed - the test server's Go has none; the workstation runs it.
|
||||
func TestPanelJSPolling(t *testing.T) {
|
||||
// panel.js run in node against a stand-in for the parts of the DOM and htmx
|
||||
// it uses (testdata/*_test.js): the refreshes of the monitoring boxes
|
||||
// survive a failure and back off (audit 2026-10-12, M11), and a button that
|
||||
// leaves the page asks first when a field holds an unsaved edit (L19).
|
||||
// Skipped where node is not installed - the test server's Go has none; the
|
||||
// workstation runs it.
|
||||
func TestPanelJS(t *testing.T) {
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is not installed")
|
||||
}
|
||||
out, err := exec.Command(node, filepath.Join("testdata", "poll_test.js"), filepath.Join("static", "panel.js")).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("panel.js polling: %v\n%s", err, out)
|
||||
for _, script := range []string{"poll_test.js", "unsaved_test.js"} {
|
||||
t.Run(script, func(t *testing.T) {
|
||||
out, err := exec.Command(node, filepath.Join("testdata", script), filepath.Join("static", "panel.js")).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,58 @@
|
||||
var form = ev.target.closest("form[data-confirm]");
|
||||
if (form && !window.confirm(form.dataset.confirm)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
// A button that does something else than save - recalculate a rate
|
||||
// limit - leaves the page, and what was typed on it and not saved goes
|
||||
// with it. Such a button carries data-unsaved, the question to ask when
|
||||
// a field of the page differs from what the page was drawn with (audit
|
||||
// 2026-10-12, L19). HTML Living Standard, SubmitEvent: submitter is the
|
||||
// button that submitted the form.
|
||||
var button = ev.submitter;
|
||||
if (button && button.dataset && button.dataset.unsaved && hasUnsavedEdit() &&
|
||||
!window.confirm(button.dataset.unsaved)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// hasUnsavedEdit tells whether a field of the page holds a value the page
|
||||
// was not drawn with: defaultValue, defaultChecked and defaultSelected are
|
||||
// what the markup said (HTML Living Standard, the input, select and
|
||||
// textarea elements). A select none of whose options says selected was
|
||||
// drawn with its first option, or none when it is a list (size or multiple).
|
||||
function hasUnsavedEdit() {
|
||||
var fields = document.querySelectorAll("input, select, textarea");
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var f = fields[i];
|
||||
if (f.disabled || f.type === "hidden" || f.type === "submit" || f.type === "button" || f.type === "reset") {
|
||||
continue;
|
||||
}
|
||||
if (f.type === "checkbox" || f.type === "radio") {
|
||||
if (f.checked !== f.defaultChecked) {
|
||||
return true;
|
||||
}
|
||||
} else if (f.tagName === "SELECT") {
|
||||
var drawn = -1;
|
||||
for (var j = 0; j < f.options.length; j++) {
|
||||
if (f.options[j].defaultSelected) {
|
||||
drawn = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (drawn < 0 && !f.multiple && f.size <= 1 && f.options.length > 0) {
|
||||
drawn = 0;
|
||||
}
|
||||
if (f.selectedIndex !== drawn) {
|
||||
return true;
|
||||
}
|
||||
} else if (f.value !== f.defaultValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Conditional field visibility ------------------------------------
|
||||
// Several forms hide a block until a select, checkbox or file input says it
|
||||
// applies. One rule table drives them all so the five near-identical helpers
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-primary">{{.Submit}}</button>
|
||||
{{if .Recalc}}<button type="submit" class="button" formaction="{{.Recalc}}">Recalculate rate limit</button>{{end}}
|
||||
{{if .Recalc}}<button type="submit" class="button" formaction="{{.Recalc}}" data-unsaved="Recalculating leaves this page, and what you changed on it and did not save is lost. Recalculate anyway?">Recalculate rate limit</button>{{end}}
|
||||
<a class="button is-text" href="{{.Cancel}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</form>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-primary" form="rate-limit">Save limit</button>
|
||||
{{if .Rate.Auto}}<form method="post" action="{{.RecalcAction}}"><button type="submit" class="button">Recalculate now</button></form>{{end}}
|
||||
{{if .Rate.Auto}}<form method="post" action="{{.RecalcAction}}"><button type="submit" class="button" data-unsaved="Recalculating leaves this page, and what you changed on it and did not save is lost. Recalculate anyway?">Recalculate now</button></form>{{end}}
|
||||
{{if .Rate.Active}}<form method="post" action="{{.LimitAction}}" data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply."><input type="hidden" name="clear" value="1"><button type="submit" class="button is-danger is-light">Remove limit</button></form>{{end}}
|
||||
</div>
|
||||
<p class="sp-muted mt-4">{{template "inline" .LimitNote}}</p>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// The polling of panel.js, run in node against a stand-in for the few parts
|
||||
// of the DOM and htmx it uses, with a clock the test moves. Run by
|
||||
// TestPanelJSPolling (paneljs_test.go): node poll_test.js <panel.js>.
|
||||
// TestPanelJS (paneljs_test.go): node poll_test.js <panel.js>.
|
||||
// Exits non-zero with what went wrong.
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// The question panel.js asks before a button that leaves the page - a rate
|
||||
// limit's recalculation - when a field holds an edit not saved yet (audit
|
||||
// 2026-10-12, L19). Run by TestPanelJS (paneljs_test.go): node
|
||||
// unsaved_test.js <panel.js>. Exits non-zero with what went wrong.
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
const failures = [];
|
||||
function check(cond, what) {
|
||||
if (!cond) failures.push(what);
|
||||
}
|
||||
|
||||
const listeners = {};
|
||||
let fields = [];
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
|
||||
body: { addEventListener() {} },
|
||||
getElementById: () => null,
|
||||
querySelectorAll: (sel) => (sel === "input, select, textarea" ? fields : []),
|
||||
};
|
||||
let asked = [];
|
||||
let answer = false;
|
||||
const window = {
|
||||
addEventListener() {},
|
||||
confirm(q) {
|
||||
asked.push(q);
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
||||
document, window, htmx: { ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
|
||||
});
|
||||
|
||||
function input(type, value, defaultValue) {
|
||||
return { tagName: "INPUT", type, value, defaultValue, disabled: false };
|
||||
}
|
||||
function box(checked, defaultChecked) {
|
||||
return { tagName: "INPUT", type: "checkbox", checked, defaultChecked, disabled: false };
|
||||
}
|
||||
function select(selectedIndex, defaults, opts) {
|
||||
return Object.assign({
|
||||
tagName: "SELECT", type: "select-one", selectedIndex, disabled: false, multiple: false, size: 0,
|
||||
options: defaults.map((d) => ({ defaultSelected: d })),
|
||||
}, opts || {});
|
||||
}
|
||||
|
||||
// submit fires the document's submit listeners for a form with no
|
||||
// data-confirm, sent by a button with the given data-unsaved, and says
|
||||
// whether the submission went on.
|
||||
function submit(unsaved) {
|
||||
let prevented = false;
|
||||
const ev = {
|
||||
target: { closest: () => null },
|
||||
submitter: { dataset: unsaved === undefined ? {} : { unsaved } },
|
||||
preventDefault() { prevented = true; },
|
||||
};
|
||||
(listeners.submit || []).forEach((fn) => fn(ev));
|
||||
return !prevented;
|
||||
}
|
||||
|
||||
const Q = "Recalculating leaves this page. Recalculate anyway?";
|
||||
const drawn = () => [
|
||||
input("text", "100", "100"),
|
||||
input("hidden", "x", "y"), // never an edit
|
||||
box(true, true),
|
||||
select(0, [false, false, false]), // no option says selected: the first
|
||||
select(2, [false, false, true]),
|
||||
];
|
||||
|
||||
function scenario(name, edit, wantAsked) {
|
||||
fields = drawn();
|
||||
edit(fields);
|
||||
asked = [];
|
||||
answer = false;
|
||||
const went = submit(Q);
|
||||
check(asked.length === (wantAsked ? 1 : 0), name + ": asked " + asked.length + " times, want " + (wantAsked ? 1 : 0));
|
||||
check(went === !wantAsked, name + ": the submission " + (went ? "went on" : "was stopped"));
|
||||
}
|
||||
|
||||
scenario("nothing changed", () => {}, false);
|
||||
scenario("a number typed", (f) => (f[0].value = "150"), true);
|
||||
scenario("a box unticked", (f) => (f[2].checked = false), true);
|
||||
scenario("a select moved off its first option", (f) => (f[3].selectedIndex = 1), true);
|
||||
scenario("a select moved off its selected option", (f) => (f[4].selectedIndex = 0), true);
|
||||
scenario("a disabled field differs", (f) => { f[0].value = "150"; f[0].disabled = true; }, false);
|
||||
|
||||
// Answered yes: the submission goes on.
|
||||
fields = drawn();
|
||||
fields[0].value = "150";
|
||||
asked = [];
|
||||
answer = true;
|
||||
check(submit(Q) === true && asked.length === 1, "answered yes, the recalculation did not go on");
|
||||
|
||||
// A button without the question - Save - never asks.
|
||||
asked = [];
|
||||
answer = false;
|
||||
check(submit(undefined) === true && asked.length === 0, "a plain submit asked or was stopped");
|
||||
|
||||
if (failures.length) {
|
||||
console.log(failures.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("ok");
|
||||
Reference in New Issue
Block a user