diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index de8e85fddbd..840770cbcfc 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -590,25 +590,42 @@ class Pad { Object.assign(this, value); if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool); } else { - if (text == null) { + // Auto-generated default content (settings.defaultPadText or whatever a + // padDefaultContent hook substitutes) is not written by the user who + // happens to open the pad first, so the text must not carry their author + // attribute — otherwise the welcome text shows up in the creator's + // authorship colour (issue #7885). Track whether the text came from the + // default-content path so its insert op can be attributed to the system + // author. + const usedDefaultContent = (text == null); + if (usedDefaultContent) { const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText}; await hooks.aCallAll('padDefaultContent', context); if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); text = exports.cleanText(context.content); } - // When the initial pad text is non-empty but no authorId was - // supplied (internal getPad calls during HTTP API setup, - // padDefaultContent flows, plugin-driven pad creation), fall back - // to the stable system author so the initial changeset's insert - // op carries an `author` attribute. Mirrors the same substitution + // The author *attribute* applied to the initial text — i.e. what colours + // it in the editor — is the stable system author when the content is + // auto-generated default text (#7885), or when non-empty text was + // supplied without an authorId (internal getPad calls during HTTP API + // setup, plugin-driven pad creation). The latter keeps the insert op + // carrying an `author` attribute, mirroring the substitution // setText/appendText already do via spliceText. - const effectiveAuthorId = - (text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId; - const firstAttribs = effectiveAuthorId - ? [['author', effectiveAuthorId] as [string, string]] + const attribAuthorId = + ((usedDefaultContent || !authorId) && text.length > 0) + ? Pad.SYSTEM_AUTHOR_ID : authorId; + const firstAttribs = attribAuthorId + ? [['author', attribAuthorId] as [string, string]] : undefined; + // The *revision* author (revs:0 meta.author) stays the real creator so + // pad ownership is preserved: isPadCreator() / the pad-wide settings gate + // and the deletion token all key off getRevisionAuthor(0). Only when no + // author was supplied at all do we fall back to the system author, so the + // initial revision still records a stable, non-empty author. + const revisionAuthorId = + authorId || (text.length > 0 ? Pad.SYSTEM_AUTHOR_ID : ''); const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool); - await this.appendRevision(firstChangeset, effectiveAuthorId); + await this.appendRevision(firstChangeset, revisionAuthorId); } this.padSettings = Pad.normalizePadSettings(this.padSettings); await hooks.aCallAll('padLoad', {pad: this}); diff --git a/src/tests/backend/specs/Pad.ts b/src/tests/backend/specs/Pad.ts index d4c4956cb03..eab6eaee4f0 100644 --- a/src/tests/backend/specs/Pad.ts +++ b/src/tests/backend/specs/Pad.ts @@ -179,6 +179,58 @@ describe(__filename, function () { pad = await padManager.getPad(padId); assert.equal(pad!.text(), `${want}\n`); }); + + // Returns the set of author IDs actually applied to the pad's text, by + // resolving every attribute marker in the current AText against the pool. + // This is what colours the text in the editor — distinct from + // getRevisionAuthor()/getAllAuthors() which also reflect pool bookkeeping. + const authorsAppliedToText = (p: any): Set => { + const applied = new Set(); + const attribs: string = p.atext.attribs; + for (const m of attribs.matchAll(/\*([0-9a-z]+)/g)) { + const attr = p.pool.getAttrib(parseInt(m[1], 36)); + if (attr && attr[0] === 'author' && attr[1] !== '') applied.add(attr[1]); + } + return applied; + }; + + it('does not colour default content with the creating user (issue #7885)', + async function () { + // When a user opens a brand-new pad, CLIENT_READY calls + // getPad(padId, null, session.author). The default welcome text is not + // written by that user, so its insert op must not carry their author + // attribute (which would colour it in the creator's colour). The system + // author owns the text instead. + const creator = await authorManager.getAuthorId(`t.${padId}`); + pad = await padManager.getPad(padId, null, creator); + const applied = authorsAppliedToText(pad); + assert(!applied.has(creator), + `default text must not be coloured with the creating author ${creator}`); + assert(applied.has('a.etherpad-system'), + 'default text should be owned by the system author'); + }); + + it('keeps the creating user as the revision-0 author so pad ownership is preserved', + async function () { + // isPadCreator()/the pad-wide settings gate and the deletion token all + // key off getRevisionAuthor(0). Reassigning the welcome-text colour to + // the system author (above) must not strip the creator's ownership. + const creator = await authorManager.getAuthorId(`t.${padId}`); + pad = await padManager.getPad(padId, null, creator); + assert.equal(await (pad as any).getRevisionAuthor(0), creator, + 'the creating user must remain the revision-0 author'); + }); + + it('still colours explicitly provided content with the creating author', + async function () { + // A real author providing real text (e.g. API createPad with text) + // keeps ownership of that text — only auto-generated default content is + // reassigned to the system author. + const creator = await authorManager.getAuthorId(`t.${padId}`); + pad = await padManager.getPad(padId, 'real user content', creator); + assert(authorsAppliedToText(pad).has(creator), + 'explicitly provided text should be coloured with the creating author'); + }); }); describe('normalizePadSettings lang (issue #7586)', function () { diff --git a/src/tests/frontend-new/specs/wcag_author_color.spec.ts b/src/tests/frontend-new/specs/wcag_author_color.spec.ts index 314591c62aa..27dda3728a7 100644 --- a/src/tests/frontend-new/specs/wcag_author_color.spec.ts +++ b/src/tests/frontend-new/specs/wcag_author_color.spec.ts @@ -36,17 +36,23 @@ const wcagRatio = (rgb1: string, rgb2: string): number => { const renderedAuthorContrast = async (page: Page) => { const body = await getPadBody(page); await body.click(); - await page.keyboard.type('contrast smoke'); + const typed = 'contrast smoke'; + await page.keyboard.type(typed); await page.waitForTimeout(300); - // The author span is the inner-frame wrapping - // the typed text. Read its computed bg + the inherited text colour. - const result = await page.frame('ace_inner')!.evaluate(() => { - const span = document.querySelector( - '#innerdocbody span[class*="author-"]:not([class*="anonymous"])') as HTMLElement | null; + // The author span is the inner-frame wrapping the + // text WE just typed. Match by text content rather than picking the first + // author span on the page: the default welcome text is owned by the system + // author (issue #7885) and renders with no background colour, so the first + // author span is no longer the current user's. Read the span's computed bg + + // the inherited text colour. + const result = await page.frame('ace_inner')!.evaluate((needle) => { + const spans = Array.from( + document.querySelectorAll('#innerdocbody span[class*="author-"]')) as HTMLElement[]; + const span = spans.find((s) => (s.textContent || '').includes(needle)); if (!span) return null; const cs = getComputedStyle(span); return {bg: cs.backgroundColor, color: cs.color}; - }); + }, typed); return result; };