source: Klonkt/test/move-actor.test.js@ 1d5ffc0

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

Account-verhuizingen slice 3: het wegwijsbord (movedTo + homepage-link)

Het sluitstuk van shaer-0j2's federatiekant. De oude Klonkt blijft
staan en ZEGT waar hij heen ging, op twee plekken.

Voor servers: het actor-document draagt movedTo zodra moved_to gezet is
(FEP-7628: een verhuisd account moet dat publiceren en geldt als
inactief). Dat is precies waarom de oude actor online blijft: wie de
Move-activity miste (server offline, latere bezoeker) leert de
verhuizing alsnog door ons op te halen. Een kolomwaarde die geen URL is
haalt de draad nooit.

Voor mensen: de homepage opent met een groot wegwijsbord boven de feed,
met de handle van het nieuwe adres als grote link. Boven de posts met
opzet: niemand hoort een dode tijdlijn door te scrollen om te ontdekken
dat we weg zijn.

En de verhuizing zelf vertelt het nu twee keer: naast de Move gaat er
een Update van de actor naar elke volger-inbox, gebouwd uit de VERSE
rij, zodat het bord ook staat als de Move onderweg verdwijnt (FEP-7628
SHOULD).

Changed files:
src/services/ActivityPubService.js

  • buildActor: movedTo bij een geldige moved_to
  • moveAccount: Update(actor) naast de Move, per inbox

src/routes/posts.js

  • home: movedTo + leesbaar label (actorDisplay) naar de view

src/views/pages/home.ejs

  • wegwijsbord boven de feed, typeof-guard voor deploy-races

src/assets/css/style.css

  • .moved-banner: accentrand, grote link, tekst breekt netjes af

src/services/i18n.js

  • phome.moved_lead/_hint in nl/en/de

test/move-actor.test.js

  • movedTo aan/uit/rommel; Update draagt het bord naast de Move
  • slice-2-test telt nu Moves apart (Update rijdt mee)

remarks: banner visueel gecontroleerd (mobiel en desktop) door het echte
templateblok met de echte CSS te renderen; suite 372 groen.

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

  • Property mode set to 100644
File size: 11.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 // Slice 3 added the Update(actor) alongside it, so count the Moves.
166 const moves = delivered.filter((d) => d.activity.type === 'Move');
167 assert.equal(moves.length, 2, 'one Move per follower inbox');
168 for (const d of moves) {
169 assert.equal(d.activity.object, ME_RADIO);
170 assert.equal(d.activity.target, 'https://elders.example/users/nieuw');
171 }
172 assert.ok(moves.some((d) => d.inbox === 'https://b.example/shared'), 'shared inbox preferred');
173});
174
175// ── Slice 3: the signpost ──────────────────────────────────────────
176// The old actor stays online and SAYS where it went (FEP-7628 movedTo), and
177// the followers are told with an Update alongside the Move.
178
179test('a moved account carries movedTo on its actor; an unmoved one does not', () => {
180 const fresh = db.prepare("SELECT * FROM sites WHERE slug = 'blog'").get();
181 assert.equal('movedTo' in buildActor('https://test.example', fresh), false, 'no move, no signpost');
182 const moved = { ...fresh, moved_to: 'https://elders.example/users/nieuw' };
183 assert.equal(buildActor('https://test.example', moved).movedTo, 'https://elders.example/users/nieuw');
184 // Junk in the column never reaches the wire.
185 assert.equal('movedTo' in buildActor('https://test.example', { ...fresh, moved_to: 'niet-een-url' }), false);
186});
187
188test('the move tells the followers twice: an Update carrying movedTo, and the Move', async () => {
189 db.prepare("DELETE FROM ap_followers WHERE slug = 'radio'").run();
190 db.prepare("UPDATE sites SET moved_to = NULL WHERE slug = 'radio'").run();
191 db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES ('radio', 'https://c.example/u/c', 'https://c.example/inbox')").run();
192 const sent = [];
193 const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', {
194 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: [ME_RADIO] }),
195 deliverFn: async (slug, inbox, activity) => { sent.push(activity); },
196 });
197 assert.equal(r.ok, true);
198 const update = sent.find((a) => a.type === 'Update');
199 const move = sent.find((a) => a.type === 'Move');
200 assert.ok(update, 'an Update is sent');
201 assert.ok(move, 'the Move is still sent');
202 assert.equal(update.object.movedTo, 'https://elders.example/users/nieuw', 'the Update carries the signpost');
203 assert.equal(update.object.id, ME_RADIO, 'and it is OUR actor being updated');
204});
Note: See TracBrowser for help on using the repository browser.