From 499f05646796a4b65c7f8b228b3bbefaceb07703 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:08:52 +0800 Subject: [PATCH] test: lock the Owner's positive project-role path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1836/#1837 pinned the refusal direction of the Project role switch at the endpoint; the positive direction — the Owner re-roles another member and the write lands — is pinned only at the policy function. A later gate tightened into "the actor must be the target" would leave every case on main green while the Owner silently loses the capability #1832 shipped (#1876). Test-only: no production change. --- core/test/project-role-authz.test.js | 26 +++++++++++++++++++ .../files/test/project-role-authz.test.js | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/core/test/project-role-authz.test.js b/core/test/project-role-authz.test.js index 8a11b991..7919236e 100644 --- a/core/test/project-role-authz.test.js +++ b/core/test/project-role-authz.test.js @@ -153,3 +153,29 @@ test('#1836: no endpoint the channel dispatches answers Unknown endpoint at the assert.equal(stranger.code, 'bot/not-found') assert.equal(stranger.message, 'Unknown endpoint no-such-endpoint') }) + +test('#1876: the Owner re-roles another member at the endpoint, and the write lands', async () => { + const { callAs, stop, home } = await start() + try { + const answer = await callAs('project-role', { id: 'project-p1', memberId: 'plain-1', role: 'maintainer' }, 'tok-owner') + assert.notEqual(answer.error?.code, 'bot/not-found', `the endpoint is not missing from authz: ${JSON.stringify(answer)}`) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.changed, true, JSON.stringify(answer)) + assert.ok(answer.value.roles.maintainers.includes('plain-1'), JSON.stringify(answer.value.roles)) + // The target's own session sees the new rank: `projects` answers the caller's own role, + // so the Owner's token cannot read it for someone else. + const read = await callAs('projects', {}, 'tok-plain') + assert.equal(read.ok, true, JSON.stringify(read)) + const listed = read.value.projects.find(project => project.id === 'project-p1') + assert.equal(listed.role, 'maintainer', JSON.stringify(listed)) + // The roles on disk are the ones the reply named, not only the reply. + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.deepEqual(saved.projects['project-p1'].roles, answer.value.roles) + // Back down, so the Owner's positive path is pinned in both directions of the switch. + const back = await callAs('project-role', { id: 'project-p1', memberId: 'plain-1', role: 'member' }, 'tok-owner') + assert.equal(back.ok, true, JSON.stringify(back)) + assert.equal(back.value.changed, true, JSON.stringify(back)) + const after = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.deepEqual(after.projects['project-p1'].roles, { maintainers: ['maint-1'], members: ['plain-1'] }) + } finally { await stop() } +}) diff --git a/patches/ds-bot/files/test/project-role-authz.test.js b/patches/ds-bot/files/test/project-role-authz.test.js index 8a11b991..7919236e 100644 --- a/patches/ds-bot/files/test/project-role-authz.test.js +++ b/patches/ds-bot/files/test/project-role-authz.test.js @@ -153,3 +153,29 @@ test('#1836: no endpoint the channel dispatches answers Unknown endpoint at the assert.equal(stranger.code, 'bot/not-found') assert.equal(stranger.message, 'Unknown endpoint no-such-endpoint') }) + +test('#1876: the Owner re-roles another member at the endpoint, and the write lands', async () => { + const { callAs, stop, home } = await start() + try { + const answer = await callAs('project-role', { id: 'project-p1', memberId: 'plain-1', role: 'maintainer' }, 'tok-owner') + assert.notEqual(answer.error?.code, 'bot/not-found', `the endpoint is not missing from authz: ${JSON.stringify(answer)}`) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.changed, true, JSON.stringify(answer)) + assert.ok(answer.value.roles.maintainers.includes('plain-1'), JSON.stringify(answer.value.roles)) + // The target's own session sees the new rank: `projects` answers the caller's own role, + // so the Owner's token cannot read it for someone else. + const read = await callAs('projects', {}, 'tok-plain') + assert.equal(read.ok, true, JSON.stringify(read)) + const listed = read.value.projects.find(project => project.id === 'project-p1') + assert.equal(listed.role, 'maintainer', JSON.stringify(listed)) + // The roles on disk are the ones the reply named, not only the reply. + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.deepEqual(saved.projects['project-p1'].roles, answer.value.roles) + // Back down, so the Owner's positive path is pinned in both directions of the switch. + const back = await callAs('project-role', { id: 'project-p1', memberId: 'plain-1', role: 'member' }, 'tok-owner') + assert.equal(back.ok, true, JSON.stringify(back)) + assert.equal(back.value.changed, true, JSON.stringify(back)) + const after = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.deepEqual(after.projects['project-p1'].roles, { maintainers: ['maint-1'], members: ['plain-1'] }) + } finally { await stop() } +})