Files
pad/internal/models/document_title_test.go
T
xarmian 71fa167614 fix(documents): the rename cascade decides a link by the wiki grammar, so a legacy title containing | \ or ] keeps its links and no longer rewrites a link to another target (BUG-2806) (#1572)
* fix(documents): the rename cascade matches a legacy title's links in their escaped form, so a title containing | \ or ] no longer leaves its links stale or rewrites a link to another target (BUG-2806)

* test(documents): a linker holding only the escaped link must be found by the scan (BUG-2806)

* docs(links,models): the ReplaceTitle limitation notes reflect the escaped cascade (BUG-2806)

* fix(documents): a link is decided by the wiki grammar, so every encoding of a legacy title's link is rewritten, and the scan allocates nothing under the cascade's memory bound (BUG-2806)

* refactor(links): drop the replaceAll helper ReplaceTitle no longer uses, keeping its termination reasoning on ReplaceTitle (BUG-2806)

* fix(documents): the refusal of | and \ in a new title gives its true reason, since the rename cascade now finds their escaped links (BUG-2806)
2026-09-26 10:52:41 -04:00

196 lines
8.9 KiB
Go

package models
import (
"regexp"
"strings"
"testing"
)
// The two mechanisms a stored wiki-link bracket passes through, mirrored from
// web/src/lib/utils/markdown.ts so this test can derive the validator's rule
// from them instead of from an opinion about which characters look dangerous.
//
// Duplicated rather than approximated — and duplication is exactly the risk
// here, stated rather than papered over: these are STATIC copies, so a change
// to the TypeScript does not reach them. Nothing in this repository fails when
// the two drift (codex round 6). What this buys is that the Go rule is derived
// from a written-down grammar rather than from an opinion, and that a reader
// can check the two by eye at the cited line numbers. Closing the drift for
// real would need a shared fixture driven from both languages.
var (
// markdown.ts::WIKI_LINK_PATTERN_SOURCE — shared by renderMarkdown and wikiLinksToMarkdown.
storedWikiLinkBracket = regexp.MustCompile(`\[\[((?:\\.|[^\]\\])+)\]\]`)
// markdown.ts::unescapeWikiBody — unescapeWikiBody, applied before title comparison.
wikiBodyEscape = regexp.MustCompile(`\\(\\|\]|\|)`)
)
// roundTrips reports whether emitting `[[title]]` the way links.ReplaceTitle
// does — plain concatenation, no escaping — produces a bracket that reads back
// as exactly this title.
//
// Both layers, because either alone gives a wrong answer. The grammar alone
// accepts `A\\B` (a valid escape pair) which the unescaper then turns into
// `A\B`, a different title. The unescaper alone accepts `A]B`, which the
// grammar cuts short.
func roundTrips(title string) bool {
emitted := "[[" + title + "]]"
m := storedWikiLinkBracket.FindStringSubmatch(emitted)
if m == nil || m[0] != emitted {
// No match, or a match over a PREFIX of the emission — the latter is
// BUG-2796's early-termination defect, where `[[A]] [[A]]` matched
// twice and neither match was the renamed document.
return false
}
return wikiBodyEscape.ReplaceAllString(m[1], "$1") == title
}
// TestDocumentTitleValidationMatchesTheRoundTripProperty is the justification
// for the SYNTAX rule, in executable form: of the titles this table covers —
// all of them within the length bound — the validator must reject one if and
// only if its links would not survive being rewritten to it.
//
// Scoped to syntax deliberately. ValidateDocumentTitle also enforces length
// and non-emptiness, and those are NOT round-trip properties: a 300-rune title
// round-trips perfectly and is still refused, for the unrelated reason that it
// is an amplification factor on other documents. Stating the biconditional
// over the whole validator would be false (codex round 6); the length rule has
// its own tests below.
//
// Both directions are asserted, because either alone permits a wrong answer.
// Without the reject leg, a validator that accepted everything passes.
// Without the accept leg, a validator that rejected `[`, `|` or a lone `\`
// passes — and that is not hypothetical: the first version of this fix banned
// `]`, `\` and `|` as a character class, and this test refuted two thirds of
// it. `|` in particular is a title shape resolveWikiBody contains a dedicated
// branch to support.
func TestDocumentTitleValidationMatchesTheRoundTripProperty(t *testing.T) {
for _, tc := range []struct {
name string
title string
}{
// Must be REJECTED — each breaks the round trip by a different
// mechanism.
{"bug-2796 repro", `A]] [[A`},
{"single close bracket", `Alpha]Beta`},
{"trailing backslash", `Alpha\`},
{"escaped backslash", `Alpha\\Beta`},
{"escaped pipe", `Alpha\|Beta`},
// Must be ACCEPTED — each looks like wiki-link syntax and none of
// them breaks anything.
{"open bracket", `Alpha[Beta`},
{"double open bracket", `Alpha[[Beta`},
{"collection-qualified shape", `collection/Title`},
{"colons", `Ratio 1:2`},
{"ordinary punctuation", `Title (draft) — v2`},
{"non-latin", `Ünïcödé título`},
} {
t.Run(tc.name, func(t *testing.T) {
wantAccept := roundTrips(tc.title)
msg := ValidateDocumentTitle(tc.title)
gotAccept := msg == ""
if gotAccept != wantAccept {
t.Errorf("title %q: round-trips=%v but validator accepts=%v (message: %q)\n"+
"the validator and the renderers must agree — a rejected title that renders "+
"correctly is a refusal of valid input; an accepted title that does not is BUG-2796",
tc.title, wantAccept, gotAccept, msg)
}
})
}
}
// TestDocumentTitleBoundIsTheRuledNumber pins the VALUE, not just the
// behaviour around it.
//
// Every other length test derives its inputs from MaxDocumentTitleRunes, so
// changing the constant to 512 would leave them all green (codex round 4).
// That is fine for arithmetic but wrong for this number: 255 is a product
// decision Dave ruled on for BUG-2798, and a silent change to it is a silent
// change to how much amplification the cheap door lets through. Changing it
// should require editing this line and noticing why.
//
// Deliberately NOT done for store.MaxRenameCascadeRetainedBytes: that one is
// mine, chosen with a measured receipt in its doc comment, and is expected to
// be tuned if the measurements change.
func TestDocumentTitleBoundIsTheRuledNumber(t *testing.T) {
if MaxDocumentTitleRunes != 255 {
t.Errorf("MaxDocumentTitleRunes = %d, want 255 (Dave's day-63 ruling on BUG-2798). "+
"If this is a deliberate product change, update the ruling reference too.",
MaxDocumentTitleRunes)
}
}
// TestDocumentTitleLengthBoundCountsRunesNotBytes pins the bound at 255
// CHARACTERS, which is what the ruling says and what a UI counter shows.
//
// The multibyte legs are the counterfactual: 255 four-byte runes are 1020
// bytes, so an implementation that reached for len() would reject them. That
// implementation would be wrong in a user-visible way — an ordinary
// 255-character title in a non-Latin script refused — while every ASCII-only
// test stayed green.
func TestDocumentTitleLengthBoundCountsRunesNotBytes(t *testing.T) {
for _, tc := range []struct {
name string
title string
accept bool
}{
{"at the bound, ascii", strings.Repeat("a", MaxDocumentTitleRunes), true},
{"one over, ascii", strings.Repeat("a", MaxDocumentTitleRunes+1), false},
{"at the bound, 2-byte runes", strings.Repeat("é", MaxDocumentTitleRunes), true},
{"at the bound, 4-byte runes", strings.Repeat("𝄞", MaxDocumentTitleRunes), true},
{"one over, 4-byte runes", strings.Repeat("𝄞", MaxDocumentTitleRunes+1), false},
{"empty", "", false},
} {
msg := ValidateDocumentTitle(tc.title)
if tc.accept && msg != "" {
t.Errorf("%s: rejected (%s), want accepted", tc.name, msg)
}
if !tc.accept && msg == "" {
t.Errorf("%s: accepted, want rejected", tc.name)
}
}
}
// TestDocumentTitleRejectsFormsTheCascadeCannotFind covers the two characters
// whose refusal the round-trip property above does NOT explain — and that gap
// is the finding, not an oversight in the table.
//
// `Alpha|Beta` and `Alpha\Beta` both round-trip perfectly: the renderer reads
// each back as exactly the title it started from. The first version of this
// validator accepted them for precisely that reason, and it was wrong (codex
// round 9). A link to such a title can be STORED in escaped form —
// `[[Alpha\|Beta]]` — which the rename cascade, searching for the raw
// `[[Alpha|Beta]]`, will not find. The rename then succeeds and leaves those
// links pointing at a title that no longer exists.
//
// So the property a title must satisfy is stricter than "the renderer reads it
// back": it must also be one whose links the cascade can FIND. Validating
// against the display layer while the maintenance layer disagrees is the same
// mistake as the unescaped LIKE, met from the other side.
func TestDocumentTitleRejectsFormsTheCascadeCannotFind(t *testing.T) {
for _, title := range []string{`Alpha|Beta`, `Alpha\Beta`} {
// The premise: each of these DOES round-trip. If that ever stops being
// true they are rejected for the ordinary syntax reason and this test
// is no longer testing what it says.
if !roundTrips(title) {
t.Fatalf("premise: %q must round-trip, or its rejection needs no separate justification", title)
}
if ValidateDocumentTitle(title) == "" {
t.Errorf("ValidateDocumentTitle(%q) accepted a title whose links the cascade cannot find; "+
"a rename would leave them stale", title)
}
}
}
// BUG-2806 (lead ruling): `|` and `\` stay refused in new titles, but the
// reason given is the true one. It used to say the rename cascade could not
// find their escaped links, which BUG-2806 made false.
func TestValidateDocumentTitle_PipeAndBackslashReasonIsTrue(t *testing.T) {
for _, title := range []string{"A|B", `A\B`} {
msg := ValidateDocumentTitle(title)
if !strings.Contains(msg, "not supported in document titles yet") || strings.Contains(msg, "cascade") {
t.Errorf("%q: refusal %q should give the not-yet-supported reason, not the cascade one", title, msg)
}
}
}