| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/xml" |
| 6 | "io" |
| 7 | "io/fs" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | // The bundle is committed, not built at boot. This asserts exactly one |
| 15 | // is present and that the path the template emits resolves to it — a |
| 16 | // mismatch would serve a 404 and leave the editor silently absent, |
| 17 | // which looks identical to "editing is not implemented yet". |
| 18 | func TestExactlyOneBundlePerKindIsEmbedded(t *testing.T) { |
| 19 | entries, err := fs.ReadDir(staticFS, "static") |
| 20 | if err != nil { |
| 21 | t.Fatalf("read static: %v", err) |
| 22 | } |
| 23 | |
| 24 | // One committed file per bundle kind. This was "exactly one editor |
| 25 | // bundle" until the jump-to palette became a second one: it ships |
| 26 | // separately so the document list does not download 300KB of |
| 27 | // ProseMirror in order to offer a keyboard shortcut (assets.go's |
| 28 | // bundleNames). The invariant has not weakened — it is still |
| 29 | // exactly one file per name, and bundleKinds is a written list |
| 30 | // rather than whatever happens to be on disk, so a build that |
| 31 | // silently stopped emitting one panics at init instead of starting |
| 32 | // without it. |
| 33 | paths := map[string]string{ |
| 34 | "editor": editorScriptPath(), |
| 35 | "jumpto": jumpToScriptPath(), |
| 36 | "share": shareScriptPath(), |
| 37 | } |
| 38 | accounted := map[string]bool{} |
| 39 | for _, kind := range bundleKinds { |
| 40 | var found []string |
| 41 | for _, e := range entries { |
| 42 | if strings.HasPrefix(e.Name(), kind+".") && strings.HasSuffix(e.Name(), ".js") { |
| 43 | found = append(found, e.Name()) |
| 44 | } |
| 45 | } |
| 46 | if len(found) != 1 { |
| 47 | t.Fatalf("want exactly one %q bundle, got %v", kind, found) |
| 48 | } |
| 49 | accounted[found[0]] = true |
| 50 | want, ok := paths[kind] |
| 51 | if !ok { |
| 52 | t.Fatalf("bundleKinds names %q but this test knows no path accessor for it", kind) |
| 53 | } |
| 54 | if want != "/static/"+found[0] { |
| 55 | t.Errorf("%s path = %q, want %q", kind, want, "/static/"+found[0]) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // And nothing else in static/ at all. That directory is the bundle |
| 60 | // build's output: .amadan/ci.d/10-gate rebuilds into it and fails |
| 61 | // on any dirt whatsoever there, then restores it with git checkout |
| 62 | // and git clean — so an extra file both reddens the gate and is |
| 63 | // deleted by it. The tab icon was written here first and lost |
| 64 | // exactly that way, which is why this is a test and not a note. |
| 65 | // Hand-authored assets go in brand/ (brandFS). |
| 66 | // |
| 67 | // Kept as "everything present is accounted for" rather than as a |
| 68 | // count, so it still catches a stray file now that the expected |
| 69 | // number is no longer one. assets.go's own registry cannot do this |
| 70 | // job: it looks up the names it knows and would never notice a |
| 71 | // third file nobody named. |
| 72 | for _, e := range entries { |
| 73 | if !accounted[e.Name()] { |
| 74 | t.Errorf("static/ holds %q, which no bundle kind accounts for — it must hold only the bundle build's output; put hand-authored assets in brand/", e.Name()) |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // The bundle must be served without a session, or a signed-out reader |
| 80 | // gets a broken page: a script tag pointing at a 302-to-signin looks |
| 81 | // exactly like an unimplemented editor. Built directly with |
| 82 | // httptest.NewRequest/app.Handler().ServeHTTP rather than |
| 83 | // app.TestRequest, which always signs someone in — there is |
| 84 | // deliberately no signed-in identity anywhere in this request. |
| 85 | func TestEditorBundleIsServed(t *testing.T) { |
| 86 | app, err := NewForTest(t) |
| 87 | if err != nil { |
| 88 | t.Fatalf("NewForTest: %v", err) |
| 89 | } |
| 90 | req := httptest.NewRequest(http.MethodGet, editorScriptPath(), nil) |
| 91 | rec := httptest.NewRecorder() |
| 92 | app.Handler().ServeHTTP(rec, req) |
| 93 | |
| 94 | if rec.Code != http.StatusOK { |
| 95 | t.Fatalf("GET %s = %d, want 200", editorScriptPath(), rec.Code) |
| 96 | } |
| 97 | if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { |
| 98 | t.Errorf("Content-Type = %q, want javascript", ct) |
| 99 | } |
| 100 | if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { |
| 101 | t.Errorf("Cache-Control = %q, want immutable (the filename carries the content hash)", cc) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // The tab icon must be served, unauthenticated, with an image content |
| 106 | // type — and the markup that links it must point at the constant this |
| 107 | // package actually routes on. Those two drifting apart is the exact |
| 108 | // failure this pins: a wrong href does not break a page, it quietly |
| 109 | // restores the blank-page tab mark the icon exists to replace, which |
| 110 | // nobody files a bug about and no other test here would notice. |
| 111 | func TestIconIsServedAndLinkedConsistently(t *testing.T) { |
| 112 | app, err := NewForTest(t) |
| 113 | if err != nil { |
| 114 | t.Fatalf("NewForTest: %v", err) |
| 115 | } |
| 116 | // No session on this request, on purpose: the sign-in page needs |
| 117 | // the icon as much as any signed-in screen does, and it is |
| 118 | // reached without one. |
| 119 | req := httptest.NewRequest(http.MethodGet, iconPath, nil) |
| 120 | rec := httptest.NewRecorder() |
| 121 | app.Handler().ServeHTTP(rec, req) |
| 122 | |
| 123 | if rec.Code != http.StatusOK { |
| 124 | t.Fatalf("GET %s = %d, want 200", iconPath, rec.Code) |
| 125 | } |
| 126 | if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") { |
| 127 | t.Errorf("Content-Type = %q, want image/svg+xml", ct) |
| 128 | } |
| 129 | if body := rec.Body.String(); !strings.Contains(body, "<svg") { |
| 130 | t.Errorf("body does not look like an SVG: %.80q", body) |
| 131 | } |
| 132 | |
| 133 | // And the link must reach a real page. Asserted on rendered output |
| 134 | // rather than on views/head.html's bytes: the file containing the |
| 135 | // href proves nothing on its own, because "head" only reaches a |
| 136 | // page if newRenderer parses it into the base template ahead of the |
| 137 | // per-page clones (render.go). Were that wiring dropped, a |
| 138 | // bytes-of-the-file check would stay green while every page lost |
| 139 | // its icon — the icon would still serve, the template would still |
| 140 | // contain the href, and nothing would be able to tell. Rendering |
| 141 | // the page subsumes the file check and closes that gap. |
| 142 | // |
| 143 | // The sign-in page, because it needs no session and so exercises |
| 144 | // the same unauthenticated path the icon itself is served on. |
| 145 | pageReq := httptest.NewRequest(http.MethodGet, "/signin", nil) |
| 146 | pageRec := httptest.NewRecorder() |
| 147 | app.Handler().ServeHTTP(pageRec, pageReq) |
| 148 | if pageRec.Code != http.StatusOK { |
| 149 | t.Fatalf("GET /signin = %d, want 200", pageRec.Code) |
| 150 | } |
| 151 | if wantHref := `href="` + iconPath + `"`; !strings.Contains(pageRec.Body.String(), wantHref) { |
| 152 | t.Errorf("the rendered sign-in page does not contain %s — the shell's head block is not carrying the icon link", wantHref) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // The icon must be well-formed XML, because SVG is XML and a browser |
| 157 | // will not render one that is not — it fails closed, showing the same |
| 158 | // blank tab mark as no icon at all. |
| 159 | // |
| 160 | // This exists because the first version of icon.svg was not: its |
| 161 | // comment named a CSS custom property with the two leading hyphens a |
| 162 | // custom property has, and two consecutive hyphens are illegal inside |
| 163 | // an XML comment. Everything a request-level check can see was |
| 164 | // correct — 200, image/svg+xml, a plausible Content-Length, the |
| 165 | // expected bytes — and the file was still unparseable. Only rendering |
| 166 | // it showed anything wrong, which is exactly the failure shape |
| 167 | // AGENTS.md warns about: a verification that cannot tell "no" from |
| 168 | // "I can't see it". |
| 169 | // |
| 170 | // Decoding every token, rather than checking the root element, is the |
| 171 | // point: a malformed comment or attribute halfway down is precisely |
| 172 | // what a shallower check misses. |
| 173 | func TestIconIsWellFormedXML(t *testing.T) { |
| 174 | b, err := fs.ReadFile(brandFS, "brand/icon.svg") |
| 175 | if err != nil { |
| 176 | t.Fatalf("read icon: %v", err) |
| 177 | } |
| 178 | dec := xml.NewDecoder(bytes.NewReader(b)) |
| 179 | var root string |
| 180 | for { |
| 181 | tok, err := dec.Token() |
| 182 | if err == io.EOF { |
| 183 | break |
| 184 | } |
| 185 | if err != nil { |
| 186 | t.Fatalf("icon.svg is not well-formed XML, so no browser will render it: %v", err) |
| 187 | } |
| 188 | if se, ok := tok.(xml.StartElement); ok && root == "" { |
| 189 | root = se.Name.Local |
| 190 | } |
| 191 | } |
| 192 | if root != "svg" { |
| 193 | t.Errorf("icon.svg root element = %q, want svg", root) |
| 194 | } |
| 195 | } |
| 196 | |