package memoriatest
// Gates for the shared chrome (estilo spec/shared-chrome, round 2; Memoria
// is its §12.1 row 6). Each was run against origin/main 97b8a23 before the
// change and failed there; the discussion quotes both runs.
import (
"encoding/base64"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"golang.org/x/net/html"
)
var titleRe = regexp.MustCompile(`(?s)
(.*?)`)
func pageTitle(body string) string {
m := titleRe.FindStringSubmatch(body)
if m == nil {
return ""
}
return html.UnescapeString(strings.TrimSpace(m[1]))
}
func storedID(t *testing.T, h http.Handler) string {
t.Helper()
m := regexp.MustCompile(`href="/documents/(\d+)"`).FindStringSubmatch(get(t, h, "/").Body.String())
if m == nil {
t.Fatal("no stored document on the index")
}
return m[1]
}
// §3: an error is a page with the bar, for a browser; plain text for
// anything else. Before: Go's "404 page not found" in monospace, and
// "could not read that document".
func TestHTMLErrorsRenderTheErrorPage(t *testing.T) {
dbPath := tempDB(t)
in, _ := signedInClient(t, dbPath)
h, _ := admissionApp(t, dbPath, []string{chromeOwner})
out := newClient(t, h)
cases := []struct {
name, path, title string
}{
{"unknown route", "/no/such/page", "Page not found · Memoria"},
{"missing document", "/documents/99999", "Document not available · Memoria"},
{"bad parameter", "/documents/not-a-number", "Document not available · Memoria"},
{"GET of a POST-only path", "/connectors/notion/sync", "Page not found · Memoria"},
}
for _, tc := range cases {
t.Run("signed in, "+tc.name, func(t *testing.T) {
rec := in.get(tc.path)
body := rec.Body.String()
if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("%s = %d %q, want a 404 page", tc.path, rec.Code, rec.Header().Get("Content-Type"))
}
if got := pageTitle(body); got != tc.title {
t.Errorf("title %q, want %q", got, tc.title)
}
if !strings.Contains(body, " stacks its own margins
// on the callout's padding (Memoria's double-height callouts).
func TestTemplatesUseOnlyCalloutTones(t *testing.T) {
dir := filepath.Join("..", "memoria", "templates")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"|
is "{part} · … · Memoria", built by the layout, and
// the first part is the page's h1. The titles mixed "—" and "·" before.
func TestPageTitlesFollowTheSuitePattern(t *testing.T) {
h := newApp(t)
store(t, h, "Minutes, 12 September", "We agreed to move the offsite to November.")
id := storedID(t, h)
for path, want := range map[string]string{
"/": "Memoria",
"/?q=offsite": "“offsite” · Memoria",
"/add": "Add content · Memoria",
"/documents/" + id: "Minutes, 12 September · Memoria",
"/documents/" + id + "/delete": "Delete document · Minutes, 12 September · Memoria",
"/connectors": "Sources · Memoria",
"/connectors/notion": "Notion · Memoria",
"/help/open-source": "Open source · Memoria",
} {
if got := pageTitle(get(t, h, path).Body.String()); got != want {
t.Errorf("%s: title %q, want %q", path, got, want)
}
}
so, _ := signinApp(t)
for path, want := range map[string]string{
"/signin": "Sign in · Memoria",
"/signin?sent=1": "Check your email · Memoria",
"/signin?err=expired": "Sign in · Memoria",
"/signed-out": "Signed out · Memoria",
} {
if got := pageTitle(get(t, so, path).Body.String()); got != want {
t.Errorf("%s: title %q, want %q", path, got, want)
}
}
}
// §8.2: one account menu, the last thing in the bar, with the one Sign
// out in the app; Appearance; Help ending in Open source. Before: a Help
// dropdown, Sources and a Sign out button in a nav that wrapped.
func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) {
h := newApp(t)
store(t, h, "Notes", "hosting migration notes")
id := storedID(t, h)
for _, path := range []string{"/", "/?q=hosting", "/add", "/documents/" + id, "/documents/" + id + "/delete", "/connectors", "/connectors/notion", "/help/open-source"} {
doc := parsePage(t, get(t, h, path).Body.String())
signOuts := findAll(doc, func(n *html.Node) bool {
return (n.Data == "button" || n.Data == "a") && strings.TrimSpace(textOf(n)) == "Sign out"
})
if len(signOuts) != 1 {
t.Errorf("%s: %d Sign outs, want exactly one", path, len(signOuts))
continue
}
ends := findAll(doc, hasAttr("estilo-bar-end"))
if len(ends) != 1 {
t.Errorf("%s: %d [estilo-bar-end]", path, len(ends))
continue
}
var last *html.Node
for c := ends[0].FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode {
last = c
}
}
if last == nil || last.Data != "details" {
t.Errorf("%s: the bar does not end with the account menu", path)
continue
}
if _, ok := attrOf(last, "estilo-account"); !ok {
t.Errorf("%s: the last thing in the bar is not the account menu", path)
}
if !within(signOuts[0], last) {
t.Errorf("%s: Sign out is not in the account menu", path)
}
account := textOf(last)
for _, want := range []string{"Signed in as", memoriaTestOwner(), "Appearance", "Light", "Dark", "System", "Help", "Open source"} {
if !strings.Contains(account, want) {
t.Errorf("%s: the account menu lacks %q", path, want)
}
}
pressed := findAll(last, func(n *html.Node) bool {
v, ok := attrOf(n, "aria-pressed")
return ok && (v == "true" || v == "false")
})
if len(pressed) != 3 {
t.Errorf("%s: %d Appearance options carry aria-pressed, want 3", path, len(pressed))
}
if strings.Contains(textOf(doc), "Connect Home account") || len(findAll(doc, hasAttr("rst-dropdown"))) != 0 {
t.Errorf("%s: the old header's Help dropdown or \"Connect Home account\" is still there", path)
}
}
}
func memoriaTestOwner() string { return "owner@test" }
// §8.4: signed out, the corner is a plain "Sign in" back to this page; on
// the sign-in pages themselves it is empty.
func TestTheSignedOutCorner(t *testing.T) {
h, _ := signinApp(t)
body := newClient(t, h).get("/no/such/page?x=1").Body.String()
doc := parsePage(t, body)
signIns := findAll(doc, hasAttr("estilo-bar-signin"))
if len(signIns) != 1 {
t.Fatalf("%d Sign in links in the bar", len(signIns))
}
if href, _ := attrOf(signIns[0], "href"); href != "/signin?return_to=%2Fno%2Fsuch%2Fpage%3Fx%3D1" {
t.Errorf("Sign in goes to %q, want back to this page", href)
}
for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} {
b := get(t, h, path).Body.String()
if !strings.Contains(b, "`) {
t.Error("the page does not carry data-theme=dark")
}
h, _ := admissionApp(t, tempDB(t), []string{chromeOwner})
out := newClient(t, h)
if got := location(t, out.do(http.MethodPost, "/appearance", url.Values{"mode": {"light"}, "return_to": {"//evil.example/"}})); got != "/" {
t.Errorf("an off-site return_to went to %q, want /", got)
}
}
// §9.4: a document link opened signed out comes back to that document
// after sign-in, through a sealed cookie; "Use a different address" keeps
// it; a plain /signin forgets it.
func TestASignedOutDeepLinkComesBackAfterSignIn(t *testing.T) {
h, mailer := admissionApp(t, tempDB(t), []string{chromeOwner})
c := newClient(t, h)
const deep = "/documents/7?x=1"
signin := location(t, c.get(deep))
if signin != "/signin?return_to=%2Fdocuments%2F7%3Fx%3D1" {
t.Fatalf("a signed-out deep link went to %q", signin)
}
c.get(signin)
var ret *http.Cookie
for name, ck := range c.cookies {
if strings.Contains(name, "return") {
ret = ck
}
}
if ret == nil {
t.Fatal("GET /signin?return_to= remembered nothing")
}
raw, _ := base64.RawURLEncoding.DecodeString(ret.Value)
if strings.Contains(ret.Value, "documents") || strings.Contains(string(raw), "documents") || !ret.HttpOnly {
t.Error("the return cookie is not sealed (the path reads straight out of it)")
}
sent := location(t, c.do(http.MethodPost, "/auth/begin", url.Values{"address": {chromeOwner}}))
body := c.get(sent).Body.String()
if !strings.Contains(body, `href="/signin?return_to=%2Fdocuments%2F7%3Fx%3D1"`) || !strings.Contains(body, "Use a different address") {
t.Errorf("\"Use a different address\" does not keep the return:\n%s", body)
}
u, _ := url.Parse(mailer.link())
dest := location(t, c.openLink(u.RequestURI()))
for i := 0; i < 3 && dest != deep && strings.HasPrefix(dest, "/auth/"); i++ {
dest = location(t, c.get(dest))
}
if dest != deep {
t.Errorf("sign-in landed on %q, want %q", dest, deep)
}
// Tampered, the cookie is ignored: the landing is the index.
c2 := newClient(t, h)
c2.get("/signin?return_to=%2Fadd")
for name, ck := range c2.cookies {
if strings.Contains(name, "return") {
v := []byte(ck.Value)
v[len(v)/2] ^= 1
c2.cookies[name] = &http.Cookie{Name: name, Value: string(v)}
}
}
if got := location(t, c2.get("/auth/landed")); got != "/" {
t.Errorf("a tampered return cookie still steered the landing to %q", got)
}
// And an off-site return_to is not remembered at all.
c3 := newClient(t, h)
c3.get("/signin?return_to=%2F%2Fevil.example%2F")
if got := location(t, c3.get("/auth/landed")); got != "/" {
t.Errorf("an off-site return_to steered the landing to %q", got)
}
}
// §9.2: already signed in, /signin skips the form to a safe return_to. And
// the trap Astra found in Docs, Sheets and Slides: the admission check
// runs FIRST, so a removed owner is signed out and told, once — not
// bounced between /signin and a page that refuses them.
func TestSigninWhenAlreadySignedInRunsTheAdmissionCheckFirst(t *testing.T) {
dbPath := tempDB(t)
c, _ := signedInClient(t, dbPath)
if got := location(t, c.get("/signin?return_to=%2Fadd")); got != "/add" {
t.Errorf("signed in, /signin?return_to=/add went to %q", got)
}
if got := location(t, c.get("/signin?return_to=%2F%2Fevil.example")); got != "/" {
t.Errorf("signed in, an off-site return_to went to %q", got)
}
// The owner is removed. Same database, same session cookie.
h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"})
c.h = h2
if got := location(t, c.get("/signin")); got != "/signin?err=removed" {
t.Fatalf("a removed owner at /signin went to %q, want /signin?err=removed", got)
}
rec := c.get("/signin?err=removed")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your access has been removed") {
t.Errorf("the removed page = %d, and does not say so", rec.Code)
}
if !strings.Contains(location(t, c.get("/")), "/signin?return_to=") {
t.Error("the removed owner's session survived")
}
}
// And the gate itself: a removed owner meets the same sentence on any
// page, instead of a 404 on every page until the session lapses.
func TestARemovedOwnerIsToldOnAnyPage(t *testing.T) {
dbPath := tempDB(t)
c, _ := signedInClient(t, dbPath)
h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"})
c.h = h2
if got := location(t, c.get("/add")); got != "/signin?err=removed" {
t.Errorf("a removed owner at /add went to %q", got)
}
}
// §9.2: Sign out lands on "You're signed out", not a bare sign-in form.
func TestSignOutLandsOnTheSignedOutPage(t *testing.T) {
c, _ := signedInClient(t, tempDB(t))
dest := location(t, c.do(http.MethodPost, "/signout", url.Values{}))
if dest != "/signed-out" {
t.Fatalf("Sign out went to %q", dest)
}
rec := c.get(dest)
b := html.UnescapeString(rec.Body.String())
if rec.Code != http.StatusOK || !strings.Contains(b, "You’re signed out") || !strings.Contains(b, "You’ve signed out of Memoria on this device.") {
t.Errorf("the signed-out page = %d:\n%s", rec.Code, b)
}
}
// Delete asks first. The index's Delete was a one-click POST with no undo.
func TestDeleteAsksFirst(t *testing.T) {
h := newApp(t)
store(t, h, "Keep me", "a document someone might delete by accident")
id := storedID(t, h)
index := get(t, h, "/").Body.String()
if strings.Contains(index, `action="/documents/`+id+`/delete"`) {
t.Error("the index still deletes in one click")
}
if !strings.Contains(index, `href="/documents/`+id+`/delete"`) {
t.Error("the index has no way to ask for a delete")
}
confirm := get(t, h, "/documents/"+id+"/delete")
b := html.UnescapeString(confirm.Body.String())
if confirm.Code != http.StatusOK || !strings.Contains(b, `method="post" action="/documents/`+id+`/delete"`) || !strings.Contains(b, "Keep me") || !strings.Contains(b, ">Cancel<") {
t.Fatalf("the confirmation page = %d:\n%s", confirm.Code, b)
}
if storedCount(t, h) != 1 {
t.Fatal("asking deleted the document")
}
post(t, h, "/documents/"+id+"/delete", url.Values{})
if storedCount(t, h) != 0 {
t.Error("the confirmed delete did not delete")
}
}
// Live search does not say "0 results…" while the person is still typing.
// The zero state is one quiet line, marked for search.js to hold back
// until a pause; a submitted search shows it at once.
func TestNoZeroResultsWhileTyping(t *testing.T) {
h := newApp(t)
store(t, h, "Revenue", "revenue grows next year")
frag := get(t, h, "/search?q=reve").Body.String()
if strings.Contains(frag, "0 results") || !strings.Contains(frag, "rst-search-empty") || strings.Contains(frag, "rst-callout") {
t.Errorf("the live zero state is not one marked quiet line:\n%s", frag)
}
js, err := os.ReadFile(filepath.Join("..", "memoria", "static", "search.js"))
if err != nil {
t.Fatal(err)
}
css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(js), `setAttribute("rst-search-pending"`) || !strings.Contains(string(js), `removeAttribute("rst-search-pending")`) {
t.Error("search.js never holds a live zero state back")
}
if !regexp.MustCompile(`\[rst-search-pending\] \[rst-search-empty\] \{[^}]*visibility: hidden`).Match(css) {
t.Error("memoria.css does not hide a held zero state")
}
}
// The layout: charset first, then the suite's stylesheet order (§0.5,
// §12.1 item 2): tokens → theme → brand → base → menu → chrome → app.
func TestTheLayoutLinksTheSharedChromeInOrder(t *testing.T) {
body := get(t, newApp(t), "/").Body.String()
head := body[strings.Index(body, "")+len(""):]
if !strings.HasPrefix(strings.TrimSpace(head), ``) {
t.Error(" is not the first thing in ")
}
last := -1
for _, name := range []string{"tokens", "theme", "brand/brand", "base", "menu", "chrome", "memoria"} {
m := regexp.MustCompile(`href="/static/` + name + `\.[^"]*css"`).FindStringIndex(body)
if m == nil {
t.Errorf("the layout does not link %s.css", name)
continue
}
if m[0] < last {
t.Errorf("%s.css is linked out of the suite's order", name)
}
last = m[0]
}
}
// Nineteen template sites wrote rst-cell-mut as an attribute, which styles
// nothing (it is a class-only utility): muted text rendered at body colour.
func TestMutedTextIsStyled(t *testing.T) {
dir := filepath.Join("..", "memoria", "templates")
entries, _ := os.ReadDir(dir)
for _, e := range entries {
raw, _ := os.ReadFile(filepath.Join(dir, e.Name()))
if n := regexp.MustCompile(`<[^>]*\srst-cell-mut[\s>]`).FindAll(raw, -1); len(n) > 0 {
t.Errorf("%s writes rst-cell-mut as an attribute %d times", e.Name(), len(n))
}
}
css, _ := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
if !regexp.MustCompile(`\[rst-muted\] \{ color: var\(--rst-text-muted\); \}`).Match(css) {
t.Error("memoria.css does not style [rst-muted]")
}
if !strings.Contains(get(t, newApp(t), "/").Body.String(), "
") {
t.Error("the footer is not muted")
}
}
// Fields fit the column: width 100% plus padding overflowed by 22px at
// 1440 and off screen at 390 (content-box); password fields were the
// browser's own; the search input drew a second box inside [rst-search].
func TestFormFieldsFitTheColumn(t *testing.T) {
css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
if err != nil {
t.Fatal(err)
}
rule := regexp.MustCompile(`(?s)\[rst-stack\] input\[type="text"\],[^{]*\{[^}]*\}`).Find(css)
if rule == nil {
t.Fatal("no [rst-stack] field rule")
}
for _, want := range []string{`input[type="password"]`, "box-sizing: border-box"} {
if !strings.Contains(string(rule), want) {
t.Errorf("the field rule lacks %q", want)
}
}
if regexp.MustCompile(`\[rst-search\] input\[type="search"\] \{`).Match(css) {
t.Error("memoria.css still draws its own box on the search input, inside rastrillo's")
}
if !strings.Contains(string(css), "::file-selector-button") {
t.Error("the file chooser's button is the browser's own")
}
}
// The Sources lists are rows, not a card of bare bulleted
s.
func TestSourcesAreRows(t *testing.T) {
b := get(t, newApp(t), "/connectors").Body.String()
if strings.Contains(b, "rst-list-item") || strings.Contains(b, "
") {
t.Error("the Sources page still puts bare list items in a card")
}
if !strings.Contains(b, "
") {
t.Error("the Sources page has no rows")
}
}
var _ = strconv.Itoa
// Estilo's chrome is tested against rastrillo v0.23's tokens in estilo;
// this is the same check against the tokens.css and theme.css THIS app
// serves (spec §12.1 item 1): a var(--rst-*) nothing defines makes its
// whole declaration invalid, and the rule vanishes with no error.
func TestEstilosChromeUsesOnlyDefinedTokens(t *testing.T) {
static := filepath.Join("..", "memoria", "static")
defined := map[string]bool{}
def := regexp.MustCompile(`(?:^|[;{,\s])(--(?:rst|estilo)-[a-zA-Z0-9_-]+)\s*:`)
for _, name := range []string{"tokens.css", "theme.css", "base.css", "menu.css", "chrome.css"} {
raw, err := os.ReadFile(filepath.Join(static, name))
if err != nil {
t.Fatal(err)
}
for _, m := range def.FindAllStringSubmatch(string(raw), -1) {
defined[m[1]] = true
}
}
if !defined["--rst-accent"] || !defined["--estilo-bar-block-size"] {
t.Fatal("the definition pattern found neither --rst-accent nor --estilo-bar-block-size; this gate is measuring nothing")
}
use := regexp.MustCompile(`var\(\s*(--(?:rst|estilo)-[a-zA-Z0-9_-]+)\s*([,)])`)
seen := 0
for _, name := range []string{"base.css", "menu.css", "chrome.css"} {
raw, err := os.ReadFile(filepath.Join(static, name))
if err != nil {
t.Fatal(err)
}
for _, m := range use.FindAllStringSubmatch(string(raw), -1) {
seen++
if m[2] == ")" && !defined[m[1]] {
t.Errorf("%s reads var(%s), which neither this app's tokens nor estilo define", name, m[1])
}
}
}
if seen < 20 {
t.Fatalf("only %d token references in estilo's chrome; the walk is broken", seen)
}
}
// polish audit r3 B7 and C5: the Title hint read as bold label text, and
// /add put a section rule between "← Back to search" and its h1, where the
// document page uses the breadcrumb nav.
func TestTheAddPageReadsLikeTheDocumentPage(t *testing.T) {
b := get(t, newApp(t), "/add").Body.String()
if !strings.Contains(b, ``) {
t.Error("the Title hint is not a quiet \"(optional)\"")
}
if !strings.Contains(b, ``) {
t.Error("/add's back link is not the breadcrumb the document page uses")
}
if regexp.MustCompile(`(?s)\s*
Add content
`).MatchString(b) {
t.Error("a section rule still sits between the back link and the h1")
}
css, _ := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
if !regexp.MustCompile(`\[rst-hint\] \{[^}]*font-weight: 400`).Match(css) {
t.Error("the hint inherits the label's weight")
}
}