Index: test/move-actor.test.js
===================================================================
--- test/move-actor.test.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
+++ test/move-actor.test.js	(revision ccaa5301c212451f2e6b7c28c49282055278e961)
@@ -0,0 +1,125 @@
+// FEP-7628 (Move actor, DRAFT) — the inbound half: an account our sites follow
+// announces a move, and our follows travel along. All network legs are
+// injected; the DB is in-memory, like the other AP tests.
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const { handleMoveInbox, buildActor } = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+for (const [id, slug] of [['s1', 'radio'], ['s2', 'blog']]) {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
+}
+
+const OLD = 'https://oldhome.example/users/dj';
+const NEW = 'https://newhome.example/users/dj';
+const STRANGER = 'https://elsewhere.example/users/nosy';
+
+// The target actor doc the mover controls; the alsoKnownAs back-reference is
+// the proof both ends belong to the same person.
+const targetActor = (aka = [OLD]) => ({ id: NEW, type: 'Person', inbox: `${NEW}/inbox`, alsoKnownAs: aka });
+const move = (overrides = {}) => ({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${OLD}#move-1`, type: 'Move', actor: OLD, object: OLD, target: NEW, ...overrides });
+
+// Stubs mirror the DB effect of the real followActor/unfollowActor, so the
+// handler's row bookkeeping is exercised without keys or delivery queues.
+let calls;
+const deps = (aka) => ({
+  fetchActorFn: async () => targetActor(aka),
+  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); },
+  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); },
+});
+
+beforeEach(() => {
+  calls = { unfollow: [], follow: [] };
+  db.prepare('DELETE FROM ap_following').run();
+  db.prepare('DELETE FROM ap_blocks').run();
+  db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('radio', OLD, 'accepted', 1);
+  db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('blog', OLD, 'accepted', 0);
+});
+
+const following = (slug) => db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY actor_uri').all(slug);
+
+test('a third party cannot narrate someone else\'s move', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: STRANGER, ...deps() });
+  assert.equal(res, 401);
+  assert.equal(following('radio')[0].actor_uri, OLD);
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('without the alsoKnownAs back-reference nothing moves', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps([]) });
+  assert.equal(res, 202); // declined, not errored: the sender may be retrying in good faith
+  assert.equal(following('radio')[0].actor_uri, OLD);
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('push mode: both sites re-follow, each keeping its own auto-boost', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.equal(res, 202);
+  assert.deepEqual(calls.unfollow.sort(), [['blog', OLD], ['radio', OLD]]);
+  assert.deepEqual(calls.follow.sort(), [['blog', NEW, false], ['radio', NEW, true]]);
+  assert.equal(following('radio')[0].actor_uri, NEW);
+  assert.equal(following('radio')[0].auto_boost, 1);
+  assert.equal(following('blog')[0].auto_boost, 0);
+});
+
+test('pull mode: the NEW actor may announce the move itself', async () => {
+  const res = await handleMoveInbox(move({ actor: NEW, id: `${NEW}#move-1` }), { verifiedActor: NEW, ...deps() });
+  assert.equal(res, 202);
+  assert.equal(following('radio')[0].actor_uri, NEW);
+});
+
+test('redelivery is idempotent: the second Move finds nothing to do', async () => {
+  await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  calls = { unfollow: [], follow: [] };
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.equal(res, 202);
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('a site already following the target is not re-followed, old row still cleaned', async () => {
+  db.prepare('INSERT INTO ap_following (slug, actor_uri, status, auto_boost) VALUES (?,?,?,?)').run('radio', NEW, 'accepted', 0);
+  await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.deepEqual(calls.follow, [['blog', NEW, false]]); // radio skipped
+  assert.equal(following('radio').length, 1);             // OLD gone, NEW kept
+  assert.equal(following('radio')[0].actor_uri, NEW);
+});
+
+test('a blocked destination is declined: no door opens to a blocked house', async () => {
+  db.prepare('INSERT INTO ap_blocks (slug, target, kind) VALUES (?,?,?)').run('radio', NEW, 'actor');
+  const res = await handleMoveInbox(move(), { verifiedActor: OLD, ...deps() });
+  assert.equal(res, 202);
+  assert.equal(following('radio')[0].actor_uri, OLD); // untouched
+  assert.equal(calls.follow.length + calls.unfollow.length, 0);
+});
+
+test('malformed moves are 400: missing target, or object === target', async () => {
+  assert.equal(await handleMoveInbox(move({ target: undefined }), { verifiedActor: OLD, ...deps() }), 400);
+  assert.equal(await handleMoveInbox(move({ target: OLD }), { verifiedActor: OLD, ...deps() }), 400);
+});
+
+test('an unsigned Move is refused before anything is read', async () => {
+  const res = await handleMoveInbox(move(), { verifiedActor: null, ...deps() });
+  assert.equal(res, 401);
+  assert.equal(following('radio')[0].actor_uri, OLD);
+});
+
+test('the actor publishes alsoKnownAs from ap_aliases; the own id is filtered out', () => {
+  db.prepare("UPDATE sites SET ap_aliases = ? WHERE slug = 'radio'")
+    .run(JSON.stringify(['https://oldhome.example/users/dj', 'https://test.example/ap/users/radio', 42]));
+  const site = db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
+  const actor = buildActor('https://test.example', site);
+  assert.deepEqual(actor.alsoKnownAs, ['https://oldhome.example/users/dj']);
+});
+
+test('no aliases set, no alsoKnownAs on the actor', () => {
+  db.prepare("UPDATE sites SET ap_aliases = NULL WHERE slug = 'radio'").run();
+  const site = db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get();
+  assert.equal('alsoKnownAs' in buildActor('https://test.example', site), false);
+});
