Bring fix/1531 up to main, so its check is judged against main again (issue 888)
mesh/merge-gate head 66616ed3538f merged into trunk a28398c16be6 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 66616ed3538f merged into trunk a28398c16be6 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/merge-gate head 66616ed3538f merged into trunk a28398c16be6 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 66616ed3538f merged into trunk a28398c16be6 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
This commit is contained in:
@@ -62,7 +62,7 @@ makes it (mode 0755) when it files the first scan.
|
||||
| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` |
|
||||
| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/<upload>.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing |
|
||||
| `printer.print` | prints a kept scan (`object`) or a completed upload (`upload`) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state |
|
||||
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
|
||||
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title and forms with their fields, each with its current value (a radio group as one field with the checked button's value, a checkbox with `checked`, a select with the selected option) and `options`; the page's links only with `links: true`, hidden fields only with `hidden: true`; a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
|
||||
|
||||
### Trust and the password
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ func (w *deviceWorker) Handle(body []byte, early func()) (action, time.Duration)
|
||||
func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) {
|
||||
switch ask.Op {
|
||||
case OpSettings:
|
||||
return w.read(ctx, ask.Page)
|
||||
return w.read(ctx, ask.Page, ask.Links, ask.Hidden)
|
||||
case OpScan:
|
||||
return w.scan(ctx)
|
||||
case OpDocument:
|
||||
@@ -301,7 +301,7 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
|
||||
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
|
||||
// the only things it sends, and a password field's value is never in the answer. The printer is not contacted
|
||||
// before the settings and the password are known to be given (1453/AC4).
|
||||
func (w *deviceWorker) read(ctx context.Context, page string) (map[string]any, error) {
|
||||
func (w *deviceWorker) read(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
|
||||
if page == "" {
|
||||
page = DefaultPage
|
||||
}
|
||||
@@ -342,13 +342,39 @@ func (w *deviceWorker) read(ctx context.Context, page string) (map[string]any, e
|
||||
}
|
||||
logout := a.Logout(lctx, body, at)
|
||||
p := ParsePage(body)
|
||||
return map[string]any{
|
||||
out := map[string]any{
|
||||
"page": page,
|
||||
"title": p.Title,
|
||||
"links": nonNil(p.Links),
|
||||
"forms": nonNilForms(p.Forms),
|
||||
"forms": nonNilForms(withoutHidden(p.Forms, hidden)),
|
||||
"logout": logout,
|
||||
}, nil
|
||||
}
|
||||
if links {
|
||||
out["links"] = nonNil(p.Links)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withoutHidden leaves the hidden fields (the CSRF token and the like, noise to a reader) out of the forms
|
||||
// unless they are asked for.
|
||||
func withoutHidden(forms []Form, keep bool) []Form {
|
||||
if keep {
|
||||
return forms
|
||||
}
|
||||
out := make([]Form, 0, len(forms))
|
||||
for _, f := range forms {
|
||||
var fields []Field
|
||||
for _, fl := range f.Fields {
|
||||
if fl.Type != "hidden" {
|
||||
fields = append(fields, fl)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 && len(f.Fields) > 0 {
|
||||
continue // a form of nothing but hidden fields (the logout form) has nothing to read
|
||||
}
|
||||
f.Fields = fields
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nonNil(l []Link) []Link {
|
||||
|
||||
@@ -21,9 +21,19 @@ type Field struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Label string `json:"label"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Options []Option `json:"options,omitempty"`
|
||||
id string
|
||||
own string // the label of this control alone: <label for>, a wrapping <label>, or a radio button's text after it
|
||||
row string // what the control's table row (or <dt>) calls it
|
||||
}
|
||||
|
||||
// Option is a choice of a select (Value and Text) or a button of a radio group (Value and Label).
|
||||
type Option struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Form is a form of a page with its named fields.
|
||||
@@ -110,6 +120,14 @@ func ParsePage(body []byte) Page {
|
||||
inTextA bool
|
||||
textA strings.Builder
|
||||
textAIdx = -1
|
||||
// Where a control stands in its row: Brother pages lay a setting out as <tr><th>name</th><td>control</td></tr>
|
||||
// or <dt>name</dt><dd>control</dd>; the first cell names the row.
|
||||
cellN int
|
||||
cellOpen bool
|
||||
cellText strings.Builder
|
||||
rowFirst string
|
||||
lastText string
|
||||
tailIdx = -1
|
||||
)
|
||||
finishOption := func() {
|
||||
if !inOption || selIdx < 0 {
|
||||
@@ -121,7 +139,10 @@ func ParsePage(body []byte) Page {
|
||||
v = squash(optText.String())
|
||||
}
|
||||
f := &form.Fields[selIdx]
|
||||
f.Options = append(f.Options, v)
|
||||
if len(f.Options) == 0 {
|
||||
f.Value = v // a browser shows the first option when none is selected
|
||||
}
|
||||
f.Options = append(f.Options, Option{Value: v, Text: squash(optText.String())})
|
||||
if optSel {
|
||||
f.Value = v
|
||||
}
|
||||
@@ -137,6 +158,11 @@ func ParsePage(body []byte) Page {
|
||||
form = Form{}
|
||||
}
|
||||
addField := func(f Field) {
|
||||
f.row = rowFirst
|
||||
if (cellOpen && cellN == 1) || f.row == "" {
|
||||
f.row = lastText
|
||||
}
|
||||
lastText = "" // text before a control names that control alone
|
||||
form.Fields = append(form.Fields, f)
|
||||
if inLabel && labelFor == "" {
|
||||
wrapped = append(wrapped, len(form.Fields)-1)
|
||||
@@ -151,6 +177,22 @@ func ParsePage(body []byte) Page {
|
||||
case inTextA:
|
||||
textA.WriteString(t)
|
||||
}
|
||||
if !inTitle && !inOption && !inTextA && link == nil {
|
||||
if q := squash(t); q != "" {
|
||||
switch {
|
||||
case tailIdx >= 0:
|
||||
// the text right after a radio button is that button's, not the next field's
|
||||
if form.Fields[tailIdx].own == "" {
|
||||
form.Fields[tailIdx].own = q
|
||||
}
|
||||
case !inLabel:
|
||||
lastText = q
|
||||
}
|
||||
}
|
||||
if cellOpen && cellN == 1 {
|
||||
cellText.WriteString(t)
|
||||
}
|
||||
}
|
||||
if link != nil {
|
||||
link.Text += t
|
||||
}
|
||||
@@ -189,6 +231,7 @@ func ParsePage(body []byte) Page {
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
tailIdx = -1
|
||||
raw := s[i+1 : i+end]
|
||||
i += end + 1
|
||||
closing := strings.HasPrefix(raw, "/")
|
||||
@@ -214,6 +257,25 @@ func ParsePage(body []byte) Page {
|
||||
switch name {
|
||||
case "title":
|
||||
inTitle = !closing
|
||||
case "tr":
|
||||
cellN, cellOpen, rowFirst, lastText = 0, false, "", ""
|
||||
cellText.Reset()
|
||||
case "td", "th", "dt", "dd":
|
||||
if cellOpen && cellN == 1 {
|
||||
rowFirst = squash(cellText.String())
|
||||
}
|
||||
cellOpen = false
|
||||
if name == "dt" && !closing {
|
||||
cellN, rowFirst, lastText = 0, "", ""
|
||||
}
|
||||
if !closing {
|
||||
cellN++
|
||||
if name == "dd" && cellN < 2 {
|
||||
cellN = 2
|
||||
}
|
||||
cellOpen = true
|
||||
cellText.Reset()
|
||||
}
|
||||
case "a":
|
||||
if closing {
|
||||
if link != nil {
|
||||
@@ -226,6 +288,7 @@ func ParsePage(body []byte) Page {
|
||||
}
|
||||
case "form":
|
||||
closeForm()
|
||||
lastText = ""
|
||||
if !closing {
|
||||
formOpen = true
|
||||
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
|
||||
@@ -240,7 +303,7 @@ func ParsePage(body []byte) Page {
|
||||
labelIDs[labelFor] = t
|
||||
}
|
||||
for _, k := range wrapped {
|
||||
form.Fields[k].Label = t
|
||||
form.Fields[k].own = t
|
||||
}
|
||||
inLabel, labelFor, wrapped = false, "", nil
|
||||
label.Reset()
|
||||
@@ -263,7 +326,14 @@ func ParsePage(body []byte) Page {
|
||||
if typ == "password" {
|
||||
f.Value = ""
|
||||
}
|
||||
if typ == "checkbox" || typ == "radio" {
|
||||
_, on := a["checked"]
|
||||
f.Checked = &on
|
||||
}
|
||||
addField(f)
|
||||
if typ == "radio" {
|
||||
tailIdx = len(form.Fields) - 1
|
||||
}
|
||||
case "select":
|
||||
finishOption()
|
||||
selIdx = -1
|
||||
@@ -298,12 +368,33 @@ func ParsePage(body []byte) Page {
|
||||
closeForm()
|
||||
p.Title = squash(title.String())
|
||||
for fi := range p.Forms {
|
||||
var fields []Field
|
||||
groups := map[string]int{}
|
||||
for k := range p.Forms[fi].Fields {
|
||||
f := &p.Forms[fi].Fields[k]
|
||||
if f.Label == "" && f.id != "" {
|
||||
f.Label = labelIDs[f.id]
|
||||
f := p.Forms[fi].Fields[k]
|
||||
if f.own == "" && f.id != "" {
|
||||
f.own = labelIDs[f.id]
|
||||
}
|
||||
if f.Type != "radio" {
|
||||
f.Label = f.own
|
||||
if f.Label == "" && f.Type != "hidden" {
|
||||
f.Label = f.row
|
||||
}
|
||||
fields = append(fields, f)
|
||||
continue
|
||||
}
|
||||
g, ok := groups[f.Name]
|
||||
if !ok {
|
||||
g = len(fields)
|
||||
groups[f.Name] = g
|
||||
fields = append(fields, Field{Name: f.Name, Type: "radio", Label: f.row})
|
||||
}
|
||||
fields[g].Options = append(fields[g].Options, Option{Value: f.Value, Label: f.own})
|
||||
if f.Checked != nil && *f.Checked {
|
||||
fields[g].Value = f.Value
|
||||
}
|
||||
}
|
||||
p.Forms[fi].Fields = fields
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fieldNamed(t *testing.T, p Page, name string) Field {
|
||||
t.Helper()
|
||||
for _, f := range p.Forms {
|
||||
for _, fl := range f.Fields {
|
||||
if fl.Name == name {
|
||||
return fl
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no field %s in %+v", name, p.Forms)
|
||||
return Field{}
|
||||
}
|
||||
|
||||
// 1530: a radio group is ONE field that answers the checked button's value, and every button is an option.
|
||||
func TestARadioGroupIsOneFieldAnsweringTheCheckedButton(t *testing.T) {
|
||||
p := ParsePage([]byte(`<form><table><tr><th>Mode</th><td>
|
||||
<input type="radio" id="a" name="R" value="1"/><label for="a">Enabled</label>
|
||||
<input type="radio" id="b" name="R" value="0" checked="checked"/><label for="b">Disabled</label></td></tr></table></form>`))
|
||||
if n := len(p.Forms[0].Fields); n != 1 {
|
||||
t.Fatalf("%d fields, want the radio group as one: %+v", n, p.Forms[0].Fields)
|
||||
}
|
||||
f := fieldNamed(t, p, "R")
|
||||
if f.Type != "radio" || f.Value != "0" || f.Label != "Mode" {
|
||||
t.Errorf("group = %+v", f)
|
||||
}
|
||||
if len(f.Options) != 2 || f.Options[0] != (Option{Value: "1", Label: "Enabled"}) || f.Options[1] != (Option{Value: "0", Label: "Disabled"}) {
|
||||
t.Errorf("options = %+v", f.Options)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a checkbox answers whether it is checked, both ways.
|
||||
func TestACheckboxAnswersCheckedOrNot(t *testing.T) {
|
||||
p := ParsePage([]byte(`<form><input type="checkbox" name="on" value="1" checked/><input type="checkbox" name="off" value="1"/></form>`))
|
||||
on, off := fieldNamed(t, p, "on"), fieldNamed(t, p, "off")
|
||||
if on.Checked == nil || !*on.Checked || off.Checked == nil || *off.Checked {
|
||||
t.Errorf("on = %+v, off = %+v", on, off)
|
||||
}
|
||||
raw, _ := json.Marshal(off)
|
||||
if !strings.Contains(string(raw), `"checked":false`) {
|
||||
t.Errorf("an unchecked box says so in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a select answers the selected option's value, else the first, with its options as value and text.
|
||||
func TestASelectAnswersTheSelectedOptionElseTheFirst(t *testing.T) {
|
||||
p := ParsePage([]byte(`<form><select name="s"><option value="0">English</option><option value="3" selected>Deutsch</option></select>
|
||||
<select name="d"><option value="0">English</option><option value="3">Deutsch</option></select></form>`))
|
||||
s, d := fieldNamed(t, p, "s"), fieldNamed(t, p, "d")
|
||||
if s.Value != "3" || d.Value != "0" {
|
||||
t.Errorf("selected %q, default %q", s.Value, d.Value)
|
||||
}
|
||||
if len(s.Options) != 2 || s.Options[1] != (Option{Value: "3", Text: "Deutsch"}) {
|
||||
t.Errorf("options = %+v", s.Options)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a field with no <label for> is labelled by its table row's first cell, or its <dt>.
|
||||
func TestAFieldIsLabelledByItsTableRow(t *testing.T) {
|
||||
p := ParsePage([]byte(`<form><table><tr><td>Sleep time</td><td><input type="text" name="B1"/></td></tr></table>
|
||||
<dl><dt>Volume</dt><dd><input type="text" name="B2"/></dd></dl>
|
||||
<table><tr><td><label for="c">Own</label></td><td><input id="c" type="text" name="B3"/></td></tr></table></form>`))
|
||||
for name, want := range map[string]string{"B1": "Sleep time", "B2": "Volume", "B3": "Own"} {
|
||||
if got := fieldNamed(t, p, name).Label; got != want {
|
||||
t.Errorf("%s label = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: the scan-from-PC page, RECONSTRUCTED (its checked button and row heading are assumptions, only the form
|
||||
// actions, field names, option labels and values come from the live answer): only its structure is asserted.
|
||||
func TestTheReconstructedScanFromPCPageHasItsRadioGroupAsOneField(t *testing.T) {
|
||||
p := ParsePage([]byte(adminFixture(t, "reconstructed-scanfrompc.html")))
|
||||
if len(p.Forms) != 2 {
|
||||
t.Fatalf("forms = %+v", p.Forms)
|
||||
}
|
||||
var radios []Field
|
||||
for _, f := range p.Forms[1].Fields {
|
||||
if f.Type == "radio" {
|
||||
radios = append(radios, f)
|
||||
}
|
||||
}
|
||||
if len(radios) != 1 || radios[0].Name != "B117d" || len(radios[0].Options) != 2 ||
|
||||
radios[0].Options[0] != (Option{Value: "1", Label: "Enabled"}) || radios[0].Options[1] != (Option{Value: "0", Label: "Disabled"}) {
|
||||
t.Errorf("radios = %+v", radios)
|
||||
}
|
||||
first := p.Forms[0]
|
||||
if first.Action != "/home/status.html" || len(first.Fields) != 2 || first.Fields[0].Name != "CSRFToken" || first.Fields[1].Name != "B1892" {
|
||||
t.Errorf("first form = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a radio group with no checked button answers value "" and both options.
|
||||
func TestARadioGroupWithNothingCheckedAnswersNoValue(t *testing.T) {
|
||||
p := ParsePage([]byte(`<form><input type="radio" id="a" name="R" value="1"/><label for="a">On</label><input type="radio" id="b" name="R" value="0"/><label for="b">Off</label></form>`))
|
||||
f := fieldNamed(t, p, "R")
|
||||
if f.Value != "" || len(f.Options) != 2 {
|
||||
t.Errorf("group = %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a label is never guessed from text that is not the field's: the text after a radio button, the text of
|
||||
// another form, a menu link, a layout table's menu. The label is empty instead.
|
||||
func TestAFieldIsNotLabelledByOtherTexts(t *testing.T) {
|
||||
for name, c := range map[string]struct{ html, field string }{
|
||||
"after a radio group": {`<form><input type="radio" name="R" value="1"/>Off<input type="text" name="X"/></form>`, "X"},
|
||||
"after a label": {`<form><input type="radio" id="a" name="R" value="1"/><label for="a">Disabled</label><input type="text" name="X"/></form>`, "X"},
|
||||
"in a second form": {`<form><input type="radio" name="R" value="1"/>Disabled</form><form><input type="text" name="X"/></form>`, "X"},
|
||||
"after a menu link": {`<a href="/scan/scanfrompc.html">Scan from PC</a><form><input type="text" name="X"/></form>`, "X"},
|
||||
"layout table menu": {`<table><tr><td><a href="/a">Menu</a><a href="/b">Status</a></td><td><form><input type="text" name="X"/></form></td></tr></table>`, "X"},
|
||||
} {
|
||||
if got := fieldNamed(t, ParsePage([]byte(c.html)), c.field).Label; got != "" {
|
||||
t.Errorf("%s: label = %q, want none", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,20 @@ func strArg(args map[string]any, name string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// boolArgs reads the two flags printer.settings takes: links and hidden.
|
||||
func boolArgs(args map[string]any) (links, hidden bool, err error) {
|
||||
for name, dst := range map[string]*bool{"links": &links, "hidden": &hidden} {
|
||||
if v, ok := args[name]; ok {
|
||||
b, isBool := v.(bool)
|
||||
if !isBool {
|
||||
return false, false, fmt.Errorf("%s is true or false", name)
|
||||
}
|
||||
*dst = b
|
||||
}
|
||||
}
|
||||
return links, hidden, nil
|
||||
}
|
||||
|
||||
// askFrom reads the arguments an ask takes out of a tool's.
|
||||
func askFrom(args map[string]any) (Ask, error) {
|
||||
var a Ask
|
||||
@@ -159,13 +173,20 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
Description: "Read one page of the printer's admin site, read-only: the module's device process logs in with the " +
|
||||
"secret admin-password (the operator's; it trusts the printer's pinned certificate alone), gets the page and logs " +
|
||||
"out; this tool asks it and waits up to a minute for its answer. Answers the " +
|
||||
"page's title, its links (href and text) and its forms with their fields (name, type, value, label); a " +
|
||||
"page's title and its forms with their fields, each answering its current value: a text, textarea or hidden " +
|
||||
"field its value; a checkbox its value and checked (true or false); a select its selected option's value (the " +
|
||||
"first when none is marked) and options (value, text); a radio group ONE field whose value is the checked button's " +
|
||||
"and whose options are the buttons (value, label). A field's label is its <label for>, else the first cell of its " +
|
||||
"table row or its <dt>. The page's links (href and text) are answered only with links: true (they repeat the " +
|
||||
"printer's whole menu), hidden fields (CSRF token, page id) only with hidden: true. A " +
|
||||
"password field's value is never given. It sends nothing but the login and the logout, and writes no " +
|
||||
"setting of the printer.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"page": map[string]any{"type": "string", "description": "a page of the admin site: a path starting with / and ending in .html (default /home/status.html)"},
|
||||
"page": map[string]any{"type": "string", "description": "a page of the admin site: a path starting with / and ending in .html (default /home/status.html)"},
|
||||
"links": map[string]any{"type": "boolean", "description": "also answer the page's links (default false)"},
|
||||
"hidden": map[string]any{"type": "boolean", "description": "also answer the hidden fields (default false)"},
|
||||
},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
@@ -183,7 +204,11 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
return s.PrinterSettings(ctx, page)
|
||||
links, hidden, err := boolArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.PrinterSettings(ctx, page, links, hidden)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,6 +60,8 @@ type Ask struct {
|
||||
At time.Time `json:"asked-at"`
|
||||
|
||||
Page string `json:"page,omitempty"` // settings
|
||||
Links bool `json:"links,omitempty"` // settings: the page's links are answered too
|
||||
Hidden bool `json:"hidden,omitempty"` // settings: the hidden fields are answered too
|
||||
Object string `json:"object,omitempty"` // document, print: a scan's key
|
||||
Upload string `json:"upload,omitempty"` // upload, print: an upload's id
|
||||
Part int `json:"part,omitempty"` // document, upload: counted from 1
|
||||
@@ -149,7 +151,7 @@ func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[st
|
||||
|
||||
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
|
||||
// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set.
|
||||
func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) {
|
||||
func (s *Service) PrinterSettings(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
|
||||
if page == "" {
|
||||
page = DefaultPage
|
||||
}
|
||||
@@ -159,7 +161,7 @@ func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]
|
||||
if err := s.Settings.MissingPrinter(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.call(ctx, Ask{Op: OpSettings, Page: page}, s.AnswerWait)
|
||||
return s.call(ctx, Ask{Op: OpSettings, Page: page, Links: links, Hidden: hidden}, s.AnswerWait)
|
||||
}
|
||||
|
||||
func validPage(page string) bool {
|
||||
|
||||
@@ -38,22 +38,21 @@ func TestPrinterSettingsReadsAPageBehindTheLogin(t *testing.T) {
|
||||
if a.Title != "Synthetic settings" {
|
||||
t.Errorf("title = %q", a.Title)
|
||||
}
|
||||
if len(a.Links) != 2 || a.Links[1] != (Link{Href: "/general/status.html", Text: "Status"}) {
|
||||
t.Errorf("links = %v", a.Links)
|
||||
if len(a.Links) != 0 {
|
||||
t.Errorf("links in the default answer: %v", a.Links)
|
||||
}
|
||||
if len(a.Forms) != 1 || a.Forms[0].Action != "/general/other.html" || a.Forms[0].Method != "post" {
|
||||
t.Fatalf("forms = %+v", a.Forms)
|
||||
}
|
||||
want := map[string]Field{
|
||||
"lock": {Name: "lock", Type: "checkbox", Value: "on", Label: "Scan lock"},
|
||||
"node": {Name: "node", Type: "text", Value: "BRN-SYNTH", Label: "Name"},
|
||||
"pw": {Name: "pw", Type: "password", Value: "", Label: "Secret"},
|
||||
"note": {Name: "note", Type: "textarea", Value: "hello & bye"},
|
||||
"token": {Name: "token", Type: "hidden", Value: "t1"},
|
||||
"lock": {Name: "lock", Type: "checkbox", Value: "on", Label: "Scan lock"},
|
||||
"node": {Name: "node", Type: "text", Value: "BRN-SYNTH", Label: "Name"},
|
||||
"pw": {Name: "pw", Type: "password", Value: "", Label: "Secret"},
|
||||
"note": {Name: "note", Type: "textarea", Value: "hello & bye"},
|
||||
}
|
||||
for _, fl := range a.Forms[0].Fields {
|
||||
if fl.Name == "mode" {
|
||||
if fl.Type != "select" || fl.Value != "m" || fl.Label != "Mode" || strings.Join(fl.Options, ",") != "a,m" {
|
||||
if fl.Type != "select" || fl.Value != "m" || fl.Label != "Mode" || len(fl.Options) != 2 || fl.Options[0] != (Option{Value: "a", Text: "Auto"}) || fl.Options[1] != (Option{Value: "m", Text: "Manual"}) {
|
||||
t.Errorf("mode = %+v", fl)
|
||||
}
|
||||
continue
|
||||
@@ -155,7 +154,7 @@ func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) {
|
||||
if _, err := r.run(map[string]any{"page": p}); err == nil || !strings.Contains(err.Error(), "not a page") {
|
||||
t.Errorf("page %q: error = %v", p, err)
|
||||
}
|
||||
if _, err := r.worker.read(t.Context(), p); err == nil || !strings.Contains(err.Error(), "not a page") {
|
||||
if _, err := r.worker.read(t.Context(), p, false, false); err == nil || !strings.Contains(err.Error(), "not a page") {
|
||||
t.Errorf("the device process, page %q: error = %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -239,3 +238,45 @@ func TestPrinterSettingsDefaultPageAndHomeStatusAreReadAfterTheLogin(t *testing.
|
||||
t.Errorf("DefaultPage = %s", DefaultPage)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: the printer's menu links are left out of the answer unless links:true is asked, and hidden fields
|
||||
// (the CSRF token) unless hidden:true is asked; a password field's value is never given.
|
||||
func TestPrinterSettingsLeavesOutLinksAndHiddenFieldsUnlessAsked(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
f.pages["/scan/scanfrompc.html"] = adminFixture(t, "reconstructed-scanfrompc.html")
|
||||
_, run := printerTools(t, f, "pw")
|
||||
read := func(args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
got, err := run(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
var m map[string]any
|
||||
json.Unmarshal(raw, &m)
|
||||
return m
|
||||
}
|
||||
names := func(m map[string]any) string {
|
||||
var n []string
|
||||
for _, fm := range m["forms"].([]any) {
|
||||
for _, fl := range fm.(map[string]any)["fields"].([]any) {
|
||||
n = append(n, fl.(map[string]any)["name"].(string))
|
||||
}
|
||||
}
|
||||
return strings.Join(n, ",")
|
||||
}
|
||||
plain := read(map[string]any{"page": "/scan/scanfrompc.html"})
|
||||
if _, has := plain["links"]; has {
|
||||
t.Errorf("links in the default answer: %v", plain["links"])
|
||||
}
|
||||
if names(plain) != "B117d" {
|
||||
t.Errorf("default fields = %s, want B117d alone", names(plain))
|
||||
}
|
||||
more := read(map[string]any{"page": "/scan/scanfrompc.html", "links": true, "hidden": true})
|
||||
if l, _ := more["links"].([]any); len(l) != 3 {
|
||||
t.Errorf("links:true gave %v", more["links"])
|
||||
}
|
||||
if names(more) != "CSRFToken,B1892,pageid,CSRFToken,postif_registration_reject,B117d" {
|
||||
t.Errorf("hidden:true fields = %s", names(more))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Brother MFC-L8390CDW series</title></head><body>
|
||||
<!-- RECONSTRUCTED, not captured: built from what printer.settings answered for /scan/scanfrompc.html on 2026-10-21
|
||||
(the two form actions, the first form's hidden fields CSRFToken and B1892, the second form's hidden fields without their token values, the radio group B117d with the labels Enabled and Disabled
|
||||
and the values 1 and 0). Which button is checked, the table layout and the row heading are assumptions. -->
|
||||
<a href="/home/status.html">Status</a><a href="/scan/scanfrompc.html">Scan from PC</a><a href="/home/logout.html">Logout</a>
|
||||
<form method="post" action="/home/status.html"><input type="hidden" name="CSRFToken" value="TOKEN"/><input type="hidden" name="B1892" value=""/></form>
|
||||
<form method="post" action="/scan/scanfrompc.html"><input type="hidden" name="pageid" value="247"/><input type="hidden" name="CSRFToken" value="TOKEN"/><input type="hidden" name="postif_registration_reject" value="1"/>
|
||||
<table><tr><th>Scan from PC</th><td><input type="radio" id="B117d_1" name="B117d" value="1" checked="checked"/><label for="B117d_1">Enabled</label>
|
||||
<input type="radio" id="B117d_0" name="B117d" value="0"/><label for="B117d_0">Disabled</label></td></tr></table>
|
||||
<input type="submit" value="Submit"/></form></body></html>
|
||||
Reference in New Issue
Block a user