blocklist-tx.js builds the three payloads and a transaction that takes its inputs from the list address and returns its change there, which is both the authority proof and the funding of the next entry. blocklist-admin.mjs publishes a report and chains the flag on its change, so the pair needs no waiting; it refuses free-text reasons and declines 'stolen' in this phase. bns.js recognises the opcodes and reduces a list through the shared reducer rather than a second copy of the rules. The tests cover the validity rules, the authority rule against a stranger who only pays the address, ordering inside one block, and the contract that matters most: no subscription, or a subscription to an empty list, changes no answer.
319 lines
19 KiB
JavaScript
319 lines
19 KiB
JavaScript
// The blocklist consumer (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md).
|
|
//
|
|
// No network. The rules are a pure reduction of one list address's history, so
|
|
// every case is a synthetic history. The last block proves the contract the
|
|
// roadmap asks for: a resolver with NO subscription is the pre-blocklist
|
|
// resolver, and a subscription to an EMPTY list changes nothing either.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
NETWORKS, KNOWN_OPS, BLOCK_REASONS, sha256, addressToLockHex, addressToScripthash,
|
|
reduceBlocklist, blocklistAddress, buildBlocklists, withBlockFlag, resolveName, buildIndexFromSnapshot,
|
|
} from "../src/lib/resolver-web.js";
|
|
import * as legacy from "../src/lib/bns.js";
|
|
import { reportPayload, blockPayload, unblockPayload, buildListTx, LIST_MIN_CHANGE_SAT } from "../src/lib/blocklist-tx.js";
|
|
import { addressToLockingBytecode, addressToScripthash as libauthScripthash } from "../src/lib/chain.js";
|
|
import { InsufficientFundsError } from "../src/lib/register-tx.js";
|
|
|
|
const NET = NETWORKS.chipnet;
|
|
const LIST = NET.blocklists[0];
|
|
const LOCK = addressToLockHex(LIST.beacon);
|
|
const OTHER_LOCK = addressToLockHex(NET.operatorAddress); // someone who is not the list
|
|
|
|
// ---------- fixture builders ----------
|
|
const hex = (s) => Buffer.from(s, "utf8").toString("hex");
|
|
const txid = (n) => String(n).padStart(4, "0").repeat(16);
|
|
const opReturn = (payload) => {
|
|
const data = Buffer.from("BNS1" + JSON.stringify(payload), "utf8");
|
|
const push = data.length <= 75 ? Buffer.from([data.length]) : Buffer.from([0x4c, data.length]);
|
|
return "6a" + Buffer.concat([push, data]).toString("hex");
|
|
};
|
|
const out = (n, lock) => ({ n, value: 0.0001, scriptPubKey: { hex: lock } });
|
|
const spend = (id, vout = 0) => ({ txid: txid(id), vout });
|
|
// A coin arriving at the list address from outside: gives the list something to spend.
|
|
const FUND = (id) => ({ txid: txid(id), vin: [{ txid: "f".repeat(64), vout: 0 }], vout: [out(0, LOCK)] });
|
|
// An entry published by the list: spends a list coin, change back to the list.
|
|
const ENTRY = (id, payload, spends) => ({ txid: txid(id), vin: spends, vout: [out(0, LOCK), { n: 1, value: 0, scriptPubKey: { hex: opReturn(payload) } }] });
|
|
// The same payload from a stranger who merely pays dust to the list address.
|
|
const STRANGER = (id, payload) => ({ txid: txid(id), vin: [{ txid: "e".repeat(64), vout: 0 }], vout: [out(0, LOCK), { n: 1, value: 0, scriptPubKey: { hex: opReturn(payload) } }, out(2, OTHER_LOCK)] });
|
|
|
|
const RPT = (names, r = "phishing", t = "block", extra = {}) => ({ o: "RPT", t, n: names, ...(r ? { r } : {}), ...extra });
|
|
const BLK = (n, p, r = "phishing", extra = {}) => ({ o: "BLK", n, r, p: txid(p), ...extra });
|
|
const UBK = (n, p) => ({ o: "UBK", n, p: txid(p) });
|
|
|
|
function reduce(entries) {
|
|
const history = entries.map(([tx, height]) => ({ tx_hash: tx.txid, height }));
|
|
const txs = new Map(entries.map(([tx]) => [tx.txid, tx]));
|
|
return reduceBlocklist({ history, txs, address: LIST.beacon, source: LIST.name });
|
|
}
|
|
|
|
// ---------- primitives ----------
|
|
|
|
test("sha256 matches the platform implementation", () => {
|
|
for (const len of [0, 1, 55, 56, 63, 64, 65, 119, 120, 257, 4096]) {
|
|
const b = Buffer.alloc(len, len % 251);
|
|
assert.equal(Buffer.from(sha256(new Uint8Array(b))).toString("hex"), createHash("sha256").update(b).digest("hex"), `length ${len}`);
|
|
}
|
|
});
|
|
|
|
test("address -> scripthash agrees with the pinned beacons and with libauth", () => {
|
|
assert.equal(addressToScripthash(NET.beacon), NET.beaconScripthash);
|
|
assert.equal(addressToScripthash(NET.tldBeacon), NET.tldBeaconScripthash);
|
|
for (const l of NET.blocklists) {
|
|
assert.equal(addressToScripthash(l.beacon), l.scripthash, "the table's scripthash must match its address");
|
|
assert.equal(addressToScripthash(l.beacon), libauthScripthash(l.beacon));
|
|
}
|
|
assert.throws(() => addressToLockHex(LIST.beacon.slice(0, -1) + "x"), /checksum/);
|
|
assert.throws(() => addressToLockHex("qrrnwvuasq5fy63mdvp3umx5qfavtu7mnq950rdssw"), /prefix/);
|
|
});
|
|
|
|
test("the three opcodes exist in both codecs, and an RPT's name list parses", () => {
|
|
for (const op of ["RPT", "BLK", "UBK"]) { assert.ok(KNOWN_OPS.has(op)); assert.ok(legacy.KNOWN_OPS.has(op)); }
|
|
assert.deepEqual([...KNOWN_OPS].sort(), [...legacy.KNOWN_OPS].sort());
|
|
assert.deepEqual(legacy.parseOpReturn(opReturn(RPT(["a.x", "b.x"]))).n, ["a.x", "b.x"]);
|
|
assert.equal(legacy.parseOpReturn(opReturn({ o: "RPT", t: "block", n: [1, 2] })), null, "a name list of non-strings is not a payload");
|
|
});
|
|
|
|
// ---------- the rules ----------
|
|
|
|
test("an empty list flags nothing", () => {
|
|
assert.equal(reduce([]).size, 0);
|
|
assert.equal(reduce([[FUND(1), 100]]).size, 0, "a funding payment is not an entry");
|
|
});
|
|
|
|
test("a block with a valid report flags the name", () => {
|
|
const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"], "phishing", "block", { e: "sia://evidence" }), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 101]]);
|
|
const f = flags.get("scam.x");
|
|
assert.equal(f.reason, "phishing");
|
|
assert.equal(f.reportTxid, txid(2));
|
|
assert.equal(f.txid, txid(3));
|
|
assert.equal(f.source, LIST.name);
|
|
assert.equal(f.evidence, "sia://evidence", "the report's evidence is carried when the flag has none");
|
|
});
|
|
|
|
test("a block whose report is missing is ignored", () => {
|
|
assert.equal(reduce([[FUND(1), 100], [ENTRY(3, BLK("scam.x", 2), [spend(1)]), 101]]).size, 0);
|
|
});
|
|
|
|
test("a block whose reason differs from its report is ignored", () => {
|
|
const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"], "phishing"), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2, "malware"), [spend(2)]), 102]]);
|
|
assert.equal(flags.size, 0);
|
|
});
|
|
|
|
test("a block for a name its report does not cover is ignored", () => {
|
|
const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("innocent.x", 2), [spend(2)]), 102]]);
|
|
assert.equal(flags.size, 0);
|
|
});
|
|
|
|
test("reasons outside the fixed vocabulary are never applied", () => {
|
|
assert.equal(BLOCK_REASONS.has("offensive"), false);
|
|
const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["x.x"], "offensive"), [spend(1)]), 101], [ENTRY(3, BLK("x.x", 2, "offensive"), [spend(2)]), 102]]);
|
|
assert.equal(flags.size, 0);
|
|
});
|
|
|
|
test("one report can flag several names", () => {
|
|
const flags = reduce([
|
|
[FUND(1), 100], [ENTRY(2, RPT(["a.x", "b.x", "legacy"], "malware"), [spend(1)]), 101],
|
|
[ENTRY(3, BLK("a.x", 2, "malware"), [spend(2)]), 102], [ENTRY(4, BLK("b.x", 2, "malware"), [spend(3)]), 102], [ENTRY(5, BLK("legacy", 2, "malware"), [spend(4)]), 102],
|
|
]);
|
|
assert.deepEqual([...flags.keys()].sort(), ["a.x", "b.x", "legacy.bch"], "a bare .bch label is flagged under its canonical name");
|
|
});
|
|
|
|
// ---------- authority ----------
|
|
|
|
test("entries from someone who only PAYS the list address are ignored", () => {
|
|
// The stranger's transactions are in the address history (they pay it dust), and well-formed.
|
|
const flags = reduce([[FUND(1), 100], [STRANGER(2, RPT(["victim.x"])), 101], [STRANGER(3, BLK("victim.x", 2)), 102]]);
|
|
assert.equal(flags.size, 0);
|
|
});
|
|
|
|
test("a stranger's block cannot ride on the list's own report, nor the reverse", () => {
|
|
const strangerBlock = reduce([[FUND(1), 100], [ENTRY(2, RPT(["victim.x"]), [spend(1)]), 101], [STRANGER(3, BLK("victim.x", 2)), 102]]);
|
|
assert.equal(strangerBlock.size, 0);
|
|
const strangerReport = reduce([[FUND(1), 100], [STRANGER(2, RPT(["victim.x"])), 101], [ENTRY(3, BLK("victim.x", 2), [spend(1)]), 102]]);
|
|
assert.equal(strangerReport.size, 0, "the list's block needs the list's report");
|
|
});
|
|
|
|
test("a stranger cannot unblock", () => {
|
|
const flags = reduce([
|
|
[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102],
|
|
[STRANGER(4, RPT(["scam.x"], null, "unblock")), 103], [STRANGER(5, UBK("scam.x", 4)), 104],
|
|
]);
|
|
assert.equal(flags.get("scam.x").reason, "phishing");
|
|
});
|
|
|
|
// ---------- unblock and ordering ----------
|
|
|
|
test("an unblock with a valid unblock report lifts the flag; latest entry wins", () => {
|
|
const base = [[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102]];
|
|
const lifted = reduce([...base, [ENTRY(4, RPT(["scam.x"], null, "unblock"), [spend(3)]), 110], [ENTRY(5, UBK("scam.x", 4), [spend(4)]), 111]]);
|
|
assert.equal(lifted.size, 0);
|
|
|
|
const again = reduce([...base, [ENTRY(4, RPT(["scam.x"], null, "unblock"), [spend(3)]), 110], [ENTRY(5, UBK("scam.x", 4), [spend(4)]), 111],
|
|
[ENTRY(6, RPT(["scam.x"], "malware"), [spend(5)]), 120], [ENTRY(7, BLK("scam.x", 6, "malware"), [spend(6)]), 121]]);
|
|
assert.equal(again.get("scam.x").reason, "malware");
|
|
});
|
|
|
|
test("an unblock cannot cite a block report, or no report", () => {
|
|
const base = [[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102]];
|
|
assert.equal(reduce([...base, [ENTRY(5, UBK("scam.x", 2), [spend(3)]), 111]]).size, 1, "the block report does not authorise an unblock");
|
|
assert.equal(reduce([...base, [ENTRY(5, UBK("scam.x", 77), [spend(3)]), 111]]).size, 1);
|
|
});
|
|
|
|
test("report and block in one block apply whatever their txid order", () => {
|
|
// The BLK's txid sorts BEFORE its report's. It spends the report's change, so causal order fixes it —
|
|
// and the report is looked up by txid, so it would be found even without that.
|
|
const chained = reduce([[FUND(1), 100], [ENTRY(9, RPT(["scam.x"]), [spend(1)]), 105], [ENTRY(2, BLK("scam.x", 9), [spend(9)]), 105]]);
|
|
assert.equal(chained.get("scam.x").reportTxid, txid(9));
|
|
const unchained = reduce([[FUND(1), 100], [FUND(8), 100], [ENTRY(9, RPT(["scam.x"]), [spend(1)]), 105], [ENTRY(2, BLK("scam.x", 9), [spend(8)]), 105]]);
|
|
assert.equal(unchained.get("scam.x").reportTxid, txid(9));
|
|
});
|
|
|
|
test("unconfirmed entries apply, like every other BNS message", () => {
|
|
const flags = reduce([[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 0], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), -1]]);
|
|
assert.equal(flags.get("scam.x").reason, "phishing");
|
|
});
|
|
|
|
// ---------- where a list is read from ----------
|
|
|
|
test("a list's address comes from its name's bl record, else from the table", () => {
|
|
const other = NET.operatorAddress;
|
|
assert.equal(blocklistAddress(LIST, null), LIST.beacon);
|
|
assert.equal(blocklistAddress(LIST, new Map([[LIST.name, { records: { bl: other } }]])), other, "the name can rotate the address");
|
|
assert.equal(blocklistAddress(LIST, new Map([[LIST.name, { records: { bl: "not-an-address" } }]])), LIST.beacon, "a malformed record falls back");
|
|
assert.equal(blocklistAddress(LIST, new Map([[LIST.name, { records: {} }]])), LIST.beacon);
|
|
});
|
|
|
|
// ---------- the write side ----------
|
|
|
|
test("payload builders enforce the vocabulary, the report link and the size", () => {
|
|
assert.throws(() => reportPayload({ type: "block", names: ["a.x"], reason: "offensive" }), /reason must be one of/);
|
|
assert.throws(() => reportPayload({ type: "ban", names: ["a.x"], reason: "phishing" }), /block.*unblock/);
|
|
assert.throws(() => reportPayload({ type: "block", names: [], reason: "phishing" }), /at least one name/);
|
|
assert.throws(() => blockPayload({ name: "a.x", reason: "phishing", reportTxid: "nope" }), /64-hex/);
|
|
assert.throws(() => reportPayload({ type: "block", names: ["a.x"], reason: "phishing", evidence: "x".repeat(200) }), /too large/);
|
|
const text = (b) => Buffer.from(b).toString("utf8");
|
|
assert.equal(text(reportPayload({ type: "unblock", names: ["A.X"] })), 'BNS1{"o":"RPT","t":"unblock","n":["a.x"]}');
|
|
assert.equal(text(blockPayload({ name: "scam.x", reason: "phishing", reportTxid: txid(2) })), `BNS1{"o":"BLK","n":"scam.x","r":"phishing","p":"${txid(2)}"}`);
|
|
assert.equal(text(unblockPayload({ name: "scam.x", reportTxid: txid(4) })), `BNS1{"o":"UBK","n":"scam.x","p":"${txid(4)}"}`);
|
|
});
|
|
|
|
test("buildListTx spends from the list and returns its change to the list", () => {
|
|
const lock = addressToLockingBytecode(LIST.beacon);
|
|
const coin = { txid: txid(1), vout: 0, satoshis: 20_000n, address: LIST.beacon, lockingBytecode: lock };
|
|
const foreign = { txid: txid(5), vout: 0, satoshis: 900_000n, address: NET.operatorAddress, lockingBytecode: addressToLockingBytecode(NET.operatorAddress) };
|
|
const built = buildListTx({ payload: reportPayload({ type: "block", names: ["scam.x"], reason: "phishing" }), listAddress: LIST.beacon, utxos: [foreign, coin], addressToLockingBytecode });
|
|
assert.equal(built.transaction.inputs.length, 1);
|
|
assert.equal(Buffer.from(built.transaction.inputs[0].outpointTransactionHash).toString("hex"), txid(1), "a coin from another address is never used: it would not prove authority");
|
|
assert.equal(Buffer.from(built.transaction.outputs[0].lockingBytecode).toString("hex"), LOCK);
|
|
assert.equal(built.changeSats + built.chainFeeSats, 20_000n);
|
|
assert.ok(built.changeSats >= LIST_MIN_CHANGE_SAT);
|
|
const next = built.next(txid(2));
|
|
assert.deepEqual([next.txid, next.vout, next.satoshis], [txid(2), 0, built.changeSats], "the change is the next entry's input");
|
|
assert.throws(() => buildListTx({ payload: new Uint8Array(10), listAddress: LIST.beacon, utxos: [foreign], addressToLockingBytecode }), InsufficientFundsError);
|
|
assert.throws(() => buildListTx({ payload: new Uint8Array(10), listAddress: LIST.beacon, utxos: [{ ...coin, satoshis: 700n }], addressToLockingBytecode }), InsufficientFundsError);
|
|
});
|
|
|
|
test("what the builders write, the reducer accepts", () => {
|
|
// Round trip through the real payload encoders rather than hand-written JSON.
|
|
const asEntry = (id, payloadBytes, spends) => {
|
|
const data = Buffer.from(payloadBytes);
|
|
const push = data.length <= 75 ? Buffer.from([data.length]) : Buffer.from([0x4c, data.length]);
|
|
return { txid: txid(id), vin: spends, vout: [out(0, LOCK), { n: 1, value: 0, scriptPubKey: { hex: "6a" + Buffer.concat([push, data]).toString("hex") } }] };
|
|
};
|
|
const flags = reduce([
|
|
[FUND(1), 100],
|
|
[asEntry(2, reportPayload({ type: "block", names: ["scam.x"], reason: "scam-financial", evidence: "sia://abc" }), [spend(1)]), 101],
|
|
[asEntry(3, blockPayload({ name: "scam.x", reason: "scam-financial", reportTxid: txid(2) }), [spend(2)]), 101],
|
|
]);
|
|
assert.equal(flags.get("scam.x").reason, "scam-financial");
|
|
});
|
|
|
|
// ---------- the contract: no subscription, or an empty list, changes nothing ----------
|
|
|
|
const cert = (name, category) => ({ n: 0, value: 0.00001, scriptPubKey: { hex: "76a914" + "11".repeat(20) + "88ac", address: "bchtest:owner" }, tokenData: { category, amount: "0", nft: { capability: "none", commitment: hex(name) } } });
|
|
const REG = (id, name, category) => ({ txid: txid(id), vin: [], vout: [cert(name, category), { n: 1, value: 0, scriptPubKey: { hex: opReturn({ o: "REG", n: name, r: { u: "https://site.example" } }) } }] });
|
|
|
|
// A fake electrum client: the name beacon, the TLD beacon (empty), and the list address.
|
|
function fakeClient({ names, list = [], failList = false }) {
|
|
const byScripthash = new Map([
|
|
[NET.beaconScripthash, names],
|
|
[NET.tldBeaconScripthash, []],
|
|
[addressToScripthash(LIST.beacon), list],
|
|
]);
|
|
const txs = new Map([...names, ...list].map(([tx]) => [tx.txid, tx]));
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
close() {},
|
|
async call(method, [arg]) {
|
|
calls.push(method + ":" + String(arg).slice(0, 8));
|
|
if (method === "blockchain.scripthash.get_history") {
|
|
if (failList && arg === addressToScripthash(LIST.beacon)) throw new Error("server refused");
|
|
return (byScripthash.get(arg) || []).map(([tx, height]) => ({ tx_hash: tx.txid, height }));
|
|
}
|
|
if (method === "blockchain.transaction.get") return txs.get(arg);
|
|
throw new Error("unexpected " + method);
|
|
},
|
|
};
|
|
}
|
|
const NAMES = [[REG(20, "good.x", "a".repeat(64)), 90], [REG(21, "scam.x", "b".repeat(64)), 91]];
|
|
const FLAGGED = [[FUND(1), 100], [ENTRY(2, RPT(["scam.x"]), [spend(1)]), 101], [ENTRY(3, BLK("scam.x", 2), [spend(2)]), 102]];
|
|
|
|
test("no subscription: resolveName is exactly the pre-blocklist resolver", async () => {
|
|
const history = NAMES.map(([tx, height]) => ({ tx_hash: tx.txid, height }));
|
|
const before = buildIndexFromSnapshot({ snapshot: { history, txs: Object.fromEntries(NAMES.map(([tx]) => [tx.txid, tx])) } });
|
|
for (const blocklists of [[], false]) {
|
|
const client = fakeClient({ names: NAMES, list: FLAGGED });
|
|
for (const n of ["good.x", "scam.x"]) {
|
|
assert.deepEqual(await resolveName(n, { client, blocklists }), before.get(n));
|
|
}
|
|
assert.equal(await resolveName("absent.x", { client, blocklists }), null);
|
|
assert.ok(!client.calls.some((c) => c.endsWith(addressToScripthash(LIST.beacon).slice(0, 8))), "the list address is never even queried");
|
|
}
|
|
});
|
|
|
|
test("default subscription to an EMPTY list: same answers, one extra history call", async () => {
|
|
const unsubscribed = fakeClient({ names: NAMES });
|
|
const subscribed = fakeClient({ names: NAMES, list: [] });
|
|
for (const n of ["good.x", "scam.x", "absent.x"]) {
|
|
assert.deepEqual(await resolveName(n, { client: subscribed }), await resolveName(n, { client: unsubscribed, blocklists: [] }));
|
|
}
|
|
const good = await resolveName("good.x", { client: subscribed });
|
|
assert.equal("blocked" in good, false, "an unflagged entry carries no blocklist field at all");
|
|
const listCalls = subscribed.calls.filter((c) => c === "blockchain.scripthash.get_history:" + addressToScripthash(LIST.beacon).slice(0, 8));
|
|
assert.equal(listCalls.length, 3, "one history call per resolution, and no transaction fetch for an empty list");
|
|
});
|
|
|
|
test("a flagged name resolves with the flag attached; others are untouched", async () => {
|
|
const client = fakeClient({ names: NAMES, list: FLAGGED });
|
|
const scam = await resolveName("scam.x", { client });
|
|
assert.equal(scam.blocked, true);
|
|
assert.equal(scam.blockReason, "phishing");
|
|
assert.equal(scam.blockReportTxid, txid(2));
|
|
assert.equal(scam.blockedBy, LIST.name);
|
|
assert.deepEqual(scam.records, { u: "https://site.example" }, "the name still resolves: what to do with the flag is the client's policy");
|
|
assert.equal("blocked" in (await resolveName("good.x", { client })), false);
|
|
});
|
|
|
|
test("a list that cannot be read never stops a name from resolving", async () => {
|
|
const client = fakeClient({ names: NAMES, list: FLAGGED, failList: true });
|
|
const scam = await resolveName("scam.x", { client });
|
|
assert.equal(scam.category, "b".repeat(64));
|
|
assert.equal("blocked" in scam, false);
|
|
});
|
|
|
|
test("buildBlocklists: union over lists, and withBlockFlag never mutates the index", async () => {
|
|
const client = fakeClient({ names: NAMES, list: FLAGGED });
|
|
const flags = await buildBlocklists({ client, lists: [LIST, { name: "second-list.x", beacon: LIST.beacon }] });
|
|
assert.equal(flags.get("scam.x").sources.length, 2, "two lists flag it: both are kept, the first is shown");
|
|
assert.equal(flags.get("scam.x").source, LIST.name);
|
|
const entry = { name: "scam.x", records: {} };
|
|
const flagged = withBlockFlag(entry, flags);
|
|
assert.equal(flagged.blocked, true);
|
|
assert.equal("blocked" in entry, false);
|
|
assert.equal(withBlockFlag({ name: "good.x" }, flags).blocked, undefined);
|
|
assert.equal((await buildBlocklists({ client, lists: [] })).size, 0);
|
|
});
|