source: Klonkt/test/move-actor.test.js@ f434294

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

Account-verhuizingen: inkomende Move plus alsoKnownAs-aliassen (FEP-7628)

De ontvangende helft van accountverhuizingen, in twee delen die samen een
verhuizing NAAR Klonkt mogelijk maken (shaer-0j2, slice 1).

Deel 1: een account dat onze sites volgen verhuist. Op een geldige Move volgt
elke lokale volger automatisch mee naar het nieuwe adres, met behoud van de
eigen auto-boost-keuze. Geldigheid heeft twee onafhankelijke benen en beide
zijn verplicht: de ondertekenaar is zelf partij in de verhuizing (oude actor
in push-modus of nieuwe actor in pull-modus), en de nieuwe actor claimt de
oude identiteit in alsoKnownAs. Zonder het eerste kan elke ondertekende
vreemde onze follows ompointen; zonder het tweede kan wie een kant beheerst
de volgers van de andere kant kapen. Herbezorging is idempotent, een
geblokkeerde bestemming wordt geweigerd, en guardianship-relaties worden
bewust NIET geretarget: een guardian is een beveiligingsanker en verhuist
pas onder de regels van shaer-tge.

Deel 2: eigen aliassen instellen via Beheer, Sites, site bewerken. Een regel
per oud account, als @naam@server of als actor-URL. Handles resolven via
WebFinger op het moment van opslaan, zodat een typfout direct zichtbaar is
in plaats van pas bij een mislukkende Move op de oude server. De aliassen
verschijnen als alsoKnownAs op de publieke actor; dat is de claim die de
oude server controleert voor hij volgers hierheen verhuist.

Let op: FEP-7628 heeft status DRAFT. De vorm is sinds 2019 de facto
Mastodon-standaard, maar de spec kan nog wijzigen.

Changed files:
src/services/ActivityPubService.js

  • Move toegevoegd aan de GATED-lijst (handtekening verplicht)
  • dispatch-blok voor Move, voor de Follow-afhandeling (geen race met de re-Follow-golf van de nieuwe actor)
  • handleMoveInbox met injecteerbare afhankelijkheden voor de tests
  • alsoKnownAs en movedTo als JSON-LD-termen in de context
  • buildActor publiceert alsoKnownAs uit sites.ap_aliases, eigen id en niet-URLs gefilterd

src/config/database.js

  • kolom sites.ap_aliases (JSON-array van actor-URIs)

src/routes/admin-sites.js

  • parseApAliases: max 5, dedupe, WebFinger-resolutie bij opslaan, fout met de betreffende regel terug naar het formulier
  • save-route asynchroon; ap_aliases in de UPDATE
  • actor-Update naar volgers wanneer de lijst wijzigt (best-effort)

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

  • veld Fediverse-aliassen onder de profiel-links

src/services/i18n.js

  • asite.aliases en asite.aliases_hint in nl, en en de

New file:
test/move-actor.test.js

  • 11 tests: derde-partij-weigering, ontbrekend aliasbewijs, push- en pull-modus, idempotentie, al-volgend overslaan, geblokkeerd doel, misvormde activiteiten, ongesigneerd, actor-publicatie met filtering

-robo
Co-Authored-By: Claude Fable 5 <noreply@…>

  • Property mode set to 100644
File size: 6.7 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});
Note: See TracBrowser for help on using the repository browser.