Ownership of a name is proven by holding a token of its category: an update is accepted when some output carries it. Nothing required that the certificate be the only such token. Whoever registered a name could mint a second one in the same transaction, a minting token for instance, sell the certificate on the market, and later publish an update the resolvers would accept. The buyer would hold the certificate and lose the name. A registration now counts only if its category is created by that very transaction, exactly one output carries it, and that output is an NFT with no capability and no fungible amount. Nothing meeting this can ever have a sibling. The same holds for TLD certificates, whose category proves the right to set a TLD's policy and to co-sign registrations under it. One definition, in resolver-web.js; bns.js and the TLD snapshot builder call it. Every valid registration on chipnet already satisfied the rule: the live registry is the same 71 names and 21 TLDs before and after. Found while writing the delivery escrow, which had to forbid exactly this.
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: [{ txid: category, vout: 0 }], 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);
|
|
});
|