@bongos/core 1.19.536 → 1.19.538
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 +37 -22
- package/docs/adr/0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md +144 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/invite-authz.js +117 -0
- package/modules/platform-identity/routes/projects.js +49 -22
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +71 -4
- package/modules/provisioning/routes/provisioning.js +21 -1
- package/modules/provisioning/tests/provisioning.mjs +29 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/project_catalog_routes.mjs +22 -0
- package/tests/project_invite_ownership.mjs +366 -0
- package/tests/provisioning_module_selection.mjs +114 -5
|
@@ -169,15 +169,20 @@ provisioning.createInstance = async (_db, args) => {
|
|
|
169
169
|
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
170
170
|
provisioning.getOpenIntent = async () => null;
|
|
171
171
|
provisioning.recordEvent = async () => {};
|
|
172
|
-
// Mirrors the real statement: `detail = detail || $n::jsonb` MERGES
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
172
|
+
// Mirrors the real statement: `detail = (detail || $n::jsonb) - $m::text[]` MERGES
|
|
173
|
+
// the patch's answers onto what is already stored and DROPS the keys it explicitly
|
|
174
|
+
// un-answered (task 1003577). The row the route then hands `effectiveModules` is
|
|
175
|
+
// that merged one, so a stub that returned the pre-patch row would hide exactly the
|
|
176
|
+
// staleness this test exists to catch — and one that only merged would hide the
|
|
177
|
+
// clear, which is the whole way back to "never answered".
|
|
176
178
|
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
177
179
|
lastUpdatedRow = { ...detailRow };
|
|
178
180
|
if (patch.description !== undefined) lastUpdatedRow.description = patch.description;
|
|
179
181
|
if (patch.detail !== undefined) {
|
|
180
|
-
|
|
182
|
+
const { set, clear } = provisioning.projectDetailPatch(patch.detail);
|
|
183
|
+
const next = { ...(detailRow.detail || {}), ...set };
|
|
184
|
+
for (const key of clear) delete next[key];
|
|
185
|
+
lastUpdatedRow.detail = next;
|
|
181
186
|
}
|
|
182
187
|
// `modules` REPLACES and is normalized on the way in — the real statement's
|
|
183
188
|
// `modules = $n::jsonb` over normalizeModuleSelection (BV1.R23, task 1002342).
|
|
@@ -502,6 +507,110 @@ await ta('PATCH /detail: another builder’s project is a 404, not a module chan
|
|
|
502
507
|
detailRow = null; lastUpdatedRow = null;
|
|
503
508
|
});
|
|
504
509
|
|
|
510
|
+
// ── Un-answering the interview ─────────────────────────────────────────────
|
|
511
|
+
// Task 1003577. `effectiveModules` reads three states off a row and "nobody said"
|
|
512
|
+
// is one of them, but a MERGING write could only ever add an answer: `{}` merged to
|
|
513
|
+
// nothing and `null` was dropped by the vocabulary, so the first `team_shape` an
|
|
514
|
+
// owner ever sent was permanent and the bundle it moved could never move back. An
|
|
515
|
+
// explicit per-key `null` is now the un-answer (RFC 7396, and `description`'s own
|
|
516
|
+
// rule on this same route), and the read-back has to resolve as a never-answered row
|
|
517
|
+
// — not merely "close to one".
|
|
518
|
+
|
|
519
|
+
await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one that was never asked', async () => {
|
|
520
|
+
// Arrange — a game project that skipped the picker, so its modules resolve from
|
|
521
|
+
// the bundle and the interview answer is the only thing that can move them.
|
|
522
|
+
const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
523
|
+
const neverAnswered = effectiveModules(virgin);
|
|
524
|
+
assert.equal(neverAnswered.source, 'bundle', 'the fixture really is an unanswered project');
|
|
525
|
+
|
|
526
|
+
detailRow = { ...virgin };
|
|
527
|
+
lastUpdatedRow = null;
|
|
528
|
+
const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
|
|
529
|
+
assert.equal(answered.status, 200, answered.raw);
|
|
530
|
+
assert.deepEqual(answered.body.detail, { team_shape: 'solo' });
|
|
531
|
+
assert.notDeepEqual(answered.body.modules.selected, neverAnswered.selected,
|
|
532
|
+
'the fixture is only meaningful if the answer really moved the bundle');
|
|
533
|
+
|
|
534
|
+
// Act — the owner takes it back
|
|
535
|
+
detailRow = lastUpdatedRow;
|
|
536
|
+
lastUpdatedRow = null;
|
|
537
|
+
const cleared = await patch('/provisioning/instances/41/detail', { detail: { team_shape: null } });
|
|
538
|
+
|
|
539
|
+
// Assert — the stored answer is GONE, not stored as null, and every derived read
|
|
540
|
+
// is the unanswered one again
|
|
541
|
+
assert.equal(cleared.status, 200, cleared.raw);
|
|
542
|
+
assert.ok(!('team_shape' in lastUpdatedRow.detail), 'the key left the jsonb entirely');
|
|
543
|
+
assert.deepEqual(cleared.body.detail, {}, 'and the read surface says the interview is unanswered');
|
|
544
|
+
assert.deepEqual(cleared.body.modules, neverAnswered, 'source, preset and adjustments are the never-answered ones');
|
|
545
|
+
assert.equal(cleared.body.modules.source, 'bundle');
|
|
546
|
+
assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('game').bundle, 'the UNADJUSTED preset');
|
|
547
|
+
assert.deepEqual(cleared.body.modules.adjustments, [], 'no rule fired, so nothing claims to have');
|
|
548
|
+
assert.deepEqual(cleared.body.publish, answered.body.publish, 'clearing an answer is not a publish change');
|
|
549
|
+
detailRow = null; lastUpdatedRow = null;
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
await ta('PATCH /detail: clearing one key leaves every other stored key alone', async () => {
|
|
553
|
+
// The statement drops the named key rather than replacing the column, so an answer
|
|
554
|
+
// the patch never mentioned survives — including one stored before it left the
|
|
555
|
+
// vocabulary, which `projectDetail` filters on READ and must not silently delete.
|
|
556
|
+
detailRow = {
|
|
557
|
+
id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null,
|
|
558
|
+
detail: { team_shape: 'community', legacy_answer: 'kept' },
|
|
559
|
+
};
|
|
560
|
+
lastUpdatedRow = null;
|
|
561
|
+
|
|
562
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: null } });
|
|
563
|
+
|
|
564
|
+
assert.equal(res.status, 200, res.raw);
|
|
565
|
+
assert.deepEqual(lastUpdatedRow.detail, { legacy_answer: 'kept' }, 'only the nulled key was dropped');
|
|
566
|
+
assert.deepEqual(res.body.detail, {}, 'and the read still filters the survivor through the vocabulary');
|
|
567
|
+
detailRow = null; lastUpdatedRow = null;
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
await ta('PATCH /detail: an off-vocabulary value is still REFUSED, never mistaken for a clear', async () => {
|
|
571
|
+
// Only null un-answers. A typo, an empty string or any other sentinel is the
|
|
572
|
+
// loud-validation case badProjectDetail was written for — silently treating one as
|
|
573
|
+
// "clear" would erase an answer the caller was trying to set.
|
|
574
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: { team_shape: 'solo' } };
|
|
575
|
+
for (const value of ['enormous', '', 0, false]) {
|
|
576
|
+
lastUpdatedRow = null;
|
|
577
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: value } });
|
|
578
|
+
assert.equal(res.status, 400, `${JSON.stringify(value)} → ${res.raw}`);
|
|
579
|
+
assert.equal(res.body.error.code, 'bad_detail');
|
|
580
|
+
assert.match(res.body.error.message, /solo/, 'the message names the answers it does take');
|
|
581
|
+
assert.equal(lastUpdatedRow, null, 'a refused write touches nothing');
|
|
582
|
+
}
|
|
583
|
+
detailRow = null; lastUpdatedRow = null;
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
await ta('POST /instances: a null answer at CREATION is accepted and stores nothing — there is nothing to un-answer yet', async () => {
|
|
587
|
+
// badProjectDetail is shared with the create route, so the un-answer verb must not
|
|
588
|
+
// become a 400 there. Nothing to clear at creation, so it simply reads as no answer.
|
|
589
|
+
lastCreateArgs = null; existingRow = null;
|
|
590
|
+
|
|
591
|
+
const res = await post('/provisioning/instances', {
|
|
592
|
+
slug: 'mercury', hosting_shape: 'standalone', type: 'game', detail: { team_shape: null },
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
596
|
+
assert.deepEqual(res.body.instance.detail, {}, 'the row is born unanswered, as it would be with no detail at all');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
await ta('PATCH /detail: an EMPTY detail patch still changes nothing — a skipped form is not an erase', async () => {
|
|
600
|
+
// RFC 7396's own rule, and the one ADR 0240 leans on: `{}` is the shape a form
|
|
601
|
+
// sends when it collected no answers, so making it erase would un-answer the
|
|
602
|
+
// interview every time an owner saved the step without touching it.
|
|
603
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: { team_shape: 'solo' } };
|
|
604
|
+
lastUpdatedRow = null;
|
|
605
|
+
|
|
606
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: {} });
|
|
607
|
+
|
|
608
|
+
assert.equal(res.status, 200, res.raw);
|
|
609
|
+
assert.deepEqual(lastUpdatedRow.detail, { team_shape: 'solo' }, 'the stored answer survives an empty patch');
|
|
610
|
+
assert.deepEqual(res.body.detail, { team_shape: 'solo' });
|
|
611
|
+
detailRow = null; lastUpdatedRow = null;
|
|
612
|
+
});
|
|
613
|
+
|
|
505
614
|
server.close();
|
|
506
615
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
507
616
|
process.exit(failed === 0 ? 0 : 1);
|