Commit 5441d8b8bf

5441d8b8bf33975b95b76721b48fb13d914126dc

parent: b0fba1c090

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 23:15 UTC

web: the topics-remove field gets a label, and a test that would have caught it

A placeholder is not an accessible name: it disappears on focus, and a
screen reader is not required to announce it. The topics-remove input had
only a placeholder while the add field beside it had both an id and a
label — #133 fixed this class across the templates and missed this one.

TestEveryInputHasAnAccessibleName is the guard that was absent. It reads
the template source rather than rendered output, since the rule is a
property of the markup and holds whatever data a page is given.

It knows both associations. Six inputs use the implicit form,
`<label>Name <input></label>`, which is as good as for/id; a check that
knew only the explicit form reported all six, which is what the first
draft did before I looked at the markup rather than believing the count.

Migrations are excluded from analysis rather than dismissed again each
scan. They are SQLite and the analyser reads .sql as PL/SQL, where '' is
NULL, so `WHERE col = ''` on a NOT NULL DEFAULT '' column — correct
SQLite, and the shape used throughout — reads as a null-comparison bug.

Ref #153

Layout: unified · split

internal/httpd/inputlabels_test.go added +70
@@ -0,0 +1,70 @@
1package httpd
2
3import (
4 "regexp"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/web"
9)
10
11var (
12 inputTag = regexp.MustCompile(`<input\b[^>]*>`)
13 labelFor = regexp.MustCompile(`<label[^>]*\bfor="([^"]+)"`)
14 labelWraps = regexp.MustCompile(`(?s)<label\b[^>]*>.*?</label>`)
15 attrID = regexp.MustCompile(`\bid="([^"]+)"`)
16 attrType = regexp.MustCompile(`\btype="([^"]+)"`)
17 ariaLabel = regexp.MustCompile(`\baria-label(?:ledby)?="`)
18)
19
20// Every input a person types into needs an accessible name: a <label for>
21// pointing at its id, or an aria-label. A placeholder is not one — it
22// disappears on focus and screen readers are not required to announce it.
23//
24// #133 fixed this class across the templates and missed the topics-remove
25// field, which the first SonarCloud scan then found
26// (Web:InputWithoutLabelCheck, #153). This is the guard that was absent.
27func TestEveryInputHasAnAccessibleName(t *testing.T) {
28 // Types that carry their own name or take no input.
29 exempt := map[string]bool{"hidden": true, "submit": true, "button": true, "reset": true, "image": true}
30 for _, name := range web.Pages() {
31 src, err := web.TemplateSource(name)
32 if err != nil {
33 t.Fatalf("%s: %v", name, err)
34 }
35 labelled := map[string]bool{}
36 for _, m := range labelFor.FindAllStringSubmatch(src, -1) {
37 labelled[m[1]] = true
38 }
39 // `<label>Name <input></label>` associates implicitly and is as
40 // good as `for`/`id`. Six inputs use it, and a check that knew
41 // only the explicit form would report every one of them.
42 wrapped := labelWraps.FindAllStringIndex(src, -1)
43 inWrappedLabel := func(at int) bool {
44 for _, span := range wrapped {
45 if at >= span[0] && at < span[1] {
46 return true
47 }
48 }
49 return false
50 }
51 for _, at := range inputTag.FindAllStringIndex(src, -1) {
52 tag := src[at[0]:at[1]]
53 typ := "text"
54 if m := attrType.FindStringSubmatch(tag); m != nil {
55 typ = m[1]
56 }
57 if exempt[typ] || ariaLabel.MatchString(tag) || inWrappedLabel(at[0]) {
58 continue
59 }
60 m := attrID.FindStringSubmatch(tag)
61 if m == nil {
62 t.Errorf("%s: input has no id and is not inside a <label>: %s", name, strings.TrimSpace(tag))
63 continue
64 }
65 if !labelled[m[1]] {
66 t.Errorf("%s: input id=%q has no <label for>: %s", name, m[1], strings.TrimSpace(tag))
67 }
68 }
69 }
70}
internal/web/templates/settings.html +3 −2
@@ -19,9 +19,10 @@
19</form> 19</form>
20<form method="post" action="{{$base}}" class="setform"> 20<form method="post" action="{{$base}}" class="setform">
21 <input type="hidden" name="field" value="topics"> 21 <input type="hidden" name="field" value="topics">
22 <label for="topics-add">Topics</label> 22 <label for="topics-add">Topics to add</label>
23 <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> 23 <input type="text" id="topics-add" name="add" placeholder="add, space-separated">
24 <input type="text" name="remove" placeholder="remove"> 24 <label for="topics-remove">Topics to remove</label>
25 <input type="text" id="topics-remove" name="remove" placeholder="remove">
25 <button type="submit">Apply</button> 26 <button type="submit">Apply</button>
26</form> 27</form>
27{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} 28{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
internal/web/web.go +9
@@ -249,6 +249,15 @@ var pages = func() map[string]*template.Template {
249 return m 249 return m
250}() 250}()
251 251
252// TemplateSource returns a template's raw text, for tests that inspect the
253// markup rather than the rendered output — an accessibility rule about
254// labels and ids is a property of the source, and holds whatever data a
255// page is given.
256func TemplateSource(name string) (string, error) {
257 b, err := templateFS.ReadFile("templates/" + name)
258 return string(b), err
259}
260
252// Pages lists the page template names, for tests that render each one. 261// Pages lists the page template names, for tests that render each one.
253func Pages() []string { 262func Pages() []string {
254 names := make([]string, 0, len(pages)) 263 names := make([]string, 0, len(pages))
sonar-project.properties +7 −1
@@ -12,7 +12,13 @@ sonar.test.inclusions=**/*_test.go
12 12
13# dist/ is release output, testdata is fixtures meant to be malformed, and 13# dist/ is release output, testdata is fixtures meant to be malformed, and
14# the fonts are third-party binaries. 14# the fonts are third-party binaries.
15sonar.exclusions=dist/**,**/testdata/**,internal/web/static/fonts/** 15#
16# The migrations are excluded because they are SQLite and the analyser
17# reads .sql as PL/SQL, where '' is NULL. That turns `WHERE col = ''` on a
18# NOT NULL DEFAULT '' column — correct SQLite, and the shape used
19# throughout — into a NullComparison finding. Excluding them is the fix;
20# dismissing the same false positive after every migration is not.
21sonar.exclusions=dist/**,**/testdata/**,internal/web/static/fonts/**,internal/store/migrations/**
16 22
17# No sonar.go.coverage.reportPaths yet. Most of this repository's coverage 23# No sonar.go.coverage.reportPaths yet. Most of this repository's coverage
18# comes from the e2e suite, and re-running that under -coverprofile would 24# comes from the e2e suite, and re-running that under -coverprofile would