Compare commits

...
Author SHA1 Message Date
jason.woltje d5428c1a87 row 203 FED R2: cases for missing self, peer id and label (webby-0191 B3)
The pattern test coerces an absent value to the string 'undefined', which
matches, so the typeof halves are the only refusal for a missing value. Adds
a case for each, and M15-M17 in BUILD.md; keeps the config-console-key check
name on purpose. Not run yet.
2026-10-22 15:40:56 -05:00
jason.woltje 8d34a9e948 row 203 FED R2: stack-release probe reads federation; a refusal case per federation check
stack-release config_checks refuses a target whose loader refuses a key the
config holds; it looked only for console, so a federation-only config passed
to a pre-203 copy. The probe now reads console or federation. The fixture's
B loader replace follows the new rejectUnknownKeys list, which the federation
key changed.

Adds a test for each federation refusal in test-config.sh (six uncovered,
plus trailing slash and duplicate peer id), and one stack-release test for
the target-loader refusal. Mutants M7-M14 in BUILD.md. Not run yet.
2026-10-22 15:38:15 -05:00
jason.woltje e704a18aec row 203 FED R2: federation key in the system config (#1701)
The loader validates an optional federation block: self and peers[].id
labels, peers[].origin as an https origin with no path, query or userinfo,
peers[].label non-empty and at most 80 characters, peer ids unique and
distinct from self. Unknown sub-keys refuse. Labels and URLs only; the key
grants no trust. Absent key: config loads with no federation output.

Tests in scripts/test-config.sh cover each refusal and the no-key case.
Notes and mutants M1-M6 in agents/quackerjack/work/queue-203/BUILD.md.
Not run yet: the gate runs on CLEAR.
2026-10-22 15:31:35 -05:00
6 changed files with 334 additions and 7 deletions

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
# Row 203, FED R2: the federation key in the system config (#1701)
Owner Quackerjack, reviewer Webby, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md,
"Brief: FED R2". Design: DESIGN.md section 3.1 (D1), section 4 (the key shape), section 7 row R2, at
gizmoduck/federation-design b93f098f0.
Base: aca16404c, rebased onto f2735bb6a (origin/refactor, shared object store). Rebased onto 253's tip
only if 253 lands first, under a new branch name.
## Commits
- 0eb4b6f1f, rebased to e704a18ae: the loader, the first FED R2 tests, and these notes.
- Second commit on top of e704a18ae (sage-2998): B1 and B2 below, the fixture fix, and the notes
rewrite. Its sha goes in the status line once committed.
## Shape
```json
"federation": {
"self": "hub",
"peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }]
}
```
- Optional. Absent: the config loads and prints what it printed before (no `federation` in the output).
- `self` and each `peers[].id` match `^[a-z][a-z0-9-]{0,31}$`. A peer id may not equal `self` or another peer's id.
- `peers[].origin` must be an https origin exactly as `new URL(origin).origin` returns it: no path, no query,
no userinfo, no trailing slash.
- `peers[].label` is a non-empty string of at most 80 characters.
- Labels and URLs only. Nothing here grants trust to a peer (section 3.1 D1, section 3.2 W1).
## Readers of the key
Two readers read the raw key. Nothing else reads it (`git grep "federation"` at e704a18ae):
1. The loader, `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). This is the only reader of
the validated value. Its output is read by the CLI and business code, which read named fields only.
The output is checked in `scripts/test-config.sh`.
2. The stack-release probe, `config_checks` in `scripts/stack-release.sh` (B1, below). It reads the config
file itself, before a copy is activated or rolled back to, and asks whether the target copy's loader
refuses a key the config holds.
Stale wording, NOT changed in this row (Sage rulings sage-2808 and sage-2809):
`packages/cli/src/access.mjs` is out of row 203. Megavolt's row 207 changes that file. Follow-up, to be
carried to DEFERRED by Sage:
- access.mjs lines 26-27 (`ACCESS_HELP` comment): "Peer homes are empty until the federation key lands
(row 203)". Still partly true after this row, since the access context does not read the key.
- access.mjs line 36 (inside the `ACCESS_HELP` string, user-visible help): "Peer homes are not read yet
(row 203 adds the federation key)". The same claim, in help text. No test in `packages/cli/tests` pins it
(`git grep` for "row 203" and "Peer homes" finds nothing in tests).
- access.mjs line 44 comment: "peers is empty until row 203".
- `packages/cli/README.md` line 1107: "until row 203 adds the". Outside the manifest; flagged in the
same follow-up.
Behavior after this row: `accessContextFor` still returns `peers: []` (access.mjs line 58). Row 203 adds the
config key only, so grants naming a peer home still refuse. Wiring the key into the access context is not in
the FED R2 brief.
## B1: the stack-release probe (sage-2998, in scope)
Webby's early look (webby-0189): a config holding `federation` and no `console` passed `config_checks`, because
the probe looked only for `console`. A rollback or activate to a pre-203 copy would then pass its checks, and
that copy's loader refuses `unsupported configuration key: "federation"` on the next read.
Fix (scripts/stack-release.sh, `config_checks`):
- The probe's node check now reads `console` or `federation` (`Object.hasOwn(d,"console")||Object.hasOwn(d,"federation")`).
- The refusal keeps the check name `config-console-key`, so existing callers and tests do not change. The
message now reads "holds a console or federation key". This is a wording change to an existing refusal,
and `KEY_MSG` in `packages/cli/tests/stack-release.test.mjs` moves with it.
- The new test is `row 203: a config whose federation key the target loader refuses ...` in
`packages/cli/tests/stack-release.test.mjs`, after the row 200 console test. Target B's loader has no
federation key in its list, so it refuses the key. Activate to B and rollback to B are both refused, and
a rollback succeeds once the key is removed.
Fixture (packages/cli/tests/stack-release-fixture.mjs, line 300): `LOADER_OLD` builds B's loader by a string
replace on the `rejectUnknownKeys` list. Adding `federation` to that list in this row left the replace with no
match, and the fixture's `assert.notEqual(LOADER_OLD, LOADER)` would fail at load. The replace now targets
`"execution", "console", "federation"]`, so B's loader refuses both keys. This regression came from my
e704a18ae, and the fix is in this commit.
## B2: a case and a mutant for each refusal (sage-2998: "one mutant per refusal")
Webby's list is covered below. Each refusal has a case in `scripts/test-config.sh` (FED R2 block) and a
mutant. M5 is killed by the self case; M13 covers the duplicate half of the id check that M5 did not reach.
## Refusals and their tests
`scripts/test-config.sh`, FED R2 block:
| Case | Refusal (exit 2) | Mutant (killed by) |
|---|---|---|
| valid key loads and is in the output | none | base |
| unknown top-level sub-key | `unsupported federation key: "extra"` | M1 |
| unknown peer sub-key | `unsupported federation.peers[0] key: "trust"` | M2 |
| origin over http | `federation.peers[0].origin must be an https origin ...` | M3 |
| origin with a path | same | M4 |
| origin with userinfo | same | M4 |
| origin with a trailing slash | same | M4 |
| peer id equal to self | `federation.peers[0].id ... is already named` | M5 |
| duplicate peer id | `federation.peers[1].id "factory" is already named` | M13 |
| peer label empty | `federation.peers[0].label must be a non-empty string ...` | M6 |
| peer label over 80 characters | `federation.peers[0].label must be ...` | M12 |
| federation not an object | `"federation" must be a JSON object` | M7 |
| self does not match the pattern | `federation.self must match ...` | M8 |
| peers not an array | `federation.peers must be an array` | M9 |
| peer not an object | `federation.peers[0] must be a JSON object` | M10 |
| peer id does not match the pattern | `federation.peers[0].id must match ...` | M11 |
| self missing | `federation.self must match ...` | M15 |
| peer id missing | `federation.peers[0].id must match ...` | M16 |
| peer label missing | `federation.peers[0].label must be ...` | M17 |
| no federation key | loads, no federation output | base |
| B1: target loader refuses federation | `config-console-key` refusal (stack-release.test) | M14 |
Mutant plan (run on Sage's gate only, one edit each, a named test must fail). Before the gate each edit string
must occur exactly once in its file. I checked all of M1 to M17 with grep -cF: each is 1. The
gate's mutant runner checks the count again before it applies each edit:
- M1: `rejectUnknownKeys(value, ["self", "peers"], "federation");` removed. Killed by "federation unknown sub-key".
- M2: `rejectUnknownKeys(peer, ["id", "origin", "label"], where);` removed. Killed by "federation peer unknown sub-key".
- M3: `url.protocol !== "https:" ||` removed. Killed by "origin over http".
- M4: `|| url.origin !== value` removed. Killed by "origin with a path", "origin with userinfo" and
"origin with a trailing slash".
- M5: `if (ids.has(peer.id)) {` changed to `if (false) {`. Killed by "peer id equal to self".
- M6: `peer.label.trim() === "" ||` removed. Killed by "peer label empty".
- M7: `if (!isPlainObject(value)) {` changed to `if (false) {`. Killed by "federation not an object".
- M8: `|| !FEDERATION_ID_PATTERN.test(value.self)` removed. Killed by "self does not match the pattern".
- M9: `if (!Array.isArray(value.peers)) {` changed to `if (false) {`. Killed by "peers not an array".
- M10: `if (!isPlainObject(peer)) {` changed to `if (false) {`. Killed by "peer not an object".
- M11: `|| !FEDERATION_ID_PATTERN.test(peer.id)` removed. Killed by "peer id does not match the pattern".
- M12: ` || peer.label.length > FEDERATION_LABEL_MAX` removed. Killed by "peer label over 80 characters".
- M13: ` ids.add(peer.id);` removed. Killed by "duplicate peer id".
- M14 (stack-release.sh): `||Object.hasOwn(d,"federation")` removed. Killed by the row 203 federation test in
`stack-release.test.mjs`.
- M15: `typeof value.self !== "string" || ` removed. Killed by "federation self missing". Without the typeof
half, an absent self passes the pattern (`/^[a-z]…$/.test(undefined)` is true).
- M16: `typeof peer.id !== "string" || ` removed. Killed by "federation peer id missing". Same reason.
- M17: `typeof peer.label !== "string" || ` removed. Killed by "federation peer label missing". Without it, a
missing label throws a TypeError at `.trim()` (exit 1), not the exit 2 refusal.
## Boundaries
- No config is written. `~/.config/mosaic-dev/config.json` is not touched. Tests use `$SANDBOX` only.
- No change to `packages/cli/src`, `scripts/bootstrap.sh`, or any reader beyond the stack-release probe.
## Manifest (sha256 of committed blobs; the list is the review scope)
- scripts/mosaic-config.mjs
- scripts/test-config.sh
- scripts/stack-release.sh (B1)
- packages/cli/tests/stack-release.test.mjs (B1 test, KEY_MSG)
- packages/cli/tests/stack-release-fixture.mjs (B's loader fixture; without this, the suite fails to load)
- agents/quackerjack/work/queue-203/BUILD.md
## Slot estimate (for Sage's gap)
- test-config.sh: about 5 min, padded.
- The cli suite: about 5 min, padded.
- stack-release.test.mjs: up to 1800 s, per Sage. It copies the loader, so it runs whole at the gate (Webby N3).
- Mutants M1–M13 and M15–M17 each re-run the FED block of test-config.sh. M14 re-runs stack-release.test.mjs.
The per-mutant cost is not measured: this run has not been timed.
## Status
- e704a18ae: the loader, first tests and notes, rebased onto f2735bb6a.
- 8d34a9e94: B1, B2, the fixture fix and the notes rewrite (Mos approved the two old-test edits, mos-1346).
- Third commit on top: B3 cases M15–M17 and these notes (sha in the message to Sage and Webby).
- NOT YET RUN: no node during Gander's slot. The test-config.sh, cli and stack-release gates run when Sage
announces a CLEAR. `bash -n` passes on both shell scripts.
- Not pushed, not claimed. Per invariant 8 the gate runs before any commit is called green.
## Review notes (Webby, webby-0189, answered here)
- B1: fixed as above (scope per sage-2998). The probe's check name `config-console-key` is kept on purpose (Webby
N5, Sage): renaming it would change callers and tests for no gain in this row. The message says "console or
federation key"; the name is the only old word left.
- B3 (webby-0191): missing self, missing peer id, and missing peer label each have a case and a mutant (M15–M17).
The typeof halves are the only thing refusing an absent value, since the pattern test coerces undefined.
- B2: a case and a mutant for each uncovered refusal, plus the trailing-slash case under M4 (N4) and the
duplicate-peer case (M13).
- N1: this file is rewritten with the current base and status.
- N2: consumers checked by `git grep "mosaic-config"`. The loader's output is read by `packages/cli/src/config.mjs`,
`packages/business/src/*.mjs`, `packages/webui/src/sso-config.mjs`, `packages/seat/src/seat.mjs`,
`packages/discord/src/binding.mjs`, `packages/conversation/src/guard.mjs`, and the shell scripts that call
`validate` (bootstrap.sh, common.sh, instance.sh, land-work.sh, stack-copy.sh, stack-release.sh,
conductor-apply.sh). Checked by `git grep "federation"` over packages and scripts: no reader outside the
loader and the stack-release probe mentions `federation`. I did NOT read each call site for which fields it
uses; that is still open for Webby's static read. Not run.
- N3: the cli stack-release tests copy the loader; they run whole at the gate. Listed in the manifest and
the slot estimate.
- N4: trailing-slash case added (see table; M4).
+3 -2
View File
@@ -295,9 +295,10 @@ export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "ha
assert.notEqual(KINDS_OLD,KINDS_NEW);
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
// from before row 192, which refuses a `console` key. The other copies have no loader.
// from before row 192, which refuses a `console` key. The other copies have no loader. Row 203: B's
// loader also refuses a `federation` key, as a pre-203 loader does (its list has no federation).
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console", "federation"]','["configVersion", "environment", "dataRoot", "execution"]');
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
+14 -1
View File
@@ -1468,7 +1468,7 @@ const SHOW=`show-${CON}.txt`;
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console or federation key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
function setEdit(set,name,from,to,sums=true){
@@ -1582,6 +1582,19 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('row 203: a config whose federation key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
const e=await mk(t);await seedPreview(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
// B's loader has no federation key in its list, as a pre-203 loader has none: it refuses the key.
const setFed=(fed)=>{const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(fed===undefined)delete c.federation;else c.federation=fed;writeFileSync(e.cfg,JSON.stringify(c))};
setFed({self:'hub',peers:[{id:'factory',origin:'https://mosaic.woltje.com',label:'Software factory'}]});
const why='mosaic-config: unsupported configuration key: "federation"';
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
setFed(undefined);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
+58 -1
View File
@@ -237,13 +237,66 @@ async function validateConsole(value) {
}
}
// FED R2 (#1701, DESIGN.md section 3.1 D1): the optional `federation` key names this instance and its
// peers. Ids and origins are labels, not trust: nothing here grants a peer anything (3.1, W1). The
// origin must be exactly an https origin, the form `new URL(origin).origin` returns, so a path, a
// trailing slash, a port that is the default, or userinfo refuses.
const FEDERATION_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
const FEDERATION_LABEL_MAX = 80;
function validateFederationOrigin(value, where) {
let url = null;
try {
url = new URL(value);
} catch {
url = null;
}
if (url === null || url.protocol !== "https:" || url.origin !== value) {
fail(2, `${where} must be an https origin with no path, query, or userinfo (got ${JSON.stringify(value)})`);
}
}
function validateFederation(value) {
if (!isPlainObject(value)) {
fail(2, '"federation" must be a JSON object');
}
rejectUnknownKeys(value, ["self", "peers"], "federation");
if (typeof value.self !== "string" || !FEDERATION_ID_PATTERN.test(value.self)) {
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(value.self)})`);
}
if (!Array.isArray(value.peers)) {
fail(2, 'federation.peers must be an array');
}
const ids = new Set([value.self]);
const peers = value.peers.map((peer, index) => {
const where = `federation.peers[${index}]`;
if (!isPlainObject(peer)) {
fail(2, `${where} must be a JSON object`);
}
rejectUnknownKeys(peer, ["id", "origin", "label"], where);
if (typeof peer.id !== "string" || !FEDERATION_ID_PATTERN.test(peer.id)) {
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(peer.id)})`);
}
if (ids.has(peer.id)) {
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
}
ids.add(peer.id);
validateFederationOrigin(peer.origin, `${where}.origin`);
if (typeof peer.label !== "string" || peer.label.trim() === "" || peer.label.length > FEDERATION_LABEL_MAX) {
fail(2, `${where}.label must be a non-empty string of at most ${FEDERATION_LABEL_MAX} characters`);
}
return { id: peer.id, origin: peer.origin, label: peer.label };
});
return { self: value.self, peers };
}
async function validate(document, file) {
if (!isPlainObject(document)) {
fail(2, "configuration must be a JSON object");
}
rejectUnknownKeys(
document,
["configVersion", "environment", "dataRoot", "execution", "console"],
["configVersion", "environment", "dataRoot", "execution", "console", "federation"],
"configuration",
);
@@ -281,6 +334,9 @@ async function validate(document, file) {
// it, so a config without it prints what it always printed.
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
// FED R2: the same rule as `console`. The key appears in the output only when the file has it.
const federationKey = Object.hasOwn(document, "federation") ? validateFederation(document.federation) : undefined;
return {
configVersion: document.configVersion,
environment: document.environment,
@@ -292,6 +348,7 @@ async function validate(document, file) {
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
...(federationKey === undefined ? {} : { federation: federationKey }),
};
}
+3 -3
View File
@@ -1078,12 +1078,12 @@ slot_problem() {
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept.
config_checks() {
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
# 0: a console or federation key (row 203: a pre-203 loader refuses federation too); 1: neither, or no file; 2: the file doesn't parse, so whether it holds the key
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on
# such a file before anything is read or stopped, so this is reached only if the file changes after it.
local key=0
node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)}
process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$?
process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?0:1)' -- "$cfg" 2>/dev/null || key=$?
[ "$key" != 1 ] || return 0
if [ "$key" != 0 ]; then
check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again"
@@ -1093,7 +1093,7 @@ process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' --
elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
fi
first="${out%%$'\n'*}"
check_refuse config-console-key "$cfg holds a console key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
check_refuse config-console-key "$cfg holds a console or federation key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
+66
View File
@@ -191,6 +191,72 @@ expect_refusal "console.sso slot name outside the pattern exits 2 naming the pat
expect_refusal "unknown key under console exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
# --- FED R2 (#1701, DESIGN.md section 3.1): the optional federation key ---
# fed_body FEDERATION-JSON: a config whose federation key is the argument.
fed_body() {
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"federation":%s}' "$DATA_ROOT" "$1"
}
FED_VALID='{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"Software factory"}]}'
cfg fed-valid.json "$(fed_body "$FED_VALID")"
OUT="$(MOSAIC_CONFIG="$SANDBOX/fed-valid.json" timeout 20 $CONFIG_OP validate)"
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"self": "hub"' && printf '%s' "$OUT" | grep -q '"origin": "https://mosaic.woltje.com"'
check "valid federation key loads and is in the output" $?
# expect_fed_refusal NAME PATH-IN-MESSAGE FEDERATION-JSON: exit 2, and the message names the bad key path.
expect_fed_refusal() {
cfg fed-bad.json "$(fed_body "$3")"
local err rc
err="$(MOSAIC_CONFIG="$SANDBOX/fed-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$2"
check "$1 (exit $rc)" $?
}
expect_fed_refusal "federation unknown sub-key exits 2 naming the key" 'unsupported federation key: "extra"' \
'{"self":"hub","peers":[],"extra":1}'
expect_fed_refusal "federation peer unknown sub-key exits 2 naming the key" 'unsupported federation.peers[0] key: "trust"' \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F","trust":"full"}]}'
expect_fed_refusal "federation peer origin over http exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"http://mosaic.woltje.com","label":"F"}]}'
expect_fed_refusal "federation peer origin with a path exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/console","label":"F"}]}'
expect_fed_refusal "federation peer origin with userinfo exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://[email protected]","label":"F"}]}'
expect_fed_refusal "federation peer id equal to self exits 2 naming the key" "federation.peers[0].id" \
'{"self":"hub","peers":[{"id":"hub","origin":"https://mosaic.woltje.com","label":"F"}]}'
expect_fed_refusal "federation peer label empty exits 2 naming the key" "federation.peers[0].label" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":""}]}'
expect_fed_refusal "federation not an object exits 2 naming the key" '"federation" must be a JSON object' '"hub"'
expect_fed_refusal "federation self pattern exits 2 naming the key" "federation.self must match" \
'{"self":"Hub","peers":[]}'
expect_fed_refusal "federation peers not an array exits 2 naming the key" "federation.peers must be an array" \
'{"self":"hub","peers":{}}'
expect_fed_refusal "federation peer not an object exits 2 naming the key" "federation.peers[0] must be a JSON object" \
'{"self":"hub","peers":["factory"]}'
expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federation.peers[0].id must match" \
'{"self":"hub","peers":[{"id":"Factory","origin":"https://mosaic.woltje.com","label":"F"}]}'
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
# A missing value: the pattern test coerces undefined to "undefined", which matches, so the typeof half is what refuses.
expect_fed_refusal "federation self missing exits 2 naming the key" "federation.self must match" \
'{"peers":[]}'
expect_fed_refusal "federation peer id missing exits 2 naming the key" "federation.peers[0].id must match" \
'{"self":"hub","peers":[{"origin":"https://mosaic.woltje.com","label":"F"}]}'
expect_fed_refusal "federation peer label missing exits 2 naming the key" "federation.peers[0].label must be" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com"}]}'
# Without the key: no federation in the output, and the config still loads (FED R2 boundary).
cfg no-federation.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
NO_FED_OUT="$(MOSAIC_CONFIG="$SANDBOX/no-federation.json" timeout 20 $CONFIG_OP validate)"
NO_FED_RC=$?
[ "$NO_FED_RC" -eq 0 ] && ! printf '%s' "$NO_FED_OUT" | grep -q '"federation"'
check "config without federation loads and has no federation output" $?
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"