source: Klonkt/test/add-to-hub.test.js@ 31680c3

main
Last change on this file since 31680c3 was 31680c3, checked in by Robin <roboburr@…>, 3 hours ago

[Add to HUB] on the Connect page — the click is the owner's yes

Putting a klonkt on the Klonkt Hub means the hub sends a Follow. With the
owner gate on (the default) that Follow then waits for the owner — the same
person who just asked for it. Without this they would sign up on the hub and
then have to approve their own sign-up on /connect.

The button POSTs to /connect/add-to-hub, which records a one-day invitation
and redirects to the hub's sign-up form with the site's handle filled in
(?add=). A Follow from the hub's host, signed by the hub itself, passes the
owner gate while that invitation holds. It is not used up on the first
Follow: a hub that re-sends after a timeout would otherwise land in the queue
for something already decided.

Narrow on purpose: only the hub's host, only signed by it, only for a day.
Anyone else still waits for the owner, and a WARD's guardians still decide —
their gate sits before this one and this path never reaches it.

If a request from the hub is already waiting (someone signed the klonkt up
earlier), the click is exactly the yes it waits for: it is accepted on the
spot and the button goes to the channel on the hub instead of the form. Once
the hub follows, /connect shows a confirmation instead of the button. Hidden
after a move, where the outgoing side is locked.

KLONKT_HUB_URL lets a self-hoster point the button at another hub; default
​https://hub.klonkt.com.

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

  • Property mode set to 100644
File size: 8.1 KB
Line 
1// [Add to HUB] (Robin, 30-9): je klonkt vanuit Klonkt op de hub zetten.
2//
3// De knop zelf is een doorverwijzing naar het aanmeldformulier van de hub.
4// Wat hier vast moet liggen is het stuk dat niet vanzelf spreekt: de klik
5// geldt als het ja van de eigenaar voor de Follow van de hub die daarop volgt.
6// Zonder dat moest de eigenaar straks zijn eigen aanmelding goedkeuren. En
7// even belangrijk: die uitzondering is SMAL -- alleen de hub, alleen kort,
8// en nooit langs de guardians van een ward.
9import { test, after } from 'node:test';
10import assert from 'node:assert/strict';
11
12process.env.DATABASE_PATH = ':memory:';
13process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
14// IP-literals: safeFetch slaat dan de DNS-lookup over en de stub vangt de rest.
15process.env.KLONKT_HUB_URL = 'https://203.0.113.77';
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const AP = (await import('../src/services/ActivityPubService.js')).default;
21const G = await import('../src/services/guardianship/index.js');
22const HubInvite = await import('../src/services/hub-invite.js');
23
24const HUB = 'https://203.0.113.77/ap/actor';
25const ANDER = 'https://198.51.100.9/ap/actor';
26
27db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES ('u1','u1','u1@t','x','god')").run();
28const site = (id, slug) => {
29 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, approve_followers) VALUES (?,?,?,?,1,1)').run(id, slug, slug, 'u1');
30 return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
31};
32
33// Verkeer naar onze eigen testapp gaat echt; al het andere (de hub, een
34// vreemde actor, een Accept naar een inbox) krijgt een nep-antwoord. Zonder dat
35// wacht een Accept op een testadres dat nergens heen leidt.
36const echteFetch = globalThis.fetch;
37globalThis.fetch = async (url, opts) => {
38 if (String(url).startsWith('http://127.0.0.1:')) return echteFetch(url, opts);
39 if (opts && opts.method === 'POST') return new Response(null, { status: 202 });
40 const id = String(url).split('#')[0];
41 return new Response(JSON.stringify({ id, type: 'Application', preferredUsername: 'x', inbox: `${id}/inbox` }),
42 { status: 200, headers: { 'content-type': 'application/activity+json' } });
43};
44after(() => { globalThis.fetch = echteFetch; });
45
46const follow = (slug, actor) => AP.handleInbox({
47 body: {
48 '@context': 'https://www.w3.org/ns/activitystreams',
49 id: `${actor}#follow-${slug}`, type: 'Follow', actor,
50 object: `https://klonkt.test/ap/users/${slug}`,
51 },
52 headers: {}, get: () => undefined, socket: {},
53}, slug, { id: actor });
54
55const volgt = (slug, actor) =>
56 db.prepare('SELECT COUNT(*) c FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, actor).c === 1;
57const wacht = (slug, actor) => G.follows.listForWard(slug).some((f) => f.follower_uri === actor);
58
59test('zonder de knop wacht de Follow van de hub gewoon op de eigenaar', async () => {
60 site('s1', 'zonder');
61 await follow('zonder', HUB);
62 assert.equal(volgt('zonder', HUB), false);
63 assert.equal(wacht('zonder', HUB), true, 'de poort doet wat hij deed');
64});
65
66test('na de knop komt de hub er meteen door — de klik WAS het ja', async () => {
67 const s = site('s2', 'met');
68 HubInvite.invite(s.id);
69 await follow('met', HUB);
70 assert.equal(volgt('met', HUB), true, 'geen eigen aanmelding meer goedkeuren');
71 assert.equal(wacht('met', HUB), false);
72 assert.equal(HubInvite.onHub('met'), true, 'en dan toont /connect een bevestiging in plaats van de knop');
73});
74
75test('de uitnodiging geldt alleen voor de hub, niet voor wie er toevallig bij komt', async () => {
76 const s = site('s3', 'smal');
77 HubInvite.invite(s.id);
78 await follow('smal', ANDER);
79 assert.equal(volgt('smal', ANDER), false);
80 assert.equal(wacht('smal', ANDER), true, 'iemand anders wacht gewoon op de eigenaar');
81});
82
83test('de uitnodiging loopt af', async () => {
84 const s = site('s4', 'oud');
85 db.prepare('UPDATE sites SET hub_invite_until = ? WHERE id = ?').run(Date.now() - 1000, s.id);
86 await follow('oud', HUB);
87 assert.equal(volgt('oud', HUB), false);
88 assert.equal(wacht('oud', HUB), true);
89});
90
91test('een opnieuw verstuurde Follow binnen de dag struikelt niet over de poort', async () => {
92 // Opgebruikt bij de eerste Follow zou een retry van de hub alsnog een
93 // wachtend verzoek geven voor iets dat al beslist was.
94 await follow('met', HUB);
95 assert.equal(wacht('met', HUB), false);
96});
97
98test('bij een WARD beslissen nog steeds de guardians', async () => {
99 const s = site('s5', 'ward');
100 db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at)
101 VALUES ('ward', 'ward', 'https://klonkt.test/ap/users/voogd', 'accepted', CURRENT_TIMESTAMP)`).run();
102 HubInvite.invite(s.id);
103 await follow('ward', HUB);
104 assert.equal(volgt('ward', HUB), false, 'de knop is geen sluiproute langs de guardians');
105});
106
107// ── de route achter de knop ──────────────────────────────────────────
108
109async function metApp(slug) {
110 const express = (await import('express')).default;
111 const router = (await import('../src/routes/posts.js')).default;
112 const app = express();
113 app.use(express.urlencoded({ extended: true }));
114 app.use((req, res, next) => {
115 req.session = { user: { id: 'u1', role: 'god', username: 'u1' } };
116 res.locals.site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
117 res.locals.siteUrlBase = '';
118 next();
119 });
120 app.use('/', router);
121 const server = app.listen(0);
122 server.unref();
123 return `http://127.0.0.1:${server.address().port}`;
124}
125const druk = async (slug) => fetch(`${await metApp(slug)}/connect/add-to-hub`, {
126 method: 'POST', redirect: 'manual',
127 headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
128 signal: AbortSignal.timeout(5000),
129});
130
131test('de knop stuurt naar het vooringevulde formulier en zet de uitnodiging', async () => {
132 const s = site('s6', 'knop');
133 const r = await druk('knop');
134 assert.equal(r.status, 303);
135 assert.equal(r.headers.get('location'), 'https://203.0.113.77/?add=knop%40klonkt.test');
136 assert.equal(HubInvite.isInvited('knop', HUB), true);
137 assert.ok(db.prepare('SELECT hub_invite_until FROM sites WHERE id = ?').get(s.id).hub_invite_until > Date.now());
138});
139
140test('wacht er al een verzoek van de hub, dan IS de klik het ja', async () => {
141 site('s7', 'wachtend');
142 G.follows.recordPending('wachtend', {
143 id: `${HUB}#follow-eerder-aangemeld`, follower: HUB, inbox: `${HUB}/inbox`, sharedInbox: null,
144 name: 'Hub', handle: '@hub@203.0.113.77', icon: null,
145 activity: { id: `${HUB}#follow-eerder-aangemeld`, type: 'Follow', actor: HUB, object: 'https://klonkt.test/ap/users/wachtend' },
146 quorum: 'owner',
147 });
148 const r = await druk('wachtend');
149 assert.equal(r.status, 303);
150 assert.equal(r.headers.get('location'), 'https://203.0.113.77/?klonkt=wachtend%40klonkt.test',
151 'niet nog eens langs het formulier: je staat er al, of bijna');
152 assert.equal(volgt('wachtend', HUB), true);
153 assert.equal(wacht('wachtend', HUB), false);
154});
155
156test('de Connect-pagina toont de knop, of de bevestiging als hij er al op staat', async () => {
157 const ejs = (await import('ejs')).default;
158 const basis = { t: (k) => k, avatar: (x) => x, connections: [], myGuardians: [], followRequests: [],
159 approveFollowers: true, movedTo: null, success: null, error: null, siteUrlBase: '', site: { slug: 'x' }, safeSite: {} };
160 const render = (hub, extra = {}) => ejs.renderFile('src/views/pages/connect.ejs', { ...basis, ...extra, hub });
161
162 const nog = await render({ url: 'https://hub.test', onHub: false, ward: false });
163 assert.match(nog, /action="\/connect\/add-to-hub"/, 'de knop');
164 assert.match(nog, /target="_blank"/, 'in een nieuw tabblad, zodat /connect blijft staan');
165
166 const al = await render({ url: 'https://hub.test', onHub: true, ward: false });
167 assert.doesNotMatch(al, /add-to-hub/, 'geen knop voor iets dat al gebeurd is');
168 assert.match(al, /tl\.hub_on/);
169
170 const verhuisd = await render({ url: 'https://hub.test', onHub: false, ward: false }, { movedTo: 'https://elders.test/ap/users/x' });
171 assert.doesNotMatch(verhuisd, /add-to-hub/, 'na een verhuizing staat de uitgaande kant op slot');
172});
Note: See TracBrowser for help on using the repository browser.