source: Klonkt/test/move-actor.test.js@ 0ca7e9a4

main
Last change on this file since 0ca7e9a4 was 0ca7e9a4, checked in by Robin <roboburr@…>, 6 weeks ago

Account-verhuizingen slice 2: de uitgaande Move (FEP-7628)

De vertrekkende helft van shaer-0j2: deze Klonkt als het oude huis. Twee
eisen voordat er iets de deur uit gaat. Een: geen guardians; een warded
account verhuizen zonder de guardianship te hertargeten zou het vangnet
van het kind stil breken, dus tot shaer-tge er is weigert een bewaakt
account met een heldere melding. Twee: het nieuwe profiel claimt dit
adres in alsoKnownAs, dezelfde back-reference die elke ontvangende
server (onze slice 1 incluis) eist; zonder die claim is de Move overal
dood bij aankomst.

De Move gaat duurzaam (retry-queue) naar elke volger-inbox; hun servers
doen de re-follow. moved_to wordt vastgelegd; het serveren ervan op de
actor en het beleid van de oude site zijn slice 3. In Beheer > Sites >
bewerken zit de aankondiging als eigen formulier met bevestiging, nooit
als bijeffect van Opslaan: een verhuizing is een deur die je achter je
dichttrekt.

Changed files:
src/services/ActivityPubService.js

  • moveAccount(site, target, {fetchActorFn, deliverFn}): guardian- weigering, resolutie (URL/handle), back-reference-check, moved_to, Move naar alle volger-inboxen (shared inbox de-dupe)

src/config/database.js

  • sites.moved_to (TEXT)

src/routes/admin-sites.js

  • POST /:slug/move met per-fout een heldere redirect-melding

src/views/pages/admin-site-edit.ejs

  • Verhuizen-sectie (eigen form + confirm), toont moved_to

src/services/i18n.js

  • asite.move* in nl/en/de

test/move-actor.test.js

  • guarded weigert (niets vastgelegd), zonder back-reference weigert, happy path: moved_to + een Move per volger-inbox (shared voorop)

remarks: gebouwd op vps/main (f434294) in een worktree, los van de
lopende boost-toggle-sessie in de hoofd-clone. Suite op 370 groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 9.8 KB
Line 
1// FEP-7628 (Move actor, DRAFT) — the inbound half: an account our sites follow
2// announces a move, and our follows travel along. All network legs are
3// injected; the DB is in-memory, like the other AP tests.
4import { test, beforeEach } from 'node:test';
5import assert from 'node:assert/strict';
6
7process.env.DATABASE_PATH = ':memory:';
8process.env.PUBLIC_BASE_URL = 'https://test.example';
9
10const dbMod = await import('../src/config/database.js');
11const db = dbMod.default;
12dbMod.initializeDatabase();
13const { handleMoveInbox, buildActor } = await import('../src/services/ActivityPubService.js');
14
15db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
16for (const [id, slug] of [['s1', 'radio'], ['s2', 'blog']]) {
17 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
18}
19
20const OLD = 'https://oldhome.example/users/dj';
21const NEW = 'https://newhome.example/users/dj';
22const STRANGER = 'https://elsewhere.example/users/nosy';
23
24// The target actor doc the mover controls; the alsoKnownAs back-reference is
25// the proof both ends belong to the same person.
26const targetActor = (aka = [OLD]) => ({ id: NEW, type: 'Person', inbox: `${NEW}/inbox`, alsoKnownAs: aka });
27const move = (overrides = {}) => ({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${OLD}#move-1`, type: 'Move', actor: OLD, object: OLD, target: NEW, ...overrides });
28
29// Stubs mirror the DB effect of the real followActor/unfollowActor, so the
30// handler's row bookkeeping is exercised without keys or delivery queues.
31let calls;
32const deps = (aka) => ({
33 fetchActorFn: async () => targetActor(aka),
34 unfollowFn: async (site, uri) => { calls.unfollow.push([site.slug, uri]); db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?').run(site.slug, uri); },
35 followFn: async (site, uri, autoBoost) => { calls.follow.push([site.slug, uri, autoBoost]); db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run(site.slug, uri, 'pending', autoBoost ? 1 : 0); },
36});
37
38beforeEach(() => {
39 calls = { unfollow: [], follow: [] };
40 db.prepare('DELETE FROM ap_following').run();
41 db.prepare('DELETE FROM ap_blocks').run();
42 db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('radio', OLD, 'accepted', 1);
43 db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('blog', OLD, 'accepted', 0);
44});
45
46const following = (slug) => db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY actor_uri').all(slug);
47
48test('a third party cannot narrate someone else\'s move', async () => {
49 const res = await handleMoveInbox(move(), { verifiedActor: STRANGER, ...deps() });
50 assert.equal(res, 401);
51 assert.equal(following('radio')[0].actor_uri, OLD);
52 assert.equal(calls.follow.length + calls.unfollow.length, 0);
53});
54
55test('without the alsoKnownAs back-reference nothing moves', async () => {
56 const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps([]) });
57 assert.equal(res, 202); // declined, not errored: the sender may be retrying in good faith
58 assert.equal(following('radio')[0].actor_uri, OLD);
59 assert.equal(calls.follow.length + calls.unfollow.length, 0);
60});
61
62test('push mode: both sites re-follow, each keeping its own auto-boost', async () => {
63 const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
64 assert.equal(res, 202);
65 assert.deepEqual(calls.unfollow.sort(), [['blog', OLD], ['radio', OLD]]);
66 assert.deepEqual(calls.follow.sort(), [['blog', NEW, false], ['radio', NEW, true]]);
67 assert.equal(following('radio')[0].actor_uri, NEW);
68 assert.equal(following('radio')[0].auto_boost, 1);
69 assert.equal(following('blog')[0].auto_boost, 0);
70});
71
72test('pull mode: the NEW actor may announce the move itself', async () => {
73 const res = await handleMoveInbox(move({ actor: NEW, id: `${NEW}#move-1` }), { verifiedActor: NEW, ...deps() });
74 assert.equal(res, 202);
75 assert.equal(following('radio')[0].actor_uri, NEW);
76});
77
78test('redelivery is idempotent: the second Move finds nothing to do', async () => {
79 await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
80 calls = { unfollow: [], follow: [] };
81 const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
82 assert.equal(res, 202);
83 assert.equal(calls.follow.length + calls.unfollow.length, 0);
84});
85
86test('a site already following the target is not re-followed, old row still cleaned', async () => {
87 db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('radio', NEW, 'accepted', 0);
88 await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
89 assert.deepEqual(calls.follow, [['blog', NEW, false]]); // radio skipped
90 assert.equal(following('radio').length, 1); // OLD gone, NEW kept
91 assert.equal(following('radio')[0].actor_uri, NEW);
92});
93
94test('a blocked destination is declined: no door opens to a blocked house', async () => {
95 db.prepare('INSERT INTO ap_blocks (slug, target, kind) VALUES (?,?,?)').run('radio', NEW, 'actor');
96 const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
97 assert.equal(res, 202);
98 assert.equal(following('radio')[0].actor_uri, OLD); // untouched
99 assert.equal(calls.follow.length + calls.unfollow.length, 0);
100});
101
102test('malformed moves are 400: missing target, or object === target', async () => {
103 assert.equal(await handleMoveInbox(move({ target: undefined }), { verifiedActor: OLD, ...deps() }), 400);
104 assert.equal(await handleMoveInbox(move({ target: OLD }), { verifiedActor: OLD, ...deps() }), 400);
105});
106
107test('an unsigned Move is refused before anything is read', async () => {
108 const res = await handleMoveInbox(move(), { verifiedActor: null, ...deps() });
109 assert.equal(res, 401);
110 assert.equal(following('radio')[0].actor_uri, OLD);
111});
112
113test('the actor publishes alsoKnownAs from ap_aliases; the own id is filtered out', () => {
114 db.prepare("UPDATE sites SET ap_aliases = ? WHERE slug = 'radio'")
115 .run(JSON.stringify(['https://oldhome.example/users/dj', 'https://test.example/ap/users/radio', 42]));
116 const site = db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
117 const actor = buildActor('https://test.example', site);
118 assert.deepEqual(actor.alsoKnownAs, ['https://oldhome.example/users/dj']);
119});
120
121test('no aliases set, no alsoKnownAs on the actor', () => {
122 db.prepare("UPDATE sites SET ap_aliases = NULL WHERE slug = 'radio'").run();
123 const site = db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
124 assert.equal('alsoKnownAs' in buildActor('https://test.example', site), false);
125});
126
127// ── Slice 2: the OUTGOING half ─────────────────────────────────────
128// This Klonkt as the old home. A guarded account refuses (shaer-tge), a
129// target without the alsoKnownAs back-reference refuses, and the happy path
130// records moved_to and delivers ONE Move to every follower inbox.
131const { moveAccount } = await import('../src/services/ActivityPubService.js');
132const ME_RADIO = 'https://test.example/ap/users/radio';
133const radioSite = () => db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
134
135test('a guarded account refuses to move (shaer-tge)', async () => {
136 db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id)
137 VALUES ('blog', 'ward', 'https://oma.example/u/oma', 'accepted', 'o9')`).run();
138 const blogSite = db.prepare("SELECT * FROM sites WHERE slug = 'blog'").get();
139 const r = await moveAccount(blogSite, 'https://elders.example/users/nieuw', {
140 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: ['https://test.example/ap/users/blog'] }),
141 });
142 assert.equal(r.error, 'guarded_account');
143 assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'blog'").get().moved_to, null, 'nothing recorded');
144});
145
146test('without the back-reference the move refuses', async () => {
147 const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', {
148 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: ['https://iemand-anders.example/x'] }),
149 });
150 assert.equal(r.error, 'no_backreference');
151 assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, null);
152});
153
154test('the happy path: moved_to recorded, one Move to every follower inbox', async () => {
155 db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES ('radio', 'https://a.example/u/a', 'https://a.example/inbox')").run();
156 db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox, shared_inbox) VALUES ('radio', 'https://b.example/u/b', 'https://b.example/inbox', 'https://b.example/shared')").run();
157 const delivered = [];
158 const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', {
159 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: [ME_RADIO] }),
160 deliverFn: async (slug, inbox, activity) => { delivered.push({ inbox, activity }); },
161 });
162 assert.equal(r.ok, true);
163 assert.equal(r.target, 'https://elders.example/users/nieuw');
164 assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, 'https://elders.example/users/nieuw');
165 assert.equal(delivered.length, 2, 'both follower inboxes');
166 for (const d of delivered) {
167 assert.equal(d.activity.type, 'Move');
168 assert.equal(d.activity.object, ME_RADIO);
169 assert.equal(d.activity.target, 'https://elders.example/users/nieuw');
170 }
171 assert.ok(delivered.some((d) => d.inbox === 'https://b.example/shared'), 'shared inbox preferred');
172});
Note: See TracBrowser for help on using the repository browser.