Index: test/add-to-hub.test.js
===================================================================
--- test/add-to-hub.test.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ test/add-to-hub.test.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
@@ -0,0 +1,172 @@
+// [Add to HUB] (Robin, 30-9): je klonkt vanuit Klonkt op de hub zetten.
+//
+// De knop zelf is een doorverwijzing naar het aanmeldformulier van de hub.
+// Wat hier vast moet liggen is het stuk dat niet vanzelf spreekt: de klik
+// geldt als het ja van de eigenaar voor de Follow van de hub die daarop volgt.
+// Zonder dat moest de eigenaar straks zijn eigen aanmelding goedkeuren. En
+// even belangrijk: die uitzondering is SMAL -- alleen de hub, alleen kort,
+// en nooit langs de guardians van een ward.
+import { test, after } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+// IP-literals: safeFetch slaat dan de DNS-lookup over en de stub vangt de rest.
+process.env.KLONKT_HUB_URL = 'https://203.0.113.77';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+const G = await import('../src/services/guardianship/index.js');
+const HubInvite = await import('../src/services/hub-invite.js');
+
+const HUB = 'https://203.0.113.77/ap/actor';
+const ANDER = 'https://198.51.100.9/ap/actor';
+
+db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES ('u1','u1','u1@t','x','god')").run();
+const site = (id, slug) => {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, approve_followers) VALUES (?,?,?,?,1,1)').run(id, slug, slug, 'u1');
+  return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
+};
+
+// Verkeer naar onze eigen testapp gaat echt; al het andere (de hub, een
+// vreemde actor, een Accept naar een inbox) krijgt een nep-antwoord. Zonder dat
+// wacht een Accept op een testadres dat nergens heen leidt.
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts) => {
+  if (String(url).startsWith('http://127.0.0.1:')) return echteFetch(url, opts);
+  if (opts && opts.method === 'POST') return new Response(null, { status: 202 });
+  const id = String(url).split('#')[0];
+  return new Response(JSON.stringify({ id, type: 'Application', preferredUsername: 'x', inbox: `${id}/inbox` }),
+    { status: 200, headers: { 'content-type': 'application/activity+json' } });
+};
+after(() => { globalThis.fetch = echteFetch; });
+
+const follow = (slug, actor) => AP.handleInbox({
+  body: {
+    '@context': 'https://www.w3.org/ns/activitystreams',
+    id: `${actor}#follow-${slug}`, type: 'Follow', actor,
+    object: `https://klonkt.test/ap/users/${slug}`,
+  },
+  headers: {}, get: () => undefined, socket: {},
+}, slug, { id: actor });
+
+const volgt = (slug, actor) =>
+  db.prepare('SELECT COUNT(*) c FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, actor).c === 1;
+const wacht = (slug, actor) => G.follows.listForWard(slug).some((f) => f.follower_uri === actor);
+
+test('zonder de knop wacht de Follow van de hub gewoon op de eigenaar', async () => {
+  site('s1', 'zonder');
+  await follow('zonder', HUB);
+  assert.equal(volgt('zonder', HUB), false);
+  assert.equal(wacht('zonder', HUB), true, 'de poort doet wat hij deed');
+});
+
+test('na de knop komt de hub er meteen door — de klik WAS het ja', async () => {
+  const s = site('s2', 'met');
+  HubInvite.invite(s.id);
+  await follow('met', HUB);
+  assert.equal(volgt('met', HUB), true, 'geen eigen aanmelding meer goedkeuren');
+  assert.equal(wacht('met', HUB), false);
+  assert.equal(HubInvite.onHub('met'), true, 'en dan toont /connect een bevestiging in plaats van de knop');
+});
+
+test('de uitnodiging geldt alleen voor de hub, niet voor wie er toevallig bij komt', async () => {
+  const s = site('s3', 'smal');
+  HubInvite.invite(s.id);
+  await follow('smal', ANDER);
+  assert.equal(volgt('smal', ANDER), false);
+  assert.equal(wacht('smal', ANDER), true, 'iemand anders wacht gewoon op de eigenaar');
+});
+
+test('de uitnodiging loopt af', async () => {
+  const s = site('s4', 'oud');
+  db.prepare('UPDATE sites SET hub_invite_until = ? WHERE id = ?').run(Date.now() - 1000, s.id);
+  await follow('oud', HUB);
+  assert.equal(volgt('oud', HUB), false);
+  assert.equal(wacht('oud', HUB), true);
+});
+
+test('een opnieuw verstuurde Follow binnen de dag struikelt niet over de poort', async () => {
+  // Opgebruikt bij de eerste Follow zou een retry van de hub alsnog een
+  // wachtend verzoek geven voor iets dat al beslist was.
+  await follow('met', HUB);
+  assert.equal(wacht('met', HUB), false);
+});
+
+test('bij een WARD beslissen nog steeds de guardians', async () => {
+  const s = site('s5', 'ward');
+  db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at)
+              VALUES ('ward', 'ward', 'https://klonkt.test/ap/users/voogd', 'accepted', CURRENT_TIMESTAMP)`).run();
+  HubInvite.invite(s.id);
+  await follow('ward', HUB);
+  assert.equal(volgt('ward', HUB), false, 'de knop is geen sluiproute langs de guardians');
+});
+
+// ── de route achter de knop ──────────────────────────────────────────
+
+async function metApp(slug) {
+  const express = (await import('express')).default;
+  const router = (await import('../src/routes/posts.js')).default;
+  const app = express();
+  app.use(express.urlencoded({ extended: true }));
+  app.use((req, res, next) => {
+    req.session = { user: { id: 'u1', role: 'god', username: 'u1' } };
+    res.locals.site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+    res.locals.siteUrlBase = '';
+    next();
+  });
+  app.use('/', router);
+  const server = app.listen(0);
+  server.unref();
+  return `http://127.0.0.1:${server.address().port}`;
+}
+const druk = async (slug) => fetch(`${await metApp(slug)}/connect/add-to-hub`, {
+  method: 'POST', redirect: 'manual',
+  headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
+  signal: AbortSignal.timeout(5000),
+});
+
+test('de knop stuurt naar het vooringevulde formulier en zet de uitnodiging', async () => {
+  const s = site('s6', 'knop');
+  const r = await druk('knop');
+  assert.equal(r.status, 303);
+  assert.equal(r.headers.get('location'), 'https://203.0.113.77/?add=knop%40klonkt.test');
+  assert.equal(HubInvite.isInvited('knop', HUB), true);
+  assert.ok(db.prepare('SELECT hub_invite_until FROM sites WHERE id = ?').get(s.id).hub_invite_until > Date.now());
+});
+
+test('wacht er al een verzoek van de hub, dan IS de klik het ja', async () => {
+  site('s7', 'wachtend');
+  G.follows.recordPending('wachtend', {
+    id: `${HUB}#follow-eerder-aangemeld`, follower: HUB, inbox: `${HUB}/inbox`, sharedInbox: null,
+    name: 'Hub', handle: '@hub@203.0.113.77', icon: null,
+    activity: { id: `${HUB}#follow-eerder-aangemeld`, type: 'Follow', actor: HUB, object: 'https://klonkt.test/ap/users/wachtend' },
+    quorum: 'owner',
+  });
+  const r = await druk('wachtend');
+  assert.equal(r.status, 303);
+  assert.equal(r.headers.get('location'), 'https://203.0.113.77/?klonkt=wachtend%40klonkt.test',
+    'niet nog eens langs het formulier: je staat er al, of bijna');
+  assert.equal(volgt('wachtend', HUB), true);
+  assert.equal(wacht('wachtend', HUB), false);
+});
+
+test('de Connect-pagina toont de knop, of de bevestiging als hij er al op staat', async () => {
+  const ejs = (await import('ejs')).default;
+  const basis = { t: (k) => k, avatar: (x) => x, connections: [], myGuardians: [], followRequests: [],
+    approveFollowers: true, movedTo: null, success: null, error: null, siteUrlBase: '', site: { slug: 'x' }, safeSite: {} };
+  const render = (hub, extra = {}) => ejs.renderFile('src/views/pages/connect.ejs', { ...basis, ...extra, hub });
+
+  const nog = await render({ url: 'https://hub.test', onHub: false, ward: false });
+  assert.match(nog, /action="\/connect\/add-to-hub"/, 'de knop');
+  assert.match(nog, /target="_blank"/, 'in een nieuw tabblad, zodat /connect blijft staan');
+
+  const al = await render({ url: 'https://hub.test', onHub: true, ward: false });
+  assert.doesNotMatch(al, /add-to-hub/, 'geen knop voor iets dat al gebeurd is');
+  assert.match(al, /tl\.hub_on/);
+
+  const verhuisd = await render({ url: 'https://hub.test', onHub: false, ward: false }, { movedTo: 'https://elders.test/ap/users/x' });
+  assert.doesNotMatch(verhuisd, /add-to-hub/, 'na een verhuizing staat de uitgaande kant op slot');
+});
