feat(signer): pluggable key sources (mnemonic and raw private key)
walletFromSeed is pure derivation and needs no Provider, keeping this
module network-free. MnemonicKeySource is the familiar default;
PrivateKeyKeySource is strictly safer since it controls one account
rather than the whole HD tree. describe() is asserted never to leak key
material.
Verified rather than assumed: walletFromSeed's private key prefix is
ed25519e_sk1 and its address is addr1... (Base, Mainnet default) - the
brief's regex already matched. The brief's test mnemonic passes BIP39
checksum validation.
Hardened beyond the brief after an edge-case pass:
- Mnemonic case: bip39's wordlist lookup is case-sensitive, so an
uppercase or mixed-case mnemonic threw outright. Casing carries no
BIP39 meaning, so lowercasing is safe - now applied, and verified to
derive the identical address rather than merely "some" address.
- PrivateKeyKeySource validated the trimmed key/address but stored the
original untrimmed value - the same knownAddresses() bug shape as
policy.ts (validate trimmed, store padded, silently fail every later
exact-string comparison). Now stores what was validated.
- Empty mnemonic input reported "got 1" word (''.split(' ') === ['']),
not "got 0" - message accuracy only, both branches already rejected.
Documented, not fixed: PrivateKeyKeySource cannot verify that `address`
is actually controlled by `privateKeyBech32`. That needs CML, and this
module deliberately imports only @lucid-evolution/wallet, whose one
raw-private-key function requires a live Provider. The failure is
bounded if misused - signing needs no address, so a mismatch surfaces as
an on-chain rejection, and policy.ts's own-address matching only ever
over-refuses on a wrong address, never under-refuses.
29 tests beyond the brief's 10. 5 of the fixes above were mutation-tested
(each reverted and confirmed to fail red before restoring the fix).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
371
packages/signer/test/keysource.test.ts
Normal file
371
packages/signer/test/keysource.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MnemonicKeySource, PrivateKeyKeySource } from '../src/keysource.ts';
|
||||
|
||||
// Throwaway test mnemonic. Controls no funds. Never use outside tests.
|
||||
const TEST_MNEMONIC =
|
||||
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||
|
||||
// A second throwaway fixture at the OTHER end of BIP39's valid word-count
|
||||
// range (24, vs TEST_MNEMONIC's 12). Every other test in this file only ever
|
||||
// exercises a 12-word mnemonic, which leaves VALID_WORD_COUNTS's other four
|
||||
// entries (15, 18, 21, 24) completely unexercised as *accepted* input — a
|
||||
// mutation dropping all of them but 12 would pass every other test in this
|
||||
// file. Deterministically generated from fixed, obviously-fake entropy
|
||||
// (bytes 0x00..0x1f) rather than randomly, so it is reproducible from that
|
||||
// description alone. Controls no funds. Never use outside tests.
|
||||
const TEST_MNEMONIC_24 =
|
||||
'abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware';
|
||||
|
||||
describe('MnemonicKeySource', () => {
|
||||
test('derives a bech32 private key and an addr1 address', () => {
|
||||
const id = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
assert.match(id.privateKeyBech32, /^ed25519e?_sk1/);
|
||||
assert.match(id.address, /^addr1/);
|
||||
});
|
||||
|
||||
test('derivation is deterministic', () => {
|
||||
const a = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
const b = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
assert.equal(a.address, b.address);
|
||||
assert.equal(a.privateKeyBech32, b.privateKeyBech32);
|
||||
});
|
||||
|
||||
test('a different account index yields a different address', () => {
|
||||
const a = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
||||
const b = new MnemonicKeySource(TEST_MNEMONIC, 1).load();
|
||||
assert.notEqual(a.address, b.address);
|
||||
});
|
||||
|
||||
test('tolerates surrounding whitespace and irregular spacing', () => {
|
||||
const messy = ` ${TEST_MNEMONIC.split(' ').join(' ')} \n`;
|
||||
assert.equal(new MnemonicKeySource(messy).load().address, new MnemonicKeySource(TEST_MNEMONIC).load().address);
|
||||
});
|
||||
|
||||
test('rejects a wrong word count', () => {
|
||||
assert.throws(() => new MnemonicKeySource('one two three'), /12, 15, 18, 21 or 24 words/);
|
||||
});
|
||||
|
||||
test('describe() never reveals the mnemonic', () => {
|
||||
const d = new MnemonicKeySource(TEST_MNEMONIC).describe();
|
||||
assert.ok(!d.includes('walk'), 'describe() must not leak key material');
|
||||
assert.match(d, /mnemonic/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrivateKeyKeySource', () => {
|
||||
test('returns the key and address it was constructed with', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
const id = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address).load();
|
||||
assert.equal(id.privateKeyBech32, derived.privateKeyBech32);
|
||||
assert.equal(id.address, derived.address);
|
||||
});
|
||||
|
||||
test('rejects a key that is not bech32 ed25519', () => {
|
||||
assert.throws(() => new PrivateKeyKeySource('nonsense', 'addr1abc'), /private key/i);
|
||||
});
|
||||
|
||||
test('rejects an address that is not addr1', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
assert.throws(() => new PrivateKeyKeySource(derived.privateKeyBech32, 'nope'), /address/i);
|
||||
});
|
||||
|
||||
test('describe() never reveals the key', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
const d = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address).describe();
|
||||
assert.ok(!d.includes(derived.privateKeyBech32), 'describe() must not leak key material');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge-case pass (beyond the brief) --------------------------------------
|
||||
//
|
||||
// The brief's 10 tests are a floor. Everything below hunts specifically for
|
||||
// the failure shape the last three tasks each shipped once: a defect that
|
||||
// produces a plausible-looking success instead of an error. For this module
|
||||
// that means: does any input ever derive a WRONG BUT VALID-LOOKING address or
|
||||
// key, rather than either the correct one or a loud failure?
|
||||
|
||||
describe('MnemonicKeySource — case is not part of BIP39, so normalising it must be safe', () => {
|
||||
// bip39's wordlist lookup (`wordlist.indexOf(word)`) is exact and
|
||||
// case-sensitive — confirmed directly against this workspace's installed
|
||||
// bip39 before writing this fix: an uppercase or mixed-case rendering of
|
||||
// TEST_MNEMONIC throws 'Invalid mnemonic' with NO normalisation applied.
|
||||
// That is a safe failure (loud, no derivation) but an unhelpful one, since
|
||||
// casing carries no BIP39 meaning — a word is identified purely by which of
|
||||
// the 2048 fixed wordlist entries it matches, never by how it happens to be
|
||||
// capitalised, so no two distinct valid mnemonics differ only by case.
|
||||
// Lowercasing before derivation therefore cannot make this module derive a
|
||||
// DIFFERENT wallet than the one the correctly-cased mnemonic would — pinned
|
||||
// below as deriving the SAME address, not merely "some" address.
|
||||
const CANONICAL = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
|
||||
test('an uppercase mnemonic derives the identical address and key as the canonical lowercase form', () => {
|
||||
const upper = new MnemonicKeySource(TEST_MNEMONIC.toUpperCase()).load();
|
||||
assert.equal(upper.address, CANONICAL.address);
|
||||
assert.equal(upper.privateKeyBech32, CANONICAL.privateKeyBech32);
|
||||
});
|
||||
|
||||
test('a mixed-case mnemonic derives the identical address and key as the canonical lowercase form', () => {
|
||||
const mixedWords = TEST_MNEMONIC.split(' ').map((w, i) => (i % 2 === 0 ? w.toUpperCase() : w));
|
||||
const mixed = new MnemonicKeySource(mixedWords.join(' ')).load();
|
||||
assert.equal(mixed.address, CANONICAL.address);
|
||||
assert.equal(mixed.privateKeyBech32, CANONICAL.privateKeyBech32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MnemonicKeySource — a wrong-but-plausible mnemonic', () => {
|
||||
// Right word count (12), every word individually present in the BIP39
|
||||
// wordlist, but the checksum encoded in the final word's spare bits does
|
||||
// not match the entropy in the first eleven words. Constructed by
|
||||
// replacing TEST_MNEMONIC's FIRST word only: the checksum bits live in the
|
||||
// last word, so tampering with any of the other eleven changes the entropy
|
||||
// while (for a non-adversarially-chosen replacement) almost certainly
|
||||
// failing the checksum — confirmed directly: bip39's own validateMnemonic()
|
||||
// rejects this exact string. Throwaway, controls nothing; it does not even
|
||||
// pass standard BIP39 validation, so no compliant wallet would derive from
|
||||
// it at all.
|
||||
const BAD_CHECKSUM_MNEMONIC =
|
||||
'bulk walk nut penalty hip pave soap entry language right filter choice';
|
||||
|
||||
test('a right-word-count, invalid-checksum mnemonic does not throw at construction time', () => {
|
||||
// Construction only checks word COUNT. Documents that the checksum
|
||||
// problem is invisible until load() is actually called.
|
||||
assert.doesNotThrow(() => new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC));
|
||||
});
|
||||
|
||||
test('...but throws at load() time, with a checksum-specific message', () => {
|
||||
const source = new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC);
|
||||
assert.throws(() => source.load(), /checksum/i);
|
||||
});
|
||||
|
||||
test('the checksum failure message reveals no word from the mnemonic', () => {
|
||||
const source = new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC);
|
||||
try {
|
||||
source.load();
|
||||
assert.fail('expected load() to throw');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
for (const word of BAD_CHECKSUM_MNEMONIC.split(' ')) {
|
||||
assert.ok(!message.includes(word), `error message must not contain the word "${word}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a mnemonic of real English words not all found in the BIP39 wordlist also throws, not derives', () => {
|
||||
// Distinct failure path from a checksum mismatch: this fails the
|
||||
// wordlist lookup itself (some of these words are not among the 2048
|
||||
// BIP39 entries), which is arguably the single most likely real mistake
|
||||
// — fat-fingering or misremembering a word — a user actually makes.
|
||||
const notAllInWordlist = 'apple banana cherry dragon eagle falcon guitar hammer island jungle kitten lantern';
|
||||
assert.throws(() => new MnemonicKeySource(notAllInWordlist).load(), /invalid mnemonic/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MnemonicKeySource — invisible Unicode characters between words', () => {
|
||||
// This exact class of bug hit policy.ts twice: .trim() and split(/\s+/) do
|
||||
// not treat every invisible character alike. NBSP (U+00A0) IS part of JS's
|
||||
// \s and IS covered by ECMAScript's WhiteSpace production, so it is already
|
||||
// handled correctly by .trim().split(/\s+/).join(' ') with no special
|
||||
// casing — confirmed below, not assumed. ZERO WIDTH SPACE (U+200B) is
|
||||
// Unicode category Cf (format), not Zs/WhiteSpace, so it is NOT matched by
|
||||
// \s and survives normalisation glued onto whichever word it sits next to.
|
||||
const NBSP = String.fromCharCode(0xa0);
|
||||
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||
const CANONICAL = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
|
||||
test('a non-breaking space between every word normalises exactly like a regular space', () => {
|
||||
const withNbsp = TEST_MNEMONIC.split(' ').join(NBSP);
|
||||
const derived = new MnemonicKeySource(withNbsp).load();
|
||||
assert.equal(derived.address, CANONICAL.address);
|
||||
assert.equal(derived.privateKeyBech32, CANONICAL.privateKeyBech32);
|
||||
});
|
||||
|
||||
test('a ZERO WIDTH SPACE glued onto a word is not silently ignored — it fails the wordlist match and throws', () => {
|
||||
// Unlike policy.ts's address Set (an exact-equality lookup against
|
||||
// caller-controlled strings, where a corrupted entry can silently miss a
|
||||
// match and get misclassified instead of rejected), BIP39 wordlist
|
||||
// lookup is an exact match against a small, fixed 2048-word list that
|
||||
// contains no invisible characters. A corrupted word cannot coincidentally
|
||||
// equal a different real word, so this fails loud instead of deriving a
|
||||
// different, plausible-looking wallet — confirmed here, not assumed.
|
||||
const words = TEST_MNEMONIC.split(' ');
|
||||
const corrupted = [words[0] + ZERO_WIDTH_SPACE, ...words.slice(1)].join(' ');
|
||||
const source = new MnemonicKeySource(corrupted);
|
||||
assert.throws(() => source.load(), /invalid mnemonic/i);
|
||||
});
|
||||
|
||||
test('a ZERO WIDTH SPACE used as the ONLY separator between two words fuses them, changing the word count and throwing at construction', () => {
|
||||
const words = TEST_MNEMONIC.split(' ');
|
||||
// Replace the space between word[0] and word[1] with a ZWSP: \s+ cannot
|
||||
// split on it, so the two words fuse into a single non-whitespace token,
|
||||
// dropping the apparent count from 12 to 11.
|
||||
const fused = [words[0] + ZERO_WIDTH_SPACE + words[1], ...words.slice(2)].join(' ');
|
||||
assert.throws(() => new MnemonicKeySource(fused), /got 11\./);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MnemonicKeySource — the empty-string-split gotcha (same shape as policy.ts\'s knownAddresses bug)', () => {
|
||||
// ''.split(' ') yields [''] (length 1), not [] (length 0). Both before and
|
||||
// after accounting for this, empty/blank input is correctly REJECTED
|
||||
// either way — this only pins that the reported count is accurate (0, not
|
||||
// the misleading 1), not a change in whether it fails.
|
||||
test('an empty string is reported as 0 words, not the misleading 1 a naive split would report', () => {
|
||||
assert.throws(() => new MnemonicKeySource(''), /got 0\./);
|
||||
});
|
||||
|
||||
test('a whitespace-only string is also reported as 0 words', () => {
|
||||
assert.throws(() => new MnemonicKeySource(' \n\t '), /got 0\./);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MnemonicKeySource — word-count breadth beyond 12', () => {
|
||||
// Every test above this one exercises only a 12-word mnemonic as VALID
|
||||
// input (12 also appears as an invalid COUNT case via 3/11/0-word inputs,
|
||||
// but never as one of the other three accepted lengths). A mutation that
|
||||
// narrowed VALID_WORD_COUNTS to just [12] would pass every test above this
|
||||
// one. TEST_MNEMONIC_24 closes that gap at the opposite end of the range.
|
||||
test('a valid 24-word mnemonic derives a bech32 private key and an addr1 address', () => {
|
||||
const id = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
||||
assert.match(id.privateKeyBech32, /^ed25519e?_sk1/);
|
||||
assert.match(id.address, /^addr1/);
|
||||
});
|
||||
|
||||
test('the 24-word mnemonic is deterministic, same as the 12-word case', () => {
|
||||
const a = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
||||
const b = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
||||
assert.equal(a.address, b.address);
|
||||
assert.equal(a.privateKeyBech32, b.privateKeyBech32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load() is idempotent — calling it twice on the SAME instance returns consistent results', () => {
|
||||
test('MnemonicKeySource.load() called twice on one instance agrees with itself', () => {
|
||||
const source = new MnemonicKeySource(TEST_MNEMONIC, 0);
|
||||
const first = source.load();
|
||||
const second = source.load();
|
||||
assert.deepEqual(first, second);
|
||||
});
|
||||
|
||||
test('PrivateKeyKeySource.load() called twice on one instance agrees with itself', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
const source = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address);
|
||||
const first = source.load();
|
||||
const second = source.load();
|
||||
assert.deepEqual(first, second);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrivateKeyKeySource — whitespace tolerance mirrors MnemonicKeySource, without weakening validation', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
|
||||
test('tolerates surrounding whitespace on both the key and the address, and stores the trimmed form', () => {
|
||||
// Regression guard for the exact shape of policy.ts's knownAddresses()
|
||||
// bug: validating the trimmed value but STORING the original padded one
|
||||
// would pass this constructor yet never match anything downstream by
|
||||
// exact string equality. Asserting the loaded value equals the clean,
|
||||
// untrimmed-source derivation (not just "does not throw") is what would
|
||||
// catch that.
|
||||
const padded = new PrivateKeyKeySource(` ${derived.privateKeyBech32} `, `\t${derived.address}\n`).load();
|
||||
assert.equal(padded.privateKeyBech32, derived.privateKeyBech32);
|
||||
assert.equal(padded.address, derived.address);
|
||||
});
|
||||
|
||||
test('a trailing ZERO WIDTH SPACE on the address is rejected, not silently accepted — trim() cannot strip it, and the regex is anchored', () => {
|
||||
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||
assert.throws(
|
||||
() => new PrivateKeyKeySource(derived.privateKeyBech32, derived.address + ZERO_WIDTH_SPACE),
|
||||
/address/i
|
||||
);
|
||||
});
|
||||
|
||||
test('a trailing ZERO WIDTH SPACE on the private key is rejected, not silently accepted', () => {
|
||||
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||
assert.throws(
|
||||
() => new PrivateKeyKeySource(derived.privateKeyBech32 + ZERO_WIDTH_SPACE, derived.address),
|
||||
/private key/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrivateKeyKeySource — validation boundaries a loose regex mutation could silently pass', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
|
||||
test('accepts the non-extended ed25519_sk1 form, not just ed25519e_sk1', () => {
|
||||
// walletFromSeed only ever produces the EXTENDED form (ed25519e_sk1...),
|
||||
// so nothing above this test ever exercises the regex's `e?` — every
|
||||
// prior key came from walletFromSeed. A power user importing a raw key
|
||||
// generated by e.g. `cardano-cli address key-gen` gets a NON-extended
|
||||
// key (ed25519_sk1...) instead. Constructed here by stripping the 'e'
|
||||
// from a real derived key: this only needs to be shaped like a
|
||||
// non-extended bech32 key for the regex under test, not a
|
||||
// cryptographically valid one, since the constructor performs no
|
||||
// cryptographic check (see the documented address/key gap above) — same
|
||||
// "fake but correctly shaped" approach the brief itself uses for
|
||||
// 'addr1abc'.
|
||||
const nonExtended = derived.privateKeyBech32.replace(/^ed25519e_sk1/, 'ed25519_sk1');
|
||||
assert.doesNotThrow(() => new PrivateKeyKeySource(nonExtended, derived.address));
|
||||
});
|
||||
|
||||
test('rejects a testnet-shaped address (addr_test1...) even though it starts with "addr"', () => {
|
||||
// Vector runs mainnet-only (see MnemonicKeySource, which hardcodes
|
||||
// network: 'Mainnet'), so an address for any other network is wrong
|
||||
// for this system, not merely differently formatted. A regex loosened
|
||||
// to `/^addr/` (matching only the four literal letters, not `addr1`
|
||||
// specifically) would let this through — a realistic operator mistake
|
||||
// (pasting a testnet address into a mainnet-only signer config), not
|
||||
// just a contrived string.
|
||||
const testnetShaped = 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w';
|
||||
assert.throws(() => new PrivateKeyKeySource(derived.privateKeyBech32, testnetShaped), /address/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrivateKeyKeySource — address/key correspondence is NOT verified (documented, deliberate gap)', () => {
|
||||
// See the long comment on PrivateKeyKeySource in keysource.ts for the full
|
||||
// reasoning. Short version: verifying this would need to derive a public
|
||||
// key (and, for a Base address, a stake credential too — a Base address is
|
||||
// not a function of the payment key alone) from the private key and
|
||||
// compare it against the address's embedded credential, which needs the
|
||||
// Cardano Multiplatform Library. This module deliberately imports ONLY
|
||||
// @lucid-evolution/wallet, and that package's one function taking a raw
|
||||
// private key (makeWalletFromPrivateKey) requires a live Provider — the
|
||||
// network capability this module exists to not have. Pinned here so the
|
||||
// gap is a visible, intentional decision, not a silent assumption.
|
||||
test('a syntactically valid key and a syntactically valid but unrelated address construct and load() without error', () => {
|
||||
const accountA = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
||||
const accountB = new MnemonicKeySource(TEST_MNEMONIC, 1).load();
|
||||
// accountA's key paired with accountB's address: independently valid,
|
||||
// mutually unrelated. Nothing in this module can detect that mismatch.
|
||||
const mismatched = new PrivateKeyKeySource(accountA.privateKeyBech32, accountB.address).load();
|
||||
assert.equal(mismatched.privateKeyBech32, accountA.privateKeyBech32);
|
||||
assert.equal(mismatched.address, accountB.address);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages never echo the rejected input, even when the input itself is invalid', () => {
|
||||
// Distinct from the describe()-leak tests above: this pins that the
|
||||
// CONSTRUCTOR's own validation errors never interpolate the value that was
|
||||
// rejected, on the principle that a string purporting to be a private key
|
||||
// should never be echoed back, valid or not.
|
||||
test('the rejected private key value itself never appears in the constructor error message', () => {
|
||||
const rejected = 'this-string-must-never-appear-in-the-thrown-message';
|
||||
try {
|
||||
new PrivateKeyKeySource(rejected, 'addr1abc');
|
||||
assert.fail('expected constructor to throw');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
assert.ok(!message.includes(rejected));
|
||||
}
|
||||
});
|
||||
|
||||
test('the rejected address value itself never appears in the constructor error message', () => {
|
||||
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||
const rejected = 'this-address-string-must-never-appear-in-the-thrown-message';
|
||||
try {
|
||||
new PrivateKeyKeySource(derived.privateKeyBech32, rejected);
|
||||
assert.fail('expected constructor to throw');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
assert.ok(!message.includes(rejected));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user