|
5 | 5 | buildLedgerAnchorPayload, |
6 | 6 | computeAnchorKeyId, |
7 | 7 | currentAnchorKey, |
| 8 | + diagnoseAnchorPublicKeys, |
8 | 9 | LEDGER_ANCHOR_LEDGER_ID, |
9 | 10 | LEDGER_ANCHOR_PAYLOAD_VERSION, |
10 | 11 | parseAnchorPublicKeys, |
@@ -171,3 +172,86 @@ describe("currentAnchorKey / anchorKeyById (rotation)", () => { |
171 | 172 | expect(anchorKeyById([retired, active], "missing")).toBeNull(); |
172 | 173 | }); |
173 | 174 | }); |
| 175 | + |
| 176 | +// #9834: `{"keys":[],"currentKeyId":null}` was the answer to six different causes, and read as a healthy |
| 177 | +// empty state for all of them. After #9719 provisioned the anchor keypair, that made "did the provisioning |
| 178 | +// take effect?" unanswerable -- a typo in the secret is byte-identical to an unset secret. |
| 179 | +describe("diagnoseAnchorPublicKeys (#9834)", () => { |
| 180 | + const key = (over: Partial<AnchorPublicKey> = {}): AnchorPublicKey => ({ |
| 181 | + keyId: "k1", |
| 182 | + publicKeySpki: "MCowBQYDK2VwAyEA" + "a".repeat(28), |
| 183 | + notBefore: "2026-01-01T00:00:00.000Z", |
| 184 | + notAfter: null, |
| 185 | + ...over, |
| 186 | + }); |
| 187 | + |
| 188 | + it("ok: exactly one current key", () => { |
| 189 | + const d = diagnoseAnchorPublicKeys(JSON.stringify([key()])); |
| 190 | + expect(d).toMatchObject({ status: "ok", currentKeyId: "k1", droppedEntries: 0 }); |
| 191 | + expect(d.keys).toHaveLength(1); |
| 192 | + }); |
| 193 | + |
| 194 | + it("unconfigured: the env var is absent", () => { |
| 195 | + expect(diagnoseAnchorPublicKeys(undefined)).toEqual({ status: "unconfigured", keys: [], currentKeyId: null, droppedEntries: 0 }); |
| 196 | + }); |
| 197 | + |
| 198 | + it("unconfigured: an empty string, and an explicitly empty array", () => { |
| 199 | + // An empty array is "configured to publish nothing", which is the same actionable state as never setting |
| 200 | + // it -- and deliberately NOT the same as entries present but every one rejected. |
| 201 | + expect(diagnoseAnchorPublicKeys("").status).toBe("unconfigured"); |
| 202 | + expect(diagnoseAnchorPublicKeys("[]").status).toBe("unconfigured"); |
| 203 | + }); |
| 204 | + |
| 205 | + it("malformed: unparseable JSON", () => { |
| 206 | + expect(diagnoseAnchorPublicKeys("{not json").status).toBe("malformed"); |
| 207 | + }); |
| 208 | + |
| 209 | + it("malformed: valid JSON that is not an array", () => { |
| 210 | + expect(diagnoseAnchorPublicKeys(JSON.stringify(key())).status).toBe("malformed"); |
| 211 | + }); |
| 212 | + |
| 213 | + it("no_valid_entries: right shape, wrong field names -- the typo case", () => { |
| 214 | + const typod = { keyid: "k1", publicKeySpki: "x", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }; |
| 215 | + const d = diagnoseAnchorPublicKeys(JSON.stringify([typod])); |
| 216 | + expect(d).toMatchObject({ status: "no_valid_entries", keys: [], currentKeyId: null }); |
| 217 | + }); |
| 218 | + |
| 219 | + it("expired: valid entries, none current -- the rotation ran off the end", () => { |
| 220 | + const d = diagnoseAnchorPublicKeys(JSON.stringify([key({ notAfter: "2026-06-01T00:00:00.000Z" })])); |
| 221 | + expect(d).toMatchObject({ status: "expired", currentKeyId: null }); |
| 222 | + expect(d.keys).toHaveLength(1); // retired keys still published, so old anchors stay verifiable |
| 223 | + }); |
| 224 | + |
| 225 | + it("ambiguous_rotation: MORE than one current key -- the other half of currentAnchorKey's null", () => { |
| 226 | + // currentAnchorKey returns null for both zero and >1 current keys. Those are different operator problems |
| 227 | + // (publish a successor vs. retire one of two), and this is the only place they are told apart. |
| 228 | + const d = diagnoseAnchorPublicKeys(JSON.stringify([key(), key({ keyId: "k2" })])); |
| 229 | + expect(d).toMatchObject({ status: "ambiguous_rotation", currentKeyId: null, droppedEntries: 0 }); |
| 230 | + expect(d.keys).toHaveLength(2); |
| 231 | + }); |
| 232 | + |
| 233 | + it("counts droppedEntries even when the overall status is ok", () => { |
| 234 | + // One good key and one typo'd: without the count, the bad entry vanishes into a healthy-looking response. |
| 235 | + const typod = { keyid: "k2", publicKeySpki: "x", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }; |
| 236 | + const d = diagnoseAnchorPublicKeys(JSON.stringify([key(), typod])); |
| 237 | + expect(d).toMatchObject({ status: "ok", currentKeyId: "k1", droppedEntries: 1 }); |
| 238 | + }); |
| 239 | + |
| 240 | + it("SECURITY: never echoes the configured value back, whatever it is", () => { |
| 241 | + // LOOPOVER_LEDGER_ANCHOR_KEYS is served by an UNAUTHENTICATED endpoint. An operator who mis-pastes |
| 242 | + // LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY into it must not have the private half published by the very |
| 243 | + // diagnostic meant to help them. Probe assembled from fragments so this file never contains a |
| 244 | + // credential-shaped literal -- the convention forbidden-content.test.ts uses. |
| 245 | + const probe = ["-----BEGIN", " PRIVATE", " KEY-----", "MC4CAQAwBQYDK2VwBCIEIA", "-----END", " PRIVATE", " KEY-----"].join(""); |
| 246 | + for (const raw of [probe, `[${probe}]`, JSON.stringify([{ keyId: probe }])]) { |
| 247 | + expect(JSON.stringify(diagnoseAnchorPublicKeys(raw))).not.toContain("PRIVATE"); |
| 248 | + expect(JSON.stringify(diagnoseAnchorPublicKeys(raw))).not.toContain("MC4CAQAwBQYDK2VwBCIEIA"); |
| 249 | + } |
| 250 | + }); |
| 251 | + |
| 252 | + it("INVARIANT: parseAnchorPublicKeys is unchanged -- the scheduler's guard order still sees what it always did", () => { |
| 253 | + for (const raw of [undefined, "", "[]", "{not json", JSON.stringify([key()]), JSON.stringify([key({ notAfter: "2026-06-01T00:00:00.000Z" })])]) { |
| 254 | + expect(diagnoseAnchorPublicKeys(raw).keys).toEqual(parseAnchorPublicKeys(raw)); |
| 255 | + } |
| 256 | + }); |
| 257 | +}); |
0 commit comments