diff --git a/resources/cosmetics/cosmetics.json b/resources/cosmetics/cosmetics.json index b731ecca5..6c6c8e495 100644 --- a/resources/cosmetics/cosmetics.json +++ b/resources/cosmetics/cosmetics.json @@ -123,98 +123,406 @@ }, "flag": { "layers": { - "a": { "name": "center_circle" }, - "b": { "name": "center_hline" }, - "c": { "name": "center_vline" }, - "d": { "name": "center_star" }, - "e": { "name": "center_flower" }, - "f": { "name": "flower_tl" }, - "g": { "name": "flower_tc" }, - "h": { "name": "flower_tr" }, - "i": { "name": "diag_br" }, - "j": { "name": "diag_bl" }, - "k": { "name": "frame" }, - "l": { "name": "full" }, - "m": { "name": "triangle_tl" }, - "n": { "name": "triangle_bl" }, - "o": { "name": "triangle_tr" }, - "p": { "name": "triangle_br" }, - "q": { "name": "half_l" }, - "r": { "name": "half_r" }, - "s": { "name": "half_t" }, - "t": { "name": "half_b" }, - "u": { "name": "mini_tr_bl" }, - "v": { "name": "mini_tr_br" }, - "w": { "name": "mini_tr_tl" }, - "x": { "name": "mini_tr_tr" }, - "y": { "name": "triangle_t" }, - "z": { "name": "triangle_l" }, - "aa": { "name": "triangle_b" }, - "ab": { "name": "triangle_r" }, - "ac": { "name": "tricolor_l" }, - "ad": { "name": "tricolor_c" }, - "ae": { "name": "tricolor_r" }, - "af": { "name": "tricolor_t" }, - "ag": { "name": "tricolor_m" }, - "ah": { "name": "tricolor_b" }, - "ai": { "name": "nato_emblem" }, - "aj": { "name": "eu_star" }, - "ak": { "name": "laurel_wreath" }, - "al": { "name": "ofm_2025" }, - "am": { "name": "octagram" }, - "an": { "name": "octagram_2" }, - "ao": { "name": "og" }, - "ap": { "name": "og_plus" }, - "aq": { "name": "beta_tester" }, - "ar": { "name": "beta_tester_circle" }, - "as": { "name": "rocket" }, - "at": { "name": "rocket_mini" }, - "au": { "name": "translator" }, - "av": { "name": "admin_shield" }, - "aw": { "name": "admin_shield_r" }, - "ax": { "name": "admin_evan" } + "a": { + "name": "center_circle", + "role_group": "donor" + }, + "b": { + "name": "center_hline", + "role_group": "donor" + }, + "c": { + "name": "center_vline", + "role_group": "donor" + }, + "d": { + "name": "center_star", + "role_group": "donor" + }, + "e": { + "name": "center_flower", + "role_group": "donor" + }, + "f": { + "name": "flower_tl", + "role_group": "donor" + }, + "g": { + "name": "flower_tc", + "role_group": "donor" + }, + "h": { + "name": "flower_tr", + "role_group": "donor" + }, + "i": { + "name": "diag_br", + "role_group": "donor" + }, + "j": { + "name": "diag_bl", + "role_group": "donor" + }, + "k": { + "name": "frame" + }, + "l": { + "name": "full" + }, + "m": { + "name": "triangle_tl", + "role_group": "donor" + }, + "n": { + "name": "triangle_bl", + "role_group": "donor" + }, + "o": { + "name": "triangle_tr", + "role_group": "donor" + }, + "p": { + "name": "triangle_br", + "role_group": "donor" + }, + "q": { + "name": "half_l", + "role_group": "donor" + }, + "r": { + "name": "half_r", + "role_group": "donor" + }, + "s": { + "name": "half_t", + "role_group": "donor" + }, + "t": { + "name": "half_b", + "role_group": "donor" + }, + "u": { + "name": "mini_tr_bl", + "role_group": "donor" + }, + "v": { + "name": "mini_tr_br", + "role_group": "donor" + }, + "w": { + "name": "mini_tr_tl", + "role_group": "donor" + }, + "x": { + "name": "mini_tr_tr", + "role_group": "donor" + }, + "y": { + "name": "triangle_t", + "role_group": "donor" + }, + "z": { + "name": "triangle_l", + "role_group": "donor" + }, + "aa": { + "name": "triangle_b", + "role_group": "donor" + }, + "ab": { + "name": "triangle_r", + "role_group": "donor" + }, + "ac": { + "name": "tricolor_l", + "role_group": "donor" + }, + "ad": { + "name": "tricolor_c", + "role_group": "donor" + }, + "ae": { + "name": "tricolor_r", + "role_group": "donor" + }, + "af": { + "name": "tricolor_t", + "role_group": "donor" + }, + "ag": { + "name": "tricolor_m", + "role_group": "donor" + }, + "ah": { + "name": "tricolor_b", + "role_group": "donor" + }, + "ai": { + "name": "nato_emblem", + "role_group": "donor" + }, + "aj": { + "name": "eu_star", + "role_group": "donor" + }, + "ak": { + "name": "laurel_wreath", + "role_group": "donor" + }, + "al": { + "name": "ofm_2025", + "role_group": "donor" + }, + "am": { + "name": "octagram", + "role_group": "donor" + }, + "an": { + "name": "octagram_2", + "role_group": "donor" + }, + "ao": { + "name": "og", + "role_group": "donor" + }, + "ap": { + "name": "og_plus", + "role_group": "donor" + }, + "aq": { + "name": "beta_tester", + "role_group": "donor" + }, + "ar": { + "name": "beta_tester_circle", + "role_group": "donor" + }, + "as": { + "name": "rocket", + "role_group": "donor" + }, + "at": { + "name": "rocket_mini", + "role_group": "donor" + }, + "au": { + "name": "translator", + "role_group": "donor" + }, + "av": { + "name": "admin_shield", + "role_group": "donor" + }, + "aw": { + "name": "admin_shield_r", + "role_group": "donor" + }, + "ax": { + "name": "admin_evan", + "role_group": "donor" + } }, "color": { - "a": { "color": "#ff0000", "name": "red" }, - "b": { "color": "#ffa500", "name": "orange" }, - "c": { "color": "#ffff00", "name": "yellow" }, - "d": { "color": "#008000", "name": "green" }, - "e": { "color": "#00ffff", "name": "cyan" }, - "f": { "color": "#0000ff", "name": "blue" }, - "g": { "color": "#000000", "name": "black" }, - "h": { "color": "#ffffff", "name": "white" }, - "i": { "color": "#800080", "name": "purple" }, - "j": { "color": "#ff69b4", "name": "hotpink" }, - "k": { "color": "#a52a2a", "name": "brown" }, - "l": { "color": "#808080", "name": "gray" }, - "m": { "color": "#20b2aa", "name": "teal" }, - "n": { "color": "#ff6347", "name": "tomato" }, - "o": { "color": "#4682b4", "name": "steelblue" }, - "p": { "color": "#90ee90", "name": "lightgreen" }, - "q": { "color": "#8b0000", "name": "darkred" }, - "r": { "color": "#191970", "name": "navy" }, - "s": { "color": "#ffd700", "name": "gold" }, - "t": { "color": "#add8e6", "name": "lightblue" }, - "u": { "color": "#f5f5dc", "name": "beige" }, - "v": { "color": "#ffb6c1", "name": "lightpink" }, - "w": { "color": "#708090", "name": "slategray" }, - "x": { "color": "#00ff7f", "name": "springgreen" }, - "y": { "color": "#dc143c", "name": "crimson" }, - "z": { "color": "#ffbf00", "name": "amber" }, - "0": { "color": "#3d9970", "name": "olive_green" }, - "1": { "color": "#87ceeb", "name": "sky_blue" }, - "2": { "color": "#6a5acd", "name": "slate_blue" }, - "3": { "color": "#ff66cc", "name": "rose_pink" }, - "4": { "color": "#36454f", "name": "charcoal" }, - "5": { "color": "#fffff0", "name": "ivory" }, - "A": { "color": "rainbow", "name": "rainbow" }, - "B": { "color": "bright-rainbow", "name": "bright_rainbow" }, - "C": { "color": "gold-glow", "name": "gold_glow" }, - "D": { "color": "silver-glow", "name": "silver_glow" }, - "E": { "color": "copper-glow", "name": "copper_glow" }, - "F": { "color": "neon", "name": "neon" }, - "G": { "color": "lava", "name": "lava" }, - "H": { "color": "water", "name": "water" } + "a": { + "color": "#ff0000", + "name": "red", + "role_group": "donor" + }, + "b": { + "color": "#ffa500", + "name": "orange", + "role_group": "donor" + }, + "c": { + "color": "#ffff00", + "name": "yellow", + "role_group": "donor" + }, + "d": { + "color": "#008000", + "name": "green", + "role_group": "donor" + }, + "e": { + "color": "#00ffff", + "name": "cyan", + "role_group": "donor" + }, + "f": { + "color": "#0000ff", + "name": "blue", + "role_group": "donor" + }, + "g": { + "color": "#000000", + "name": "black", + "role_group": "donor" + }, + "h": { + "color": "#ffffff", + "name": "white", + "role_group": "donor" + }, + "i": { + "color": "#800080", + "name": "purple", + "role_group": "donor" + }, + "j": { + "color": "#ff69b4", + "name": "hotpink", + "role_group": "donor" + }, + "k": { + "color": "#a52a2a", + "name": "brown", + "role_group": "donor" + }, + "l": { + "color": "#808080", + "name": "gray", + "role_group": "donor" + }, + "m": { + "color": "#20b2aa", + "name": "teal", + "role_group": "donor" + }, + "n": { + "color": "#ff6347", + "name": "tomato", + "role_group": "donor" + }, + "o": { + "color": "#4682b4", + "name": "steelblue", + "role_group": "donor" + }, + "p": { + "color": "#90ee90", + "name": "lightgreen", + "role_group": "donor" + }, + "q": { + "color": "#8b0000", + "name": "darkred", + "role_group": "donor" + }, + "r": { + "color": "#191970", + "name": "navy", + "role_group": "donor" + }, + "s": { + "color": "#ffd700", + "name": "gold", + "role_group": "donor" + }, + "t": { + "color": "#add8e6", + "name": "lightblue", + "role_group": "donor" + }, + "u": { + "color": "#f5f5dc", + "name": "beige", + "role_group": "donor" + }, + "v": { + "color": "#ffb6c1", + "name": "lightpink", + "role_group": "donor" + }, + "w": { + "color": "#708090", + "name": "slategray", + "role_group": "donor" + }, + "x": { + "color": "#00ff7f", + "name": "springgreen", + "role_group": "donor" + }, + "y": { + "color": "#dc143c", + "name": "crimson", + "role_group": "donor" + }, + "z": { + "color": "#ffbf00", + "name": "amber", + "role_group": "donor" + }, + "0": { + "color": "#3d9970", + "name": "olive_green", + "role_group": "donor" + }, + "1": { + "color": "#87ceeb", + "name": "sky_blue", + "role_group": "donor" + }, + "2": { + "color": "#6a5acd", + "name": "slate_blue", + "role_group": "donor" + }, + "3": { + "color": "#ff66cc", + "name": "rose_pink", + "role_group": "donor" + }, + "4": { + "color": "#36454f", + "name": "charcoal", + "role_group": "donor" + }, + "5": { + "color": "#fffff0", + "name": "ivory", + "role_group": "donor" + }, + "A": { + "color": "rainbow", + "name": "rainbow", + "role_group": "donor" + }, + "B": { + "color": "bright-rainbow", + "name": "bright_rainbow", + "role_group": "donor" + }, + "C": { + "color": "gold-glow", + "name": "gold_glow", + "role_group": "donor" + }, + "D": { + "color": "silver-glow", + "name": "silver_glow", + "role_group": "donor" + }, + "E": { + "color": "copper-glow", + "name": "copper_glow", + "role_group": "donor" + }, + "F": { + "color": "neon", + "name": "neon", + "role_group": "donor" + }, + "G": { + "color": "lava", + "name": "lava", + "role_group": "donor" + }, + "H": { + "color": "water", + "name": "water", + "role_group": "donor" + } } } } diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 226242967..bb8d29b1c 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -1,3 +1,4 @@ +import { base64url } from "jose"; import type { TemplateResult } from "lit"; import { html, LitElement, render } from "lit"; import { customElement, query, state } from "lit/decorators.js"; @@ -369,7 +370,10 @@ export function generatePreviewDataUrl( height?: number, ): string { // Calculate canvas size - const decoder = new PatternDecoder(pattern ?? DEFAULT_PATTERN_B64); + const decoder = new PatternDecoder( + pattern ?? DEFAULT_PATTERN_B64, + base64url.decode, + ); const scaledWidth = decoder.scaledWidth(); const scaledHeight = decoder.scaledHeight(); diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 07d97b126..1586b208e 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -17,6 +17,8 @@ export const CosmeticsSchema = z.object({ z.string(), z.object({ name: z.string(), + role_group: z.string().optional(), + flares: z.array(z.string()).optional(), }), ), color: z.record( @@ -24,6 +26,8 @@ export const CosmeticsSchema = z.object({ z.object({ color: z.string(), name: z.string(), + role_group: z.string().optional(), + flares: z.array(z.string()).optional(), }), ), }), diff --git a/src/core/PatternDecoder.ts b/src/core/PatternDecoder.ts index 167732de3..1311aec44 100644 --- a/src/core/PatternDecoder.ts +++ b/src/core/PatternDecoder.ts @@ -1,5 +1,3 @@ -import { base64url } from "jose"; - export class PatternDecoder { private bytes: Uint8Array; @@ -7,8 +5,11 @@ export class PatternDecoder { readonly width: number; readonly scale: number; - constructor(base64: string) { - this.bytes = base64url.decode(base64); + constructor( + base64: string, + base64urlDecode: (input: Uint8Array | string) => Uint8Array, + ) { + this.bytes = base64urlDecode(base64); if (this.bytes.length < 3) { throw new Error( diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 5970deae8..5b726ea74 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,3 +1,4 @@ +import { base64url } from "jose"; import { z } from "zod/v4"; import quickChatData from "../../resources/QuickChat.json" with { type: "json" }; import { @@ -190,7 +191,7 @@ export const RequiredPatternSchema = z .refine( (val) => { try { - new PatternDecoder(val); + new PatternDecoder(val, base64url.decode); return true; } catch (e) { console.error(JSON.stringify(e.message, null, 2)); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 72ed071e5..6063acd81 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1,3 +1,4 @@ +import { base64url } from "jose"; import { Config } from "../configuration/Config"; import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player } from "../Schemas"; @@ -163,7 +164,7 @@ export class PlayerView { this.decoder = this.cosmetics.pattern === undefined ? undefined - : new PatternDecoder(this.cosmetics.pattern); + : new PatternDecoder(this.cosmetics.pattern, base64url.decode); } patternDecoder(): PatternDecoder | undefined { diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 3f84a5f53..bc7125a3d 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -2,7 +2,10 @@ import { Cosmetics } from "../core/CosmeticSchemas"; import { PatternDecoder } from "../core/PatternDecoder"; export class PrivilegeChecker { - constructor(private cosmetics: Cosmetics) {} + constructor( + private cosmetics: Cosmetics, + private b64urlDecode: (base64: string) => Uint8Array, + ) {} isPatternAllowed( base64: string, @@ -14,7 +17,7 @@ export class PrivilegeChecker { if (found === undefined) { try { // Ensure that the pattern will not throw for clients - new PatternDecoder(base64); + new PatternDecoder(base64, this.b64urlDecode); } catch (e) { // Pattern is invalid return "invalid"; @@ -55,4 +58,94 @@ export class PrivilegeChecker { return "restricted"; } + + isCustomFlagAllowed( + flag: string, + roles: readonly string[] | undefined, + flares: readonly string[] | undefined, + ): true | "restricted" | "invalid" { + if (!flag.startsWith("!")) return "invalid"; + const code = flag.slice(1); + if (!code) return "invalid"; + const segments = code.split("_"); + if (segments.length === 0) return "invalid"; + + const MAX_LAYERS = 6; // Maximum number of layers allowed + if (segments.length > MAX_LAYERS) return "invalid"; + + const superFlare = flares?.includes("flag:*") ?? false; + + for (const segment of segments) { + const [layerKey, colorKey] = segment.split("-"); + if (!layerKey || !colorKey) return "invalid"; + const layer = this.cosmetics.flag.layers[layerKey]; + const color = this.cosmetics.flag.color[colorKey]; + if (!layer || !color) return "invalid"; + + // Super-flare bypasses all restrictions + if (superFlare) { + continue; + } + + // Check layer restrictions + const layerSpec = layer; + let layerAllowed = false; + if (!layerSpec.role_group && !layerSpec.flares) { + layerAllowed = true; + } else { + // By role + if (layerSpec.role_group) { + const allowedRoles = + this.cosmetics.role_groups[layerSpec.role_group] || []; + if (roles?.some((r) => allowedRoles.includes(r))) { + layerAllowed = true; + } + } + // By flare + if ( + layerSpec.flares && + flares?.some((f) => layerSpec.flares?.includes(f)) + ) { + layerAllowed = true; + } + // By named flag:layer:{name} + if (flares?.includes(`flag:layer:${layerSpec.name}`)) { + layerAllowed = true; + } + } + + // Check color restrictions + const colorSpec = color; + let colorAllowed = false; + if (!colorSpec.role_group && !colorSpec.flares) { + colorAllowed = true; + } else { + // By role + if (colorSpec.role_group) { + const allowedRoles = + this.cosmetics.role_groups[colorSpec.role_group] || []; + if (roles?.some((r) => allowedRoles.includes(r))) { + colorAllowed = true; + } + } + // By flare + if ( + colorSpec.flares && + flares?.some((f) => colorSpec.flares?.includes(f)) + ) { + colorAllowed = true; + } + // By named flag:color:{name} + if (flares?.includes(`flag:color:${colorSpec.name}`)) { + colorAllowed = true; + } + } + + // If either part is restricted, block + if (!(layerAllowed && colorAllowed)) { + return "restricted"; + } + } + return true; + } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index e27d91cbb..04de85e5b 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; import ipAnonymize from "ip-anonymize"; +import { base64url } from "jose"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; @@ -44,7 +45,7 @@ export function startWorker() { const gm = new GameManager(config, log); - const privilegeChecker = new PrivilegeChecker(COSMETICS); + const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode); if (config.otelEnabled()) { initWorkerMetrics(gm); @@ -369,7 +370,18 @@ export function startWorker() { // Check if the flag is allowed if (clientMsg.flag !== undefined) { - // TODO: Implement custom flag validation + if (clientMsg.flag.startsWith("!")) { + const allowed = privilegeChecker.isCustomFlagAllowed( + clientMsg.flag, + roles, + flares, + ); + if (allowed !== true) { + log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); + ws.close(1002, `Custom flag ${allowed}`); + return; + } + } } // Check if the pattern is allowed diff --git a/tests/server/Privilege.customFlag.test.ts b/tests/server/Privilege.customFlag.test.ts new file mode 100644 index 000000000..330f00f30 --- /dev/null +++ b/tests/server/Privilege.customFlag.test.ts @@ -0,0 +1,188 @@ +import type { Cosmetics } from "../../src/core/CosmeticSchemas"; +import { PrivilegeChecker } from "../../src/server/Privilege"; + +describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => { + const dummyPatternDecoder = (_base64: string) => { + throw new Error("Method not implemented"); + }; + + const mockCosmetics: Cosmetics = { + role_groups: { + donor: ["role_donor"], + admin: ["role_admin"], + }, + patterns: {}, + flag: { + layers: { + a: { + name: "chocolate", + role_group: "donor", + flares: ["cosmetic:flags"], + }, + b: { name: "center_hline" }, + c: { name: "admin_layer", role_group: "admin" }, + }, + color: { + a: { color: "#ff0000", name: "red", role_group: "admin" }, + b: { color: "#00ff00", name: "green" }, + c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] }, + }, + }, + }; + + const checker = new PrivilegeChecker(mockCosmetics, dummyPatternDecoder); + + it("allowed: unrestricted layer/color", () => { + expect(checker.isCustomFlagAllowed("!b-b", [], [])).toBe(true); + }); + + it("restricted: donor layer without role", () => { + expect(checker.isCustomFlagAllowed("!a-b", [], [])).toBe("restricted"); + }); + + it("allowed: donor layer with donor role", () => { + expect(checker.isCustomFlagAllowed("!a-b", ["role_donor"], [])).toBe(true); + }); + + it("allowed: donor layer with correct flare", () => { + expect(checker.isCustomFlagAllowed("!a-b", [], ["cosmetic:flags"])).toBe( + true, + ); + }); + + it("restricted: admin color without role", () => { + expect(checker.isCustomFlagAllowed("!b-a", [], [])).toBe("restricted"); + }); + + it("allowed: admin color with admin role", () => { + expect(checker.isCustomFlagAllowed("!b-a", ["role_admin"], [])).toBe(true); + }); + + it("allowed: color with correct flare", () => { + expect(checker.isCustomFlagAllowed("!b-c", [], ["cosmetic:blue"])).toBe( + true, + ); + }); + + it("invalid: non-existent layer", () => { + expect(checker.isCustomFlagAllowed("!zzz-a", ["role_donor"], [])).toBe( + "invalid", + ); + }); + + it("invalid: non-existent color", () => { + expect(checker.isCustomFlagAllowed("!a-zzz", ["role_donor"], [])).toBe( + "invalid", + ); + }); + + it("allowed: superFlare allows all listed", () => { + expect(checker.isCustomFlagAllowed("!a-a", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!b-b", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!c-a", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!a-c", [], ["flag:*"])).toBe(true); + }); + + it("invalid: superFlare does not allow non-existent", () => { + expect(checker.isCustomFlagAllowed("!zzz-zzz", [], ["flag:*"])).toBe( + "invalid", + ); + }); + it("allowed: flare flag:layer:chocolate allows chocolate layer", () => { + expect( + checker.isCustomFlagAllowed("!a-b", [], ["flag:layer:chocolate"]), + ).toBe(true); + }); + it("allowed: flare flag:color:blue allows blue color", () => { + expect(checker.isCustomFlagAllowed("!b-c", [], ["flag:color:blue"])).toBe( + true, + ); + }); + it("restricted: only color flare, layer still restricted", () => { + expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); + it("restricted: only layer flare, color still restricted", () => { + expect(checker.isCustomFlagAllowed("!c-a", [], ["cosmetic:flags"])).toBe( + "restricted", + ); + }); + it("allowed: layer by role, color by flare", () => { + // layer a: role_group donor, color c: flares ["cosmetic:blue"] + expect( + checker.isCustomFlagAllowed("!a-c", ["role_donor"], ["cosmetic:blue"]), + ).toBe(true); + }); + it("restricted: layer by role, color by flare (missing flare)", () => { + expect(checker.isCustomFlagAllowed("!a-c", ["role_donor"], [])).toBe( + "restricted", + ); + }); + it("restricted: layer by role, color by flare (missing role)", () => { + expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); + it("allowed: layer by flare, color by role", () => { + // layer a: flares ["cosmetic:flags"], color a: role_group admin + expect( + checker.isCustomFlagAllowed("!a-a", ["role_admin"], ["cosmetic:flags"]), + ).toBe(true); + }); + it("restricted: layer by flare, color by role (missing flare)", () => { + expect(checker.isCustomFlagAllowed("!a-a", ["role_admin"], [])).toBe( + "restricted", + ); + }); + it("restricted: layer by flare, color by role (missing role)", () => { + expect(checker.isCustomFlagAllowed("!a-a", [], ["cosmetic:flags"])).toBe( + "restricted", + ); + }); + it("allowed: two segments, both unrestricted", () => { + expect(checker.isCustomFlagAllowed("!b-b_b-b", [], [])).toBe(true); + }); + it("restricted: two segments, one restricted by layer role", () => { + expect(checker.isCustomFlagAllowed("!a-b_b-b", [], [])).toBe("restricted"); + expect(checker.isCustomFlagAllowed("!a-b_b-b", ["role_donor"], [])).toBe( + true, + ); + }); + it("restricted: two segments, one restricted by color role", () => { + expect(checker.isCustomFlagAllowed("!b-a_b-b", [], [])).toBe("restricted"); + expect(checker.isCustomFlagAllowed("!b-a_b-b", ["role_admin"], [])).toBe( + true, + ); + }); + it("allowed: two segments, one by role, one by flare", () => { + expect( + checker.isCustomFlagAllowed( + "!a-c_b-b", + ["role_donor"], + ["cosmetic:blue"], + ), + ).toBe(true); + expect(checker.isCustomFlagAllowed("!a-c_b-b", ["role_donor"], [])).toBe( + "restricted", + ); + expect(checker.isCustomFlagAllowed("!a-c_b-b", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); + it("allowed: two segments, both by flare", () => { + expect( + checker.isCustomFlagAllowed( + "!a-c_a-c", + [], + ["cosmetic:flags", "cosmetic:blue"], + ), + ).toBe(true); + expect( + checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:flags"]), + ).toBe("restricted"); + expect(checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); +});