@bongos/core 1.24.5 → 1.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +70 -45
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
- package/docs/api/openapi.json +110 -5
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +36 -32
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +63 -55
- package/migrations/core_286_credit_log_no_spend.sql +128 -0
- package/modules/copy-desk/auto-close.js +5 -3
- package/modules/copy-desk/page-status.js +7 -4
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
- package/modules/lifecycle/routes/claims.js +7 -1
- package/modules/platform-identity/account-logins.js +131 -0
- package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
- package/modules/platform-identity/routes/profile.js +29 -1
- package/modules/platform-identity/routes/sso.js +25 -3
- package/modules/public-landing/public/account.html +114 -0
- package/modules/public-landing/public/account.states.json +1 -0
- package/modules/ui-design/kit/serve.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/auth-github.js +24 -7
- package/src/bongos/routes/auth-google.js +71 -5
- package/src/bongos/routes/auth-link.js +132 -0
- package/src/bongos/routes.js +2 -0
- package/src/bongos/sign-in-pages.js +20 -1
- package/src/module-api.js +1 -1
- package/tests/account_linking.mjs +289 -0
- package/tests/account_logins_db.mjs +56 -0
- package/tests/claims_batch_routes.mjs +79 -12
- package/tests/copy_desk_page_release.mjs +20 -0
- package/tests/copy_stuck_page_tweaks.mjs +2 -2
- package/tests/credit_log_no_spend_db.mjs +160 -0
- package/tests/effective_visibility_predicate.mjs +8 -0
- package/tests/hub_account_page.mjs +91 -5
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// tests/credit_log_no_spend_db.mjs — task 1004988 (goal 1000092; ADR 0385
|
|
2
|
+
// decision 2, ADR 0387). Real DB.
|
|
3
|
+
//
|
|
4
|
+
// Builds credit_log in a throwaway schema, applies the REAL migration
|
|
5
|
+
// (migrations/core_286_credit_log_no_spend.sql), and proves the DATABASE refuses a row that spends,
|
|
6
|
+
// redeems or transfers credits, while every existing reversal path still books:
|
|
7
|
+
//
|
|
8
|
+
// - task.confirmed_reversal — the only reversal path that exists today: the
|
|
9
|
+
// ADR 0120 clawback, migrations/core_194_confirmed_backlog_clawback.sql. It is
|
|
10
|
+
// re-run here, verbatim, AFTER the trigger, exactly as the integration suite's
|
|
11
|
+
// clawback_confirmed_backlog test re-runs it on a fully migrated database.
|
|
12
|
+
// - reversal:<id> — the general shape the trigger defines for any future one.
|
|
13
|
+
//
|
|
14
|
+
// modules/economy itself writes no negative row today (every writer there books
|
|
15
|
+
// earnings, and the descent and idea writers drop a non-positive delta), so the
|
|
16
|
+
// economy writers are covered by the "positive and zero rows are untouched"
|
|
17
|
+
// cases rather than by a reversal of their own.
|
|
18
|
+
//
|
|
19
|
+
// The probe the task asks for is built in: the last block DROPS the trigger and
|
|
20
|
+
// shows the same spend row is then admitted, so a test that kept passing without
|
|
21
|
+
// the trigger would fail that block.
|
|
22
|
+
//
|
|
23
|
+
// Self-skips without a reachable Postgres.
|
|
24
|
+
// Run: DATABASE_URL=... node tests/credit_log_no_spend_db.mjs
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Pool } = require('pg');
|
|
32
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const MIGRATION = 'core_286_credit_log_no_spend.sql';
|
|
34
|
+
|
|
35
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
36
|
+
let client;
|
|
37
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
38
|
+
console.log(`credit_log_no_spend_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
39
|
+
await pgPool.end().catch(() => {});
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const SCHEMA = `t1004988_${process.pid}`;
|
|
43
|
+
let pass = 0;
|
|
44
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
45
|
+
const refused = async (sql, params, m) => {
|
|
46
|
+
await client.query('SAVEPOINT s');
|
|
47
|
+
let err = null;
|
|
48
|
+
try { await client.query(sql, params); } catch (e) { err = e; }
|
|
49
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
50
|
+
ok(err && err.code === '23514', `${m}${err ? '' : ' (it was admitted)'}`);
|
|
51
|
+
};
|
|
52
|
+
const ins = (builder, task, delta, reason) => client.query(
|
|
53
|
+
'INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES ($1, $2, $3, $4) RETURNING id',
|
|
54
|
+
[builder, task, delta, reason],
|
|
55
|
+
).then((r) => r.rows[0].id);
|
|
56
|
+
const balance = async (b) => Number((await client.query('SELECT total_credits FROM builders WHERE id = $1', [b])).rows[0].total_credits);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
60
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
61
|
+
// The columns credit_log and its balance trigger carry (migrations/003_pms.sql
|
|
62
|
+
// plus the later description/session columns), and the task columns core_194
|
|
63
|
+
// reads and writes.
|
|
64
|
+
await client.query(`CREATE TABLE builders (id bigserial PRIMARY KEY, total_credits integer NOT NULL DEFAULT 0)`);
|
|
65
|
+
await client.query(`CREATE TABLE tasks (id bigserial PRIMARY KEY, status text NOT NULL,
|
|
66
|
+
needs_rebase boolean NOT NULL DEFAULT false, needs_rebase_reason text, needs_rebase_at timestamptz, updated_at timestamptz)`);
|
|
67
|
+
await client.query(`CREATE TABLE credit_log (id bigserial PRIMARY KEY, builder_id bigint NOT NULL REFERENCES builders(id),
|
|
68
|
+
task_id bigint REFERENCES tasks(id), delta integer NOT NULL, reason text NOT NULL, description text,
|
|
69
|
+
session_id text, recorded_at timestamptz NOT NULL DEFAULT now())`);
|
|
70
|
+
await client.query(`CREATE FUNCTION pms_apply_credit() RETURNS trigger AS $$
|
|
71
|
+
BEGIN UPDATE builders SET total_credits = total_credits + NEW.delta WHERE id = NEW.builder_id; RETURN NEW; END;
|
|
72
|
+
$$ LANGUAGE plpgsql`);
|
|
73
|
+
await client.query('CREATE TRIGGER trg_credit_log_apply AFTER INSERT ON credit_log FOR EACH ROW EXECUTE FUNCTION pms_apply_credit()');
|
|
74
|
+
await client.query(readFileSync(join(here, '..', 'migrations', MIGRATION), 'utf8'));
|
|
75
|
+
|
|
76
|
+
await client.query('INSERT INTO builders DEFAULT VALUES'); // 1
|
|
77
|
+
await client.query('INSERT INTO builders DEFAULT VALUES'); // 2
|
|
78
|
+
await client.query('INSERT INTO builders DEFAULT VALUES'); // 3
|
|
79
|
+
await client.query("INSERT INTO tasks (status) VALUES ('confirmed'), ('shipped')"); // 1, 2
|
|
80
|
+
await client.query('BEGIN');
|
|
81
|
+
|
|
82
|
+
// Earning and audit rows are untouched.
|
|
83
|
+
const earned = await ins(1, 2, 30, 'task.shipped');
|
|
84
|
+
await ins(1, null, 12, 'session.token_reward');
|
|
85
|
+
await ins(1, null, 0, 'rank_change');
|
|
86
|
+
ok(await balance(1) === 42, 'positive and zero rows book as before');
|
|
87
|
+
// Every reason the economy writers book today (modules/economy/credits.js and
|
|
88
|
+
// achievements.js). All are positive; none may be caught by the spend names.
|
|
89
|
+
const ECONOMY_REASONS = [
|
|
90
|
+
'task.shipped', 'task.confirmed', 'session.token_reward', 'idea_promotion_bonus',
|
|
91
|
+
'ship.net_negative', 'tweak.page_approved:2', 'idea.credit.task_ship:7:2',
|
|
92
|
+
'idea.credit.full_pass:7', 'idea.credit.governance_pass:7', 'achievement.first-ship',
|
|
93
|
+
];
|
|
94
|
+
for (const reason of ECONOMY_REASONS) await ins(3, null, 1, reason);
|
|
95
|
+
ok(await balance(3) === ECONOMY_REASONS.length, `every economy writer's reason still books (${ECONOMY_REASONS.join(', ')})`);
|
|
96
|
+
|
|
97
|
+
// Spend, redemption, transfer: refused.
|
|
98
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['spend'], 'a raw negative spend row is refused');
|
|
99
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['redemption'], 'a raw negative redemption row is refused');
|
|
100
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['module.purchase'], 'any other negative row is refused');
|
|
101
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, 5, $1)', ['transfer.in'], 'the positive half of a transfer is refused too');
|
|
102
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 0, $1)', ['redemption'], 'a zero-value redemption row is refused');
|
|
103
|
+
ok(await balance(1) === 42, 'no refused row moved the balance');
|
|
104
|
+
// The refused names match as whole words, so a reason that only starts with
|
|
105
|
+
// the letters still books.
|
|
106
|
+
await ins(3, null, 1, 'achievement.spender-of-the-month');
|
|
107
|
+
await ins(3, null, 1, 'spender-of-the-month');
|
|
108
|
+
ok(await balance(3) === ECONOMY_REASONS.length + 2, 'a reason that merely starts with "spend" is not refused');
|
|
109
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 1, $1)', ['spend:7'], 'spend:<x> is refused');
|
|
110
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 1, $1)', ['Transfer-out'], 'and the match ignores case');
|
|
111
|
+
|
|
112
|
+
// reversal:<id> — exists, same builder, positive, never more than booked.
|
|
113
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['reversal:999999'], 'a reversal naming no row is refused');
|
|
114
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, -5, $1)', [`reversal:${earned}`], 'a reversal of another builder\'s row is refused');
|
|
115
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -31, $1)', [`reversal:${earned}`], 'a reversal larger than the row is refused');
|
|
116
|
+
await ins(1, 2, -20, `reversal:${earned}`);
|
|
117
|
+
await ins(1, 2, -10, `reversal:${earned}`);
|
|
118
|
+
ok(await balance(1) === 12, 'reversal:<id> books, in parts, up to the whole row');
|
|
119
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -1, $1)', [`reversal:${earned}`], 'a reversal past the row\'s total is refused');
|
|
120
|
+
const audit = (await client.query("SELECT id FROM credit_log WHERE reason = 'rank_change'")).rows[0].id;
|
|
121
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -1, $1)', [`reversal:${audit}`], 'a zero row cannot be reversed');
|
|
122
|
+
|
|
123
|
+
// task.confirmed_reversal — the ADR 0120 clawback, re-run verbatim.
|
|
124
|
+
await ins(2, 1, 15, 'task.confirmed');
|
|
125
|
+
const core194 = readFileSync(join(here, '..', 'migrations', 'core_194_confirmed_backlog_clawback.sql'), 'utf8')
|
|
126
|
+
.replace(/^\s*(BEGIN|COMMIT);\s*$/gim, ''); // already inside this test's transaction
|
|
127
|
+
await client.query(core194);
|
|
128
|
+
ok(await balance(2) === 0, 'core_194 (task.confirmed_reversal) still reverses a confirmed task\'s credit');
|
|
129
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, 1, -1, $1)', ['task.confirmed_reversal'], 'that reversal cannot go past what was booked');
|
|
130
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, 2, -1, $1)', ['task.confirmed_reversal'], 'nor reverse a task that booked nothing');
|
|
131
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, -1, $1)', ['task.confirmed_reversal'], 'nor run without a task');
|
|
132
|
+
|
|
133
|
+
// Existing rows are never rewritten: the trigger is INSERT-only.
|
|
134
|
+
const { rows: trig } = await client.query(
|
|
135
|
+
"SELECT tgtype FROM pg_trigger WHERE tgname = 'trg_credit_log_refuse_spend' AND tgrelid = 'credit_log'::regclass");
|
|
136
|
+
// tgtype bits: 1 ROW, 2 BEFORE, 4 INSERT, 8 DELETE, 16 UPDATE.
|
|
137
|
+
ok(trig.length === 1 && (trig[0].tgtype & 2) && (trig[0].tgtype & 4) && !(trig[0].tgtype & 24), 'the trigger is BEFORE INSERT only');
|
|
138
|
+
const { rows: idx } = await client.query(
|
|
139
|
+
"SELECT pg_get_indexdef(indexrelid) AS def FROM pg_index WHERE indexrelid = 'idx_credit_log_reversals'::regclass");
|
|
140
|
+
ok(idx.length === 1 && /\(builder_id, reason\) WHERE \(delta < 0\)/.test(idx[0].def),
|
|
141
|
+
'the reversal sums read a partial index of negative rows only, not the whole ledger');
|
|
142
|
+
const { rows: cmt } = await client.query(`SELECT col_description('credit_log'::regclass,
|
|
143
|
+
(SELECT attnum FROM pg_attribute WHERE attrelid = 'credit_log'::regclass AND attname = 'delta')) AS d,
|
|
144
|
+
obj_description('credit_log'::regclass) AS t`);
|
|
145
|
+
ok(!/redemption|spent \(future\)/i.test(`${cmt[0].d} ${cmt[0].t}`), 'the comments no longer offer redemption or a future spend');
|
|
146
|
+
|
|
147
|
+
// The probe: without the trigger the same spend row is admitted.
|
|
148
|
+
await client.query('DROP TRIGGER trg_credit_log_refuse_spend ON credit_log');
|
|
149
|
+
await ins(1, null, -5, 'spend');
|
|
150
|
+
ok(await balance(1) === 7, 'probe: with the trigger dropped, the spend row books — the trigger is what refuses it');
|
|
151
|
+
|
|
152
|
+
await client.query('ROLLBACK');
|
|
153
|
+
console.log(`credit_log_no_spend_db: ${pass} passed`);
|
|
154
|
+
} finally {
|
|
155
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
156
|
+
await client.query('SET search_path TO public').catch(() => {});
|
|
157
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
158
|
+
client.release();
|
|
159
|
+
await pgPool.end();
|
|
160
|
+
}
|
|
@@ -172,6 +172,14 @@ const NON_PUBLIC_READS = [
|
|
|
172
172
|
fn: 'listLoginsForPerson',
|
|
173
173
|
why: 'the own-scope read behind GET /me/sign-in-methods (task 1004992): which ways in the signed-in person has, keyed on their authenticated key only. It returns their own providers and addresses to themselves; a private account must still see its own sign-in methods.',
|
|
174
174
|
},
|
|
175
|
+
{
|
|
176
|
+
fn: 'linkLogin',
|
|
177
|
+
why: 'linking a second way in (task 1004995): finds the signed-in person\'s own account row by their authenticated key to attach the login to. It returns only linked/refused to that person; a private account must still be able to link.',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
fn: 'unlinkLogin',
|
|
181
|
+
why: 'removing a way in (task 1004995): locks the signed-in person\'s own logins by their authenticated key to count them. It returns only unlinked/refused to that person; a private account must still be able to unlink.',
|
|
182
|
+
},
|
|
175
183
|
{
|
|
176
184
|
fn: 'hiddenGithubIds',
|
|
177
185
|
why: 'the closeness-graph consent gate — it answers only "which of these accounts set hide_stats", the OPPOSITE question to visibility. Its callers are requireBuilder-gated and it must stay fail-closed on hide alone (ADR 0171 D4); joining addressability would silently empty the graph of every private account for readers who are allowed to see the edge.',
|
|
@@ -83,8 +83,8 @@ test('the script builds no markup from strings, and the controls are real markup
|
|
|
83
83
|
|
|
84
84
|
test('the door returns here, and the page calls only its own-scoped routes', () => {
|
|
85
85
|
assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
|
|
86
|
-
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
87
|
-
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds'].sort());
|
|
86
|
+
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
87
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds', 'GET /me/sign-in-methods', 'DELETE /me/sign-in-methods/'].sort());
|
|
88
88
|
// the bar's two calls live in the shared asset: the identity read and sign out
|
|
89
89
|
const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
|
|
90
90
|
assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
|
|
@@ -183,7 +183,7 @@ const ACCOUNT = () => ({
|
|
|
183
183
|
// A DOM stub just wide enough for this page: every id in the markup is an
|
|
184
184
|
// element (hidden where the markup says so), every radio and link input is
|
|
185
185
|
// addressable by the selectors the script uses, and fetch answers from a handler.
|
|
186
|
-
function boot(answer) {
|
|
186
|
+
function boot(answer, { search = '' } = {}) {
|
|
187
187
|
const els = new Map();
|
|
188
188
|
const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
|
|
189
189
|
// textContent reads the node's own text plus its children's, and setting it
|
|
@@ -299,7 +299,7 @@ function boot(answer) {
|
|
|
299
299
|
};
|
|
300
300
|
const location = { hostname: 'cloudbongos.com', pathname: '/account' };
|
|
301
301
|
// one global, as in the browser: hub-bar.js sets window.HubBar, then the page reads it
|
|
302
|
-
const ctx = vm.createContext({ document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
|
|
302
|
+
const ctx = vm.createContext({ document, fetch, location, window: { location: { search, assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
|
|
303
303
|
vm.runInContext(BAR, ctx);
|
|
304
304
|
vm.runInContext(SCRIPT, ctx);
|
|
305
305
|
return dom;
|
|
@@ -498,7 +498,7 @@ test('the section ships inside the signed-in body, last, as a list with an empty
|
|
|
498
498
|
test('signed in: the list is read once, after the account, and each row names its project and host with its own Leave', async () => {
|
|
499
499
|
const dom = boot(building(BUILDING));
|
|
500
500
|
await settle();
|
|
501
|
-
assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects', '/me/guilds']);
|
|
501
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects', '/me/guilds', '/me/sign-in-methods']);
|
|
502
502
|
assert.equal(dom.els.get('buildingLoading').hidden, true);
|
|
503
503
|
assert.equal(dom.els.get('buildingEmpty').hidden, true);
|
|
504
504
|
const rows = rowsOf(dom);
|
|
@@ -857,3 +857,89 @@ test('the hall trusts only a bare https hub origin (OTB.hubOrigin, shared by Set
|
|
|
857
857
|
assert.equal(origin('https://cloudbongos.com/account'), null, 'a value with a path is not an origin');
|
|
858
858
|
assert.equal(origin('https://user@cloudbongos.com'), null);
|
|
859
859
|
});
|
|
860
|
+
|
|
861
|
+
// ── ways to sign in (task 1004995) ───────────────────────────────────────────
|
|
862
|
+
// Link is a link to the sign-in routes; their answer comes back in the address.
|
|
863
|
+
// A way in the server says cannot be removed shows why, never a Remove.
|
|
864
|
+
|
|
865
|
+
const WAYS = (methods) => (m, p) => {
|
|
866
|
+
if (m === 'GET' && p === '/me/cross-project') return { status: 200, data: ACCOUNT() };
|
|
867
|
+
if (m === 'GET' && p === '/me/sign-in-methods') return { status: 200, data: { methods, providers: ['github', 'google'] } };
|
|
868
|
+
if (m === 'DELETE' && p === '/me/sign-in-methods/google') return { status: 200, data: { ok: true } };
|
|
869
|
+
if (m === 'DELETE' && p === '/me/sign-in-methods/github') return { status: 409, data: { error: { code: 'last_way_in', message: 'this is your only way in, so it cannot be removed — link another first' } } };
|
|
870
|
+
return { status: 500, data: null };
|
|
871
|
+
};
|
|
872
|
+
const waysOf = (dom) => dom.els.get('waysList').children;
|
|
873
|
+
const actionOf = (row) => row.children[1].children[0];
|
|
874
|
+
|
|
875
|
+
test('the ways section sits in the signed-in body with its list and status line', () => {
|
|
876
|
+
const body = PAGE.split('<div id="acctBody" hidden>')[1].split('</main>')[0];
|
|
877
|
+
const sec = (body.match(/<section class="aSec" id="aWaysIn" aria-labelledby="aWaysIn-h">[\s\S]*?<\/section>/) || [''])[0];
|
|
878
|
+
assert.ok(sec, 'ways to sign in is a section of the account body (the link routes land on #aWaysIn)');
|
|
879
|
+
assert.match(sec, /<ul class="mineRows" id="waysList"><\/ul>/);
|
|
880
|
+
assert.match(sec, /Linking never joins two accounts\./, 'the page says the no-merge rule up front');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('one row per provider: a held way in shows its address and Remove; a missing one links to its sign-in', async () => {
|
|
884
|
+
const dom = boot(WAYS([
|
|
885
|
+
{ provider: 'github', email: null, unlink_refused: 'born_with' },
|
|
886
|
+
]));
|
|
887
|
+
await settle();
|
|
888
|
+
const rows = waysOf(dom);
|
|
889
|
+
assert.equal(rows.length, 2);
|
|
890
|
+
assert.equal(rows[0].children[0].children[0].textContent, 'GitHub');
|
|
891
|
+
assert.equal(rows[0].children[0].children[1].textContent, 'linked', 'no address shown → linked');
|
|
892
|
+
assert.equal(rows[0].children[0].children[2].textContent, 'Your account was made with GitHub, so GitHub stays linked.');
|
|
893
|
+
assert.equal(rows[0].children[1].children.length, 0, 'no Remove for a way in the server keeps');
|
|
894
|
+
assert.equal(rows[1].children[0].children[1].textContent, 'not linked');
|
|
895
|
+
assert.equal(actionOf(rows[1]).textContent, 'Link Google');
|
|
896
|
+
assert.equal(actionOf(rows[1]).href, '/api/bongos/auth/google/start?link=1');
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('Link GitHub goes to the GitHub link route; the only way in says why it stays', async () => {
|
|
900
|
+
const dom = boot(WAYS([{ provider: 'google', email: 'ana@example.com', unlink_refused: 'last_way_in' }]));
|
|
901
|
+
await settle();
|
|
902
|
+
const [gh, goo] = waysOf(dom);
|
|
903
|
+
assert.equal(actionOf(gh).href, '/api/bongos/auth/link/github/start');
|
|
904
|
+
assert.equal(goo.children[0].children[1].textContent, 'ana@example.com');
|
|
905
|
+
assert.match(goo.children[0].children[2].textContent, /^Your only way in, so it stays\./);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('Remove sends one DELETE, says so, and re-reads the list', async () => {
|
|
909
|
+
const dom = boot(WAYS([
|
|
910
|
+
{ provider: 'github', email: null, unlink_refused: null },
|
|
911
|
+
{ provider: 'google', email: 'ana@example.com', unlink_refused: null },
|
|
912
|
+
]));
|
|
913
|
+
await settle();
|
|
914
|
+
const google = waysOf(dom)[1];
|
|
915
|
+
dom.click(actionOf(google));
|
|
916
|
+
assert.equal(actionOf(google).textContent, 'Removing…');
|
|
917
|
+
await settle();
|
|
918
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'DELETE').map((c) => c.path), ['/me/sign-in-methods/google']);
|
|
919
|
+
assert.equal(dom.text('waysMsg'), 'Google is removed. It no longer opens this account.');
|
|
920
|
+
assert.equal(dom.calls.filter((c) => c.path === '/me/sign-in-methods' && c.method === 'GET').length, 2);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test('a refused Remove is said in the server’s words and keeps the button', async () => {
|
|
924
|
+
const dom = boot(WAYS([
|
|
925
|
+
{ provider: 'github', email: null, unlink_refused: null },
|
|
926
|
+
{ provider: 'google', email: null, unlink_refused: null },
|
|
927
|
+
]));
|
|
928
|
+
await settle();
|
|
929
|
+
const gh = waysOf(dom)[0];
|
|
930
|
+
dom.click(actionOf(gh));
|
|
931
|
+
await settle();
|
|
932
|
+
assert.equal(dom.text('waysMsg'), 'This is your only way in, so it cannot be removed — link another first.');
|
|
933
|
+
assert.equal(actionOf(gh).disabled, false);
|
|
934
|
+
assert.equal(actionOf(gh).textContent, 'Remove');
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test('a link’s answer is said plainly: linked, already its own account (never merged), cancelled', async () => {
|
|
938
|
+
const say = async (search) => { const d = boot(WAYS([]), { search }); await settle(); return [d.text('waysMsg'), d.els.get('waysMsg').className]; };
|
|
939
|
+
assert.deepEqual(await say('?linked=google'), ['Google is linked. You can now sign in with either.', 'projMsg ok']);
|
|
940
|
+
const [taken, cls] = await say('?link_refused=taken&provider=github');
|
|
941
|
+
assert.match(taken, /^That GitHub account already has its own account here, so it was not linked\. We never join two accounts; nothing changed\.$/);
|
|
942
|
+
assert.equal(cls, 'projMsg err');
|
|
943
|
+
assert.match((await say('?link_refused=cancelled&provider=google'))[0], /^You cancelled linking Google\. Nothing changed\.$/);
|
|
944
|
+
assert.match((await say('?link_refused=whatever&provider=google'))[0], /^Couldn’t link Google\. Try again\.$/);
|
|
945
|
+
});
|