diff --git a/src/config.ts b/src/config.ts index 0306ab7..654fab2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,10 +69,7 @@ addDefaults(config, { message: "OK", } }, - validityCheck: { - userAgent: null, - userAgentR: null, - }, + requestValidatorRules: [], userCounterURL: null, userCounterRatio: 10, newLeafURLs: null, @@ -271,9 +268,11 @@ function loadFromEnv(config: SBSConfig, prefix = "") { config[key] = value === "true"; } else if (key === "newLeafURLs") { config[key] = [value]; + } else if (key === "requestValidatorRules") { + config[key] = JSON.parse(value) ?? []; } else { config[key] = value; } } } -} \ No newline at end of file +} diff --git a/src/routes/postBranding.ts b/src/routes/postBranding.ts index 5900849..b6d426d 100644 --- a/src/routes/postBranding.ts +++ b/src/routes/postBranding.ts @@ -18,8 +18,9 @@ import { checkBanStatus } from "../utils/checkBan"; import axios from "axios"; import { getMaxResThumbnail } from "../utils/youtubeApi"; import { getVideoDetails } from "../utils/getVideoDetails"; -import { canSubmitDeArrow, validSubmittedData } from "../utils/permissions"; +import { canSubmitDeArrow } from "../utils/permissions"; import { parseUserAgent } from "../utils/userAgent"; +import { isRequestInvalid } from "../utils/requestValidator"; enum BrandingType { Title, @@ -58,8 +59,21 @@ export async function postBranding(req: Request, res: Response) { const hashedIP = await getHashCache(getIP(req) + config.globalSalt as IPAddress); const isBanned = await checkBanStatus(hashedUserID, hashedIP); - if (!validSubmittedData(userAgent, req.headers["user-agent"])) { - Logger.warn(`Rejecting submission based on invalid data: ${hashedUserID} ${videoID} ${videoDuration} ${userAgent} ${req.headers["user-agent"]}`); + if (isRequestInvalid({ + userAgent, + userAgentHeader: req.headers["user-agent"], + videoDuration, + videoID, + userID, + service, + dearrow: { + title, + thumbnail, + downvote, + }, + endpoint: "dearrow-postBranding", + })) { + Logger.warn(`Dearrow submission rejected by request validator: ${hashedUserID} ${videoID} ${videoDuration} ${userAgent} ${req.headers["user-agent"]} ${title.title} ${thumbnail.timestamp}`); res.status(200).send("OK"); return; } diff --git a/src/routes/postCasual.ts b/src/routes/postCasual.ts index fae7471..b8a3cc4 100644 --- a/src/routes/postCasual.ts +++ b/src/routes/postCasual.ts @@ -14,6 +14,8 @@ import { QueryCacher } from "../utils/queryCacher"; import { acquireLock } from "../utils/redisLock"; import { checkBanStatus } from "../utils/checkBan"; import { canSubmitDeArrow } from "../utils/permissions"; +import { isRequestInvalid } from "../utils/requestValidator"; +import { parseUserAgent } from "../utils/userAgent"; interface ExistingVote { UUID: BrandingUUID; @@ -22,6 +24,7 @@ interface ExistingVote { export async function postCasual(req: Request, res: Response) { const { videoID, userID, downvote } = req.body as CasualVoteSubmission; + const userAgent = req.body.userAgent ?? parseUserAgent(req.get("user-agent")) ?? ""; let categories = req.body.categories as CasualCategory[]; const title = (req.body.title as string)?.toLowerCase(); const service = getService(req.body.service); @@ -36,6 +39,19 @@ export async function postCasual(req: Request, res: Response) { return res.status(400).send("Bad Request"); } + if (isRequestInvalid({ + userID, + videoID, + userAgent, + userAgentHeader: req.headers["user-agent"], + casualCategories: categories, + service, + endpoint: "dearrow-postCasual", + })) { + Logger.warn(`Casual vote rejected by request validator: ${userAgent} ${req.headers["user-agent"]} ${categories} ${service} ${videoID}`); + return res.status(200).send("OK"); + } + try { const hashedUserID = await getHashCache(userID); const hashedVideoID = await getHashCache(videoID, 1); @@ -134,4 +150,4 @@ async function handleExistingVotes(videoID: VideoID, service: Service, titleID: [videoID, service, titleID, hashedUserID, hashedIP, category, now]); return false; -} \ No newline at end of file +} diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 0b828fd..deb3908 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -20,11 +20,12 @@ import { parseUserAgent } from "../utils/userAgent"; import { getService } from "../utils/getService"; import axios from "axios"; import { vote } from "./voteOnSponsorTime"; -import { canSubmit, canSubmitGlobal, validSubmittedData } from "../utils/permissions"; +import { canSubmit, canSubmitGlobal } from "../utils/permissions"; import { getVideoDetails, videoDetails } from "../utils/getVideoDetails"; import * as youtubeID from "../utils/youtubeID"; import { acquireLock } from "../utils/redisLock"; import { checkBanStatus } from "../utils/checkBan"; +import { isRequestInvalid } from "../utils/requestValidator"; type CheckResult = { pass: boolean, @@ -509,8 +510,17 @@ export async function postSkipSegments(req: Request, res: Response): Promise { return privateDB.prepare("run", @@ -15,7 +16,7 @@ function logUserNameChange(userID: string, newUserName: string, oldUserName: str export async function setUsername(req: Request, res: Response): Promise { const userIDInput = req.query.userID as string; - const adminUserIDInput = req.query.adminUserID as string; + const adminUserIDInput = req.query.adminUserID as string | undefined; let userName = req.query.username as string; let hashedUserID: HashedUserID; @@ -29,16 +30,22 @@ export async function setUsername(req: Request, res: Response): Promise 0) { return res.sendStatus(200); } - timings.push(Date.now()); - if (await isUserBanned(hashedUserID)) { return res.sendStatus(200); } @@ -80,8 +83,6 @@ export async function setUsername(req: Request, res: Response): Promise)[key]; - if (check === null) { - continue; - } else { - switch (key) { - case "userAgent": - if (!userAgent.match(check)) { - return false; - } - break; - case "userAgentR": - if (!userAgentR.match(new RegExp(check))) { - return false; - } - break; - } - } - } - - return true; -} - export async function canSubmitGlobal(userID: HashedUserID): Promise { const oldSubmitterOrAllowedPromise = oldSubmitterOrAllowed(userID); @@ -158,4 +130,4 @@ export async function canSubmitDeArrow(userID: HashedUserID): Promise boolean; +type CompiledSegmentCheck = (input: IncomingSegment) => boolean; +type InputExtractor = ( + input: RequestValidatorInput, +) => string | number | undefined | null; +type SegmentExtractor = (input: IncomingSegment) => string | undefined | null; +type BooleanRules = "titleOriginal" | "thumbnailOriginal" | "dearrowDownvote"; +type RuleEntry = + | [Exclude, ValidatorPattern] + | [BooleanRules, boolean]; + +let compiledRules: CompiledValidityCheck; + +function patternToRegex(pattern: ValidatorPattern): RegExp { + return typeof pattern === "string" + ? new RegExp(pattern, "i") + : new RegExp(...pattern); +} + +function compilePattern( + pattern: ValidatorPattern, + extractor: InputExtractor, +): CompiledValidityCheck { + const regex = patternToRegex(pattern); + + return (input: RequestValidatorInput) => { + const field = extractor(input); + if (field == undefined) return false; + return regex.test(String(field)); + }; +} + +function compileSegmentPattern( + pattern: ValidatorPattern, + extractor: SegmentExtractor, +): CompiledSegmentCheck { + const regex = patternToRegex(pattern); + + return (input: IncomingSegment) => { + const field = extractor(input); + if (field == undefined) return false; + return regex.test(field); + }; +} + +export function compileRules( + ruleDefinitions: RequestValidatorRule[], +): CompiledValidityCheck { + if (ruleDefinitions.length === 0) return () => false; + + const rules: CompiledValidityCheck[] = []; + for (const ruleDefinition of ruleDefinitions) { + const ruleComponents: CompiledValidityCheck[] = []; + const segmentRuleComponents: CompiledSegmentCheck[] = []; + for (const [ruleKey, rulePattern] of Object.entries( + ruleDefinition, + ) as RuleEntry[]) { + switch (ruleKey) { + case "userAgent": + ruleComponents.push( + compilePattern(rulePattern, (input) => input.userAgent), + ); + break; + case "userAgentHeader": + ruleComponents.push( + compilePattern( + rulePattern, + (input) => input.userAgentHeader, + ), + ); + break; + case "videoDuration": + ruleComponents.push( + compilePattern( + rulePattern, + (input) => input.videoDuration, + ), + ); + break; + case "videoID": + ruleComponents.push( + compilePattern(rulePattern, (input) => input.videoID), + ); + break; + case "userID": + ruleComponents.push( + compilePattern(rulePattern, (input) => input.userID), + ); + break; + case "service": + ruleComponents.push( + compilePattern(rulePattern, (input) => input.service), + ); + break; + case "startTime": + segmentRuleComponents.push( + compileSegmentPattern( + rulePattern, + (input) => input.segment[0], + ), + ); + break; + case "endTime": + segmentRuleComponents.push( + compileSegmentPattern( + rulePattern, + (input) => input.segment[1], + ), + ); + break; + case "category": + segmentRuleComponents.push( + compileSegmentPattern( + rulePattern, + (input) => input.category, + ), + ); + break; + case "actionType": + segmentRuleComponents.push( + compileSegmentPattern( + rulePattern, + (input) => input.actionType, + ), + ); + break; + case "description": + segmentRuleComponents.push( + compileSegmentPattern( + rulePattern, + (input) => input.description, + ), + ); + break; + case "title": + ruleComponents.push( + compilePattern( + rulePattern, + (input) => input.dearrow?.title?.title, + ), + ); + break; + case "titleOriginal": + ruleComponents.push( + (input) => + input.dearrow?.title?.original === rulePattern, + ); + break; + case "thumbnailTimestamp": + ruleComponents.push( + compilePattern( + rulePattern, + (input) => input.dearrow?.thumbnail?.timestamp, + ), + ); + break; + case "thumbnailOriginal": + ruleComponents.push( + (input) => + input.dearrow?.thumbnail?.original === rulePattern, + ); + break; + case "dearrowDownvote": + ruleComponents.push( + (input) => input.dearrow?.downvote === rulePattern, + ); + break; + case "newUsername": + ruleComponents.push( + compilePattern( + rulePattern, + (input) => input.newUsername, + ), + ); + break; + case "endpoint": + ruleComponents.push( + compilePattern(rulePattern, (input) => input.endpoint), + ); + break; + case "casualCategory": { + const regex = patternToRegex(rulePattern); + ruleComponents.push((input) => { + if (input.casualCategories === undefined) { + return false; + } + for (const category of input.casualCategories) { + if (regex.test(category)) return true; + } + return false; + }); + break; + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustive: never = ruleKey; + } + } + } + if (segmentRuleComponents.length > 0) { + ruleComponents.push((input) => { + if (input.segments === undefined) return false; + for (const segment of input.segments) { + let result = true; + for (const rule of segmentRuleComponents) { + if (!rule(segment)) { + result = false; + break; + } + } + if (result) return true; + } + return false; + }); + } + rules.push((input) => { + for (const rule of ruleComponents) { + if (!rule(input)) return false; + } + return true; + }); + } + return (input) => { + for (const rule of rules) { + if (rule(input)) return true; + } + return false; + }; +} + +export function isRequestInvalid(input: RequestValidatorInput) { + compiledRules ??= compileRules(config.requestValidatorRules); + return compiledRules(input); +} diff --git a/test/cases/requestValidator.ts b/test/cases/requestValidator.ts new file mode 100644 index 0000000..979c170 --- /dev/null +++ b/test/cases/requestValidator.ts @@ -0,0 +1,735 @@ +import assert from "assert"; +import { RequestValidatorRule } from "../../src/types/config.model"; +import { ActionType, Category } from "../../src/types/segments.model"; +import { + CompiledValidityCheck, + compileRules, +} from "../../src/utils/requestValidator"; + +describe("Request validator", () => { + describe("single simple rule", () => { + const ruleset: RequestValidatorRule[] = [ + { + // rules are case insensitive by default + userID: "^[a-z]+$", + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + userID: "asdfg", + }), + ); + }); + it("case insensitive match", () => { + assert.ok( + compiledRuleset({ + userID: "asDfg", + }), + ); + }); + it("simple expected no match", () => { + assert.ok( + !compiledRuleset({ + userID: "125aaa", + }), + ); + }); + it("missing field - no match", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + }); + + describe("single case sensitive rule", () => { + const ruleset: RequestValidatorRule[] = [ + { + // tuple patterns allow setting regex flags + userID: ["^[a-z]+$", ""], + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + userID: "asdfg", + }), + ); + }); + it("different casing", () => { + assert.ok( + !compiledRuleset({ + userID: "asDfg", + }), + ); + }); + it("extra field match", () => { + assert.ok( + compiledRuleset({ + userID: "asdfg", + userAgent: "Mozilla/5.0", + }), + ); + }); + it("simple expected no match", () => { + assert.ok( + !compiledRuleset({ + userID: "125aaa", + }), + ); + }); + it("missing field - no match", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + }); + + describe("2-pattern rule", () => { + const ruleset: RequestValidatorRule[] = [ + { + userID: ["^[a-z]+$", ""], + userAgent: "^Mozilla/5\\.0", + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + userID: "asdfg", + userAgent: "Mozilla/5.0 Chromeium/213.7", + }), + ); + }); + it("only matching one pattern - fail #1", () => { + assert.ok( + !compiledRuleset({ + userID: "asDfg", + userAgent: "Mozilla/5.0 Chromeium/213.7", + }), + ); + }); + it("only matching one pattern - fail #2", () => { + assert.ok( + !compiledRuleset({ + userID: "asdfg", + userAgent: "ReVanced/20.07.39", + }), + ); + }); + it("missing one of the fields - fail #1", () => { + assert.ok( + !compiledRuleset({ + userID: "asdfg", + }), + ); + }); + it("missing one of the fields - fail #2", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0 Chromeium/213.7", + }), + ); + }); + it("missing all fields - fail", () => { + assert.ok( + !compiledRuleset({ + videoDuration: 21.37, + }), + ); + }); + }); + + describe("1-pattern segment rule", () => { + const ruleset: RequestValidatorRule[] = [ + { + description: "mini_bomba", + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + ], + }), + ); + }); + it("match on one of multiple segments", () => { + assert.ok( + compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaaa", + }, + ], + }), + ); + }); + it("match on one of multiple segments with other missing field", () => { + assert.ok( + compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + { + segment: ["1", "2"], + category: "sponsor" as Category, + actionType: "skip" as ActionType, + }, + ], + }), + ); + }); + it("no match with one segment", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaa", + }, + ], + }), + ); + }); + it("no match with multiple segments", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaa", + }, + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "bbbbb", + }, + ], + }), + ); + }); + it("one segment missing field", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "sponsor" as Category, + actionType: "skip" as ActionType, + }, + ], + }), + ); + }); + it("multiple segments missing field", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "sponsor" as Category, + actionType: "skip" as ActionType, + }, + { + segment: ["1", "2"], + category: "filler" as Category, + actionType: "skip" as ActionType, + }, + ], + }), + ); + }); + it("zero segments", () => { + assert.ok( + !compiledRuleset({ + segments: [], + }), + ); + }); + it("missing segments", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + }); + + describe("2-pattern segment rule", () => { + const ruleset: RequestValidatorRule[] = [ + { + description: "mini_bomba", + startTime: "\\.\\d", + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + segments: [ + { + segment: ["1.1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + ], + }), + ); + }); + it("match on one of multiple segments", () => { + assert.ok( + compiledRuleset({ + segments: [ + { + segment: ["1.1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaaa", + }, + ], + }), + ); + }); + it("match on one of multiple segments with other missing field", () => { + assert.ok( + compiledRuleset({ + segments: [ + { + segment: ["1.1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + { + segment: ["1", "2"], + category: "sponsor" as Category, + actionType: "skip" as ActionType, + }, + ], + }), + ); + }); + it("no match with one segment #1", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaa", + }, + ], + }), + ); + }); + it("no match with one segment #2", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1.1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaa", + }, + ], + }), + ); + }); + it("no match with one segment #2", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + ], + }), + ); + }); + it("no match with multiple segments", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaa", + }, + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "bbbbb", + }, + ], + }), + ); + }); + it("no match with multiple segments with partial matches", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1.1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "aaaa", + }, + { + segment: ["1", "2"], + category: "chapter" as Category, + actionType: "chapter" as ActionType, + description: "mini_bomba", + }, + ], + }), + ); + }); + it("one segment missing field", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "sponsor" as Category, + actionType: "skip" as ActionType, + }, + ], + }), + ); + }); + it("multiple segments missing field", () => { + assert.ok( + !compiledRuleset({ + segments: [ + { + segment: ["1", "2"], + category: "sponsor" as Category, + actionType: "skip" as ActionType, + }, + { + segment: ["1", "2"], + category: "filler" as Category, + actionType: "skip" as ActionType, + }, + ], + }), + ); + }); + it("zero segments", () => { + assert.ok( + !compiledRuleset({ + segments: [], + }), + ); + }); + it("missing segments", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + }); + + describe("boolean rule", () => { + const ruleset: RequestValidatorRule[] = [ + { + dearrowDownvote: true, + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + dearrow: { + downvote: true, + }, + }), + ); + }); + it("simple expected no match", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + }, + }), + ); + }); + it("missing field - no match", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + }); + + describe("mixed type rules", () => { + const ruleset: RequestValidatorRule[] = [ + { + titleOriginal: true, + title: "mini_bomba", + }, + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("simple expected match", () => { + assert.ok( + compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mini_bomba gaming", + original: true, + }, + }, + }), + ); + }); + it("simple expected no match", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mschae restaurant", + original: false, + }, + }, + }), + ); + }); + it("partial match #1", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mini_bomba gaming", + original: false, + }, + }, + }), + ); + }); + it("partial match #2", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mschae restaurant", + original: true, + }, + }, + }), + ); + }); + it("missing field - no match #1", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + it("missing field - no match #2", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + } + }), + ); + }); + it("missing field - no match #3", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + thumbnail: { + original: true, + } + } + }), + ); + }); + }); + + describe("two-rule ruleset", () => { + const ruleset: RequestValidatorRule[] = [ + { + titleOriginal: true, + }, + { + title: "mini_bomba", + } + ]; + let compiledRuleset: CompiledValidityCheck; + + before(() => { + compiledRuleset = compileRules(ruleset); + }); + + it("matches both", () => { + assert.ok( + compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mini_bomba gaming", + original: true, + }, + }, + }), + ); + }); + it("matches 1", () => { + assert.ok( + compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mini_bomba gaming", + original: false, + }, + }, + }), + ); + }); + it("matches 2", () => { + assert.ok( + compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mschae restaurant", + original: true, + }, + }, + }), + ); + }); + it("no match", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + title: { + title: "mschae restaurant", + original: false, + }, + }, + }), + ); + }); + it("missing both fields #1", () => { + assert.ok( + !compiledRuleset({ + userAgent: "Mozilla/5.0", + }), + ); + }); + it("missing both fields #2", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + } + }), + ); + }); + it("missing both fields #3", () => { + assert.ok( + !compiledRuleset({ + dearrow: { + downvote: false, + thumbnail: { + original: true, + } + } + }), + ); + }); + }); +});