Skip to content

Commit 891d1b8

Browse files
JohnMcLearclaude
andcommitted
fix: allow undo of clear authorship colors without disconnect (#2802)
When a user clears authorship colors and then undoes, the undo changeset re-applies author attributes for all authors who contributed text. The server was rejecting this because it treated any changeset containing another author's ID as impersonation, disconnecting the user. The fix distinguishes between: - '+' ops (new text): still reject if attributed to another author - '=' ops (attribute changes on existing text): allow restoring other authors' attributes, which is needed for undo of clear authorship Also removes the client-side workaround in undomodule.ts that prevented clear authorship from being undone at all, and adds backend + frontend tests covering the multi-author undo scenario. Fixes: #2802 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 72a41b2 commit 891d1b8

File tree

5 files changed

+449
-19
lines changed

5 files changed

+449
-19
lines changed

src/node/handler/PadMessageHandler.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,10 @@ const handleUserChanges = async (socket:any, message: {
652652
// Verify that the changeset has valid syntax and is in canonical form
653653
checkRep(changeset);
654654

655-
// Validate all added 'author' attribs to be the same value as the current user
655+
// Validate all added 'author' attribs to be the same value as the current user.
656+
// Exception: '=' ops (attribute changes on existing text) are allowed to set other authors'
657+
// IDs. This is necessary for undoing "clear authorship colors", which re-applies the original
658+
// author attributes for all authors. See https://github.com/ether/etherpad-lite/issues/2802
656659
for (const op of deserializeOps(unpack(changeset).ops)) {
657660
// + can add text with attribs
658661
// = can change or add attribs
@@ -664,8 +667,12 @@ const handleUserChanges = async (socket:any, message: {
664667
// an attribute number isn't in the pool).
665668
const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get('author');
666669
if (opAuthorId && opAuthorId !== thisSession.author) {
667-
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
668-
`${opAuthorId} in changeset ${changeset}`);
670+
// Allow '=' ops to restore other authors' attributes on existing text (undo of clear
671+
// authorship). Only reject '+' ops that insert new text as another author.
672+
if (op.opcode === '+') {
673+
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
674+
`${opAuthorId} in changeset ${changeset}`);
675+
}
669676
}
670677
}
671678

src/static/js/undomodule.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,7 @@ const undoModule = (() => {
212212
}
213213
}
214214
if (!merged) {
215-
/*
216-
* Push the event on the undo stack only if it exists, and if it's
217-
* not a "clearauthorship". This disallows undoing the removal of the
218-
* authorship colors, but is a necessary stopgap measure against
219-
* https://github.com/ether/etherpad-lite/issues/2802
220-
*/
221-
if (event && (event.eventType !== 'clearauthorship')) {
215+
if (event) {
222216
stack.pushEvent(event);
223217
}
224218
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
'use strict';
2+
3+
/**
4+
* Tests for https://github.com/ether/etherpad-lite/issues/2802
5+
*
6+
* When User B clears authorship colors (removing all author attributes) and then undoes,
7+
* the undo changeset re-applies author attributes for ALL authors (A and B). The server
8+
* rejects this because User B is submitting changes containing User A's author ID, causing
9+
* a disconnect with "badChangeset".
10+
*
11+
* The server should allow undo of clear authorship without disconnecting the user.
12+
*/
13+
14+
import {PadType} from "../../../node/types/PadType";
15+
16+
const assert = require('assert').strict;
17+
const common = require('../common');
18+
const padManager = require('../../../node/db/PadManager');
19+
import AttributePool from '../../../static/js/AttributePool';
20+
import padutils from '../../../static/js/pad_utils';
21+
22+
describe(__filename, function () {
23+
let agent: any;
24+
let pad: PadType | null;
25+
let padId: string;
26+
let socketA: any;
27+
let socketB: any;
28+
let revA: number;
29+
let revB: number;
30+
31+
before(async function () {
32+
agent = await common.init();
33+
});
34+
35+
beforeEach(async function () {
36+
padId = common.randomString();
37+
assert(!await padManager.doesPadExist(padId));
38+
pad = await padManager.getPad(padId, '');
39+
await pad!.setText('\n');
40+
assert.equal(pad!.text(), '\n');
41+
});
42+
43+
afterEach(async function () {
44+
if (socketA != null) socketA.close();
45+
socketA = null;
46+
if (socketB != null) socketB.close();
47+
socketB = null;
48+
if (pad != null) await pad.remove();
49+
pad = null;
50+
});
51+
52+
/**
53+
* Connect a user to the pad with a unique author token.
54+
*/
55+
const connectUser = async () => {
56+
const res = await agent.get(`/p/${padId}`).expect(200);
57+
const socket = await common.connect(res);
58+
const token = padutils.generateAuthorToken();
59+
const {type, data: clientVars} = await common.handshake(socket, padId, token);
60+
assert.equal(type, 'CLIENT_VARS');
61+
const rev = clientVars.collab_client_vars.rev;
62+
const author = clientVars.userId;
63+
return {socket, rev, author};
64+
};
65+
66+
const sendUserChanges = async (socket: any, baseRev: number, changeset: string, apool?: any) => {
67+
await common.sendUserChanges(socket, {
68+
baseRev,
69+
changeset,
70+
...(apool ? {apool} : {}),
71+
});
72+
};
73+
74+
/**
75+
* Wait for an ACCEPT_COMMIT message, skipping any other COLLABROOM messages
76+
* (like USER_NEWINFO, NEW_CHANGES, etc.) that may arrive first.
77+
*/
78+
const waitForAcceptCommit = async (socket: any, wantRev: number) => {
79+
for (;;) {
80+
const msg = await common.waitForSocketEvent(socket, 'message');
81+
if (msg.disconnect) {
82+
throw new Error(`Unexpected disconnect: ${JSON.stringify(msg)}`);
83+
}
84+
if (msg.type === 'COLLABROOM' && msg.data?.type === 'ACCEPT_COMMIT') {
85+
assert.equal(msg.data.newRev, wantRev);
86+
return;
87+
}
88+
// Skip non-ACCEPT_COMMIT messages (USER_NEWINFO, NEW_CHANGES, etc.)
89+
}
90+
};
91+
92+
/**
93+
* Drain messages from a socket until we get an ACCEPT_COMMIT or disconnect.
94+
* Returns the message for assertion.
95+
*/
96+
const waitForNextCommitOrDisconnect = async (socket: any): Promise<any> => {
97+
for (;;) {
98+
const msg = await common.waitForSocketEvent(socket, 'message');
99+
if (msg.disconnect) return msg;
100+
if (msg.type === 'COLLABROOM' && msg.data?.type === 'ACCEPT_COMMIT') return msg;
101+
// Skip USER_NEWINFO, NEW_CHANGES, etc.
102+
}
103+
};
104+
105+
/**
106+
* Drain non-ACCEPT_COMMIT messages so the socket is ready for the next operation.
107+
* Waits briefly then consumes any queued messages.
108+
*/
109+
const drainMessages = async (socket: any) => {
110+
await new Promise(resolve => setTimeout(resolve, 500));
111+
};
112+
113+
describe('undo of clear authorship colors (bug #2802)', function () {
114+
it('should not disconnect when undoing clear authorship with multiple authors', async function () {
115+
this.timeout(30000);
116+
117+
// Step 1: Connect User A
118+
const userA = await connectUser();
119+
socketA = userA.socket;
120+
revA = userA.rev;
121+
122+
// Step 2: User A types "hello" with their author attribute
123+
const apoolA = new AttributePool();
124+
apoolA.putAttrib(['author', userA.author]);
125+
await Promise.all([
126+
waitForAcceptCommit(socketA, revA + 1),
127+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
128+
]);
129+
revA += 1;
130+
131+
// Step 3: Connect User B (after User A's text is committed)
132+
await drainMessages(socketA);
133+
const userB = await connectUser();
134+
socketB = userB.socket;
135+
revB = userB.rev;
136+
// User B joins and sees the pad at the current head revision
137+
await drainMessages(socketA);
138+
139+
// Step 4: User B types " world" with their author attribute
140+
const apoolB = new AttributePool();
141+
apoolB.putAttrib(['author', userB.author]);
142+
await Promise.all([
143+
waitForAcceptCommit(socketB, revB + 1),
144+
sendUserChanges(socketB, revB, 'Z:6>6=5*0+6$ world', apoolB),
145+
]);
146+
revB += 1;
147+
148+
// Wait for User A to see the change
149+
await drainMessages(socketA);
150+
revA = revB;
151+
152+
// The pad now has "hello world\n" with two different authors
153+
assert.equal(pad!.text(), 'hello world\n');
154+
155+
// Step 5: User B clears authorship colors (sets author to '' on all text)
156+
const clearPool = new AttributePool();
157+
clearPool.putAttrib(['author', '']);
158+
await Promise.all([
159+
waitForAcceptCommit(socketB, revB + 1),
160+
sendUserChanges(socketB, revB, 'Z:c>0*0=b$', clearPool),
161+
]);
162+
revB += 1;
163+
await drainMessages(socketA);
164+
revA = revB;
165+
166+
// Step 6: User B undoes the clear authorship
167+
// This is the critical part - the undo changeset re-applies the original
168+
// author attributes, which include User A's author ID.
169+
// The server currently rejects this because User B is submitting changes
170+
// with User A's author ID.
171+
const undoPool = new AttributePool();
172+
undoPool.putAttrib(['author', userA.author]); // 0 = author A
173+
undoPool.putAttrib(['author', userB.author]); // 1 = author B
174+
// Undo restores: "hello" with author A (5 chars), " world" with author B (6 chars)
175+
const undoChangeset = 'Z:c>0*0=5*1=6$';
176+
177+
// This should NOT disconnect User B - that's the bug (#2802)
178+
const result = await Promise.all([
179+
waitForNextCommitOrDisconnect(socketB),
180+
sendUserChanges(socketB, revB, undoChangeset, undoPool),
181+
]);
182+
183+
const msg = result[0];
184+
assert.notDeepEqual(msg, {disconnect: 'badChangeset'},
185+
'User was disconnected with badChangeset - bug #2802');
186+
assert.equal(msg.type, 'COLLABROOM');
187+
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
188+
});
189+
190+
it('should allow clear authorship changeset with empty author from any user', async function () {
191+
// Connect one user, write text, then clear authorship
192+
const userA = await connectUser();
193+
socketA = userA.socket;
194+
revA = userA.rev;
195+
196+
// User A types text
197+
const apoolA = new AttributePool();
198+
apoolA.putAttrib(['author', userA.author]);
199+
await Promise.all([
200+
waitForAcceptCommit(socketA, revA + 1),
201+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
202+
]);
203+
revA += 1;
204+
205+
// User A clears authorship (sets author='')
206+
// This should always be allowed since author='' is not impersonation
207+
const clearPool = new AttributePool();
208+
clearPool.putAttrib(['author', '']);
209+
await Promise.all([
210+
waitForAcceptCommit(socketA, revA + 1),
211+
sendUserChanges(socketA, revA, 'Z:6>0*0=5$', clearPool),
212+
]);
213+
});
214+
215+
it('changeset restoring own author after clear should be accepted', async function () {
216+
// User clears their own authorship and then undoes (restoring own author attr)
217+
const userA = await connectUser();
218+
socketA = userA.socket;
219+
revA = userA.rev;
220+
221+
// User A types text with author attribute
222+
const apoolA = new AttributePool();
223+
apoolA.putAttrib(['author', userA.author]);
224+
await Promise.all([
225+
waitForAcceptCommit(socketA, revA + 1),
226+
sendUserChanges(socketA, revA, 'Z:1>5*0+5$hello', apoolA),
227+
]);
228+
revA += 1;
229+
230+
// User A clears authorship (sets author='')
231+
const clearPool = new AttributePool();
232+
clearPool.putAttrib(['author', '']);
233+
await Promise.all([
234+
waitForAcceptCommit(socketA, revA + 1),
235+
sendUserChanges(socketA, revA, 'Z:6>0*0=5$', clearPool),
236+
]);
237+
revA += 1;
238+
239+
// User A undoes the clear - restoring their own author attribute
240+
// This should be accepted since it's their own author ID
241+
await Promise.all([
242+
waitForAcceptCommit(socketA, revA + 1),
243+
sendUserChanges(socketA, revA, 'Z:6>0*0=5$', apoolA),
244+
]);
245+
});
246+
247+
it('changeset impersonating another author for new text should still be rejected', async function () {
248+
// Security: a user should NOT be able to write NEW text attributed to another author
249+
const userA = await connectUser();
250+
socketA = userA.socket;
251+
revA = userA.rev;
252+
253+
await drainMessages(socketA);
254+
255+
const userB = await connectUser();
256+
socketB = userB.socket;
257+
revB = userB.rev;
258+
259+
await drainMessages(socketA);
260+
261+
// User B tries to insert text attributed to User A - this should be rejected
262+
const fakePool = new AttributePool();
263+
fakePool.putAttrib(['author', userA.author]);
264+
265+
const result = await Promise.all([
266+
waitForNextCommitOrDisconnect(socketB),
267+
sendUserChanges(socketB, revB, 'Z:1>5*0+5$hello', fakePool),
268+
]);
269+
270+
assert.deepEqual(result[0], {disconnect: 'badChangeset'},
271+
'Should reject changeset that impersonates another author for new text');
272+
});
273+
});
274+
});

src/tests/frontend-new/specs/clear_authorship_color.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ test('clear authorship color', async ({page}) => {
3838
})
3939

4040

41-
test("makes text clear authorship colors and checks it can't be undone", async function ({page}) {
41+
test("clear authorship colors can be undone to restore author colors", async function ({page}) {
42+
// Fix for https://github.com/ether/etherpad-lite/issues/2802
43+
// Previously, undo of clear authorship was blocked as a workaround.
44+
// Now the server properly allows it, so undo should restore author colors.
4245
const innnerPad = await getPadBody(page);
4346
const padText = "Hello"
4447

@@ -51,19 +54,20 @@ test("makes text clear authorship colors and checks it can't be undone", async f
5154
const retrievedClasses = await innnerPad.locator('div span').nth(0).getAttribute('class')
5255
expect(retrievedClasses).toContain('author');
5356

54-
5557
await firstDivClass.focus()
5658
await clearAuthorship(page);
5759
expect(await firstDivClass.getAttribute('class')).not.toContain('author');
5860

61+
// Undo should restore authorship colors
5962
await undoChanges(page);
60-
const changedFirstDiv = innnerPad.locator('div').nth(0)
61-
expect(await changedFirstDiv.getAttribute('class')).not.toContain('author');
62-
63-
64-
await pressUndoButton(page);
65-
const secondChangedFirstDiv = innnerPad.locator('div').nth(0)
66-
expect(await secondChangedFirstDiv.getAttribute('class')).not.toContain('author');
63+
await page.waitForTimeout(1000);
64+
const spanAfterUndo = innnerPad.locator('div span').nth(0);
65+
const classesAfterUndo = await spanAfterUndo.getAttribute('class');
66+
expect(classesAfterUndo).toContain('author');
67+
68+
// User should not be disconnected
69+
const disconnected = page.locator('.disconnected, .unreachable');
70+
expect(await disconnected.isVisible()).toBe(false);
6771
});
6872

6973

0 commit comments

Comments
 (0)