From 8fcb5c8ace3f78e5c04781562c7cae9e159a7950 Mon Sep 17 00:00:00 2001 From: drifterza Date: Mon, 4 May 2026 14:00:34 +0200 Subject: [PATCH] feat(acl): add HuJSON policy parsing and manipulation utilities Add comment-json dependency for parsing HuJSON (JSON with comments and trailing commas) used by Headscale ACL policies. Introduces app/utils/acl-editor.ts with typed interfaces for ACL policy entities and pure functions for parsing, serializing, and mutating policy sections (rules, groups, hosts, tags, SSH rules). Comment metadata is preserved through mutations via comment-json's assign helper. Includes 45 unit tests covering parsing, round-trips, comment preservation, trailing comma handling, and all mutation functions. --- app/utils/acl-editor.ts | 133 +++++++++++ nix/package.nix | 2 +- package.json | 1 + pnpm-lock.yaml | 25 ++ tests/unit/utils/acl-editor.test.ts | 351 ++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 app/utils/acl-editor.ts create mode 100644 tests/unit/utils/acl-editor.test.ts diff --git a/app/utils/acl-editor.ts b/app/utils/acl-editor.ts new file mode 100644 index 00000000..909d1f29 --- /dev/null +++ b/app/utils/acl-editor.ts @@ -0,0 +1,133 @@ +import { assign, parse, stringify } from "comment-json"; + +export interface AclRule { + action: "accept"; + src: string[]; + dst: string[]; + proto?: string; +} + +export interface SshRule { + action: "accept" | "check"; + src: string[]; + dst: string[]; + users: string[]; + checkPeriod?: string; +} + +export interface AclPolicy { + acls?: AclRule[]; + groups?: Record; + hosts?: Record; + tagOwners?: Record; + ssh?: SshRule[]; + autoApprovers?: { routes?: Record; exitNode?: string[] }; + tests?: unknown[]; +} + +export function parsePolicy(raw: string): AclPolicy { + if (!raw.trim()) return {}; + return parse(raw) as AclPolicy; +} + +export function stringifyPolicy(policy: AclPolicy): string { + return stringify(policy, null, 2); +} + +// comment-json stores comments as Symbols which get lost in spread. +function patch(policy: AclPolicy, changes: Partial): AclPolicy { + return assign(assign({} as AclPolicy, policy), changes) as AclPolicy; +} + +// Generic array operations on a policy field +type ArrayField = "acls" | "ssh"; + +function appendTo( + policy: AclPolicy, + key: K, + item: NonNullable[number], +): AclPolicy { + return patch(policy, { + [key]: [...((policy[key] as unknown[]) ?? []), item], + } as Partial); +} + +function removeAt(policy: AclPolicy, key: K, index: number): AclPolicy { + const arr = [...((policy[key] as unknown[]) ?? [])]; + if (index < 0 || index >= arr.length) return policy; + arr.splice(index, 1); + return patch(policy, { [key]: arr } as Partial); +} + +function replaceAt( + policy: AclPolicy, + key: K, + index: number, + item: NonNullable[number], +): AclPolicy { + const arr = [...((policy[key] as unknown[]) ?? [])]; + if (index < 0 || index >= arr.length) return policy; + arr[index] = item; + return patch(policy, { [key]: arr } as Partial); +} + +// Generic record operations on a policy field +type RecordField = "groups" | "hosts" | "tagOwners"; + +function setEntry( + policy: AclPolicy, + key: K, + entryKey: string, + value: NonNullable[string], +): AclPolicy { + return patch(policy, { + [key]: { ...(policy[key] as Record), [entryKey]: value }, + } as Partial); +} + +function removeEntry( + policy: AclPolicy, + key: K, + entryKey: string, +): AclPolicy { + const map = { ...(policy[key] as Record) }; + delete map[entryKey]; + return patch(policy, { [key]: map } as Partial); +} + +// Prefix helpers +function groupKey(name: string) { + return name.startsWith("group:") ? name : `group:${name}`; +} + +function tagKey(name: string) { + return name.startsWith("tag:") ? name : `tag:${name}`; +} + +// ACL rules +export const addAclRule = (p: AclPolicy, rule: AclRule) => appendTo(p, "acls", rule); +export const removeAclRule = (p: AclPolicy, i: number) => removeAt(p, "acls", i); +export const updateAclRule = (p: AclPolicy, i: number, rule: AclRule) => + replaceAt(p, "acls", i, rule); + +// SSH rules +export const addSshRule = (p: AclPolicy, rule: SshRule) => appendTo(p, "ssh", rule); +export const removeSshRule = (p: AclPolicy, i: number) => removeAt(p, "ssh", i); +export const updateSshRule = (p: AclPolicy, i: number, rule: SshRule) => + replaceAt(p, "ssh", i, rule); + +// Groups +export const setGroup = (p: AclPolicy, name: string, members: string[]) => + setEntry(p, "groups", groupKey(name), members); +export const removeGroup = (p: AclPolicy, name: string) => removeEntry(p, "groups", groupKey(name)); + +// Hosts +export const setHost = (p: AclPolicy, name: string, addr: string) => + setEntry(p, "hosts", name, addr); +export const removeHost = (p: AclPolicy, name: string) => removeEntry(p, "hosts", name); + +// Tag owners +export const setTagOwner = (p: AclPolicy, tag: string, owners: string[]) => + setEntry(p, "tagOwners", tagKey(tag), owners); +export const removeTagOwner = (p: AclPolicy, tag: string) => + removeEntry(p, "tagOwners", tagKey(tag)); diff --git a/nix/package.nix b/nix/package.nix index 9a4774b5..67b719c9 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -33,7 +33,7 @@ in inherit (finalAttrs) pname version src; fetcherVersion = 3; pnpm = pnpm_10; - hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8="; + hash = "sha256-OTd6+KxPc0NZyPiof6DNAH+bZouSkKHN5YTPjL1ko1E="; }; buildPhase = '' diff --git a/package.json b/package.json index 058317bd..a778b382 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@uiw/react-codemirror": "4.25.9", "arktype": "^2.2.0", "clsx": "^2.1.1", + "comment-json": "^5.0.0", "drizzle-orm": "1.0.0-beta.21", "isbot": "5.1.37", "jose": "6.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a6a48a0..028783fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + comment-json: + specifier: ^5.0.0 + version: 5.0.0 drizzle-orm: specifier: 1.0.0-beta.21 version: 1.0.0-beta.21(@libsql/client@0.17.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(arktype@2.2.0)(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(valibot@1.3.1(typescript@6.0.2)) @@ -2163,6 +2166,9 @@ packages: arktype@2.2.0: resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -2394,6 +2400,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + comment-json@5.0.0: + resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} + engines: {node: '>= 6'} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -2706,6 +2716,11 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -3873,6 +3888,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.3.1: @@ -5939,6 +5955,8 @@ snapshots: '@ark/util': 0.56.0 arkregex: 0.0.5 + array-timsort@1.0.3: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -6162,6 +6180,11 @@ snapshots: commander@2.20.3: optional: true + comment-json@5.0.0: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -6434,6 +6457,8 @@ snapshots: escalade@3.2.0: {} + esprima@4.0.1: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: diff --git a/tests/unit/utils/acl-editor.test.ts b/tests/unit/utils/acl-editor.test.ts new file mode 100644 index 00000000..03d8c561 --- /dev/null +++ b/tests/unit/utils/acl-editor.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "vitest"; + +import { + type AclPolicy, + type AclRule, + type SshRule, + addAclRule, + addSshRule, + parsePolicy, + removeAclRule, + removeGroup, + removeHost, + removeSshRule, + removeTagOwner, + setGroup, + setHost, + setTagOwner, + stringifyPolicy, + updateAclRule, + updateSshRule, +} from "~/utils/acl-editor"; + +const FULL = `{ + "acls": [ + {"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}, + {"action": "accept", "src": ["group:dev"], "dst": ["tag:server:22"]} + ], + "groups": { + "group:admin": ["user1", "user2"], + "group:dev": ["user3"] + }, + "hosts": { + "server1": "100.64.0.1", + "server2": "100.64.0.2" + }, + "tagOwners": { + "tag:server": ["group:admin"], + "tag:ci": ["user3"] + }, + "ssh": [ + {"action": "accept", "src": ["group:admin"], "dst": ["tag:server"], "users": ["root"]} + ] +}`; + +const WITH_COMMENTS = `{ + // Main access rules + "acls": [ + // Allow admins everywhere + {"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}, + ], + // Team groups + "groups": { + "group:admin": ["alice", "bob"], + }, +}`; + +const WITH_EXTRA_FIELDS = `{ + "acls": [ + {"action": "accept", "src": ["*"], "dst": ["*:*"]} + ], + "autoApprovers": { + "routes": {"10.0.0.0/8": ["group:admin"]}, + "exitNode": ["group:admin"] + }, + "tests": [ + {"src": "user1", "accept": ["100.64.0.1:80"]} + ] +}`; + +const rule = (o?: Partial): AclRule => ({ + action: "accept", + src: ["*"], + dst: ["*:*"], + ...o, +}); + +const ssh = (o?: Partial): SshRule => ({ + action: "accept", + src: ["group:admin"], + dst: ["tag:server"], + users: ["root"], + ...o, +}); + +describe("parsing", () => { + test("empty/whitespace input returns empty object", () => { + expect(parsePolicy("")).toEqual({}); + expect(parsePolicy(" ")).toEqual({}); + }); + + test("parses all policy sections", () => { + const p = parsePolicy(FULL); + expect(p.acls).toHaveLength(2); + expect(Object.keys(p.groups ?? {})).toEqual(["group:admin", "group:dev"]); + expect(Object.keys(p.hosts ?? {})).toEqual(["server1", "server2"]); + expect(Object.keys(p.tagOwners ?? {})).toEqual(["tag:server", "tag:ci"]); + expect(p.ssh).toHaveLength(1); + }); + + test("handles HuJSON (comments + trailing commas)", () => { + const p = parsePolicy(WITH_COMMENTS); + expect(p.acls).toHaveLength(1); + expect(p.groups?.["group:admin"]).toEqual(["alice", "bob"]); + }); + + test("rejects invalid input", () => { + expect(() => parsePolicy("{invalid}")).toThrow(); + }); + + test("stringify round-trips preserve data and section-level comments", () => { + const output = stringifyPolicy(parsePolicy(WITH_COMMENTS)); + expect(output).toContain("// Main access rules"); + expect(output).toContain("// Team groups"); + const reparsed = parsePolicy(output); + expect(reparsed.acls).toHaveLength(1); + expect(reparsed.groups?.["group:admin"]).toEqual(["alice", "bob"]); + }); +}); + +// Tests array generics (appendTo/removeAt/replaceAt) via acl + ssh wrappers +describe("array operations", () => { + test("append to empty and existing arrays", () => { + const fromEmpty = addAclRule({}, rule()); + expect(fromEmpty.acls).toHaveLength(1); + + const fromExisting = addAclRule(parsePolicy(FULL), rule({ src: ["group:ops"] })); + expect(fromExisting.acls).toHaveLength(3); + expect(fromExisting.acls?.[2].src).toEqual(["group:ops"]); + }); + + test("remove by index, out-of-bounds no-op, and last-element removal", () => { + const p = parsePolicy(FULL); + + const removed = removeAclRule(p, 0); + expect(removed.acls).toHaveLength(1); + expect(removed.acls?.[0].src).toEqual(["group:dev"]); + + // Out of bounds and undefined arrays return same ref + expect(removeAclRule(p, 99)).toBe(p); + expect(removeAclRule(p, -1)).toBe(p); + expect(removeAclRule({}, 0)).toEqual({}); + + // Removing last element leaves empty array + const single = parsePolicy(`{"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]}`); + expect(removeAclRule(single, 0).acls).toEqual([]); + }); + + test("replace at index", () => { + const p = parsePolicy(FULL); + const updated = updateAclRule(p, 1, rule({ src: ["group:ops"], dst: ["*:443"] })); + expect(updated.acls?.[1].src).toEqual(["group:ops"]); + expect(updated.acls?.[0].src).toEqual(["group:admin"]); // untouched + expect(updateAclRule(p, 99, rule())).toBe(p); // out of bounds + }); + + test("ssh rules use same generics", () => { + const added = addSshRule({}, ssh()); + expect(added.ssh).toHaveLength(1); + + const p = parsePolicy(FULL); + expect(removeSshRule(p, 0).ssh).toHaveLength(0); + + const updated = updateSshRule(p, 0, ssh({ action: "check", checkPeriod: "12h" })); + expect(updated.ssh?.[0].action).toBe("check"); + expect(updated.ssh?.[0].checkPeriod).toBe("12h"); + }); +}); + +// Tests record generics (setEntry/removeEntry) via groups/hosts/tags wrappers +describe("record operations", () => { + test("set new, overwrite existing, and auto-prefix", () => { + // Groups: new + overwrite + prefix + expect(setGroup({}, "ops", ["a"]).groups?.["group:ops"]).toEqual(["a"]); + const p = parsePolicy(FULL); + const updated = setGroup(p, "group:admin", ["newuser"]); + expect(updated.groups?.["group:admin"]).toEqual(["newuser"]); + expect(updated.groups?.["group:dev"]).toEqual(["user3"]); // sibling untouched + + // Hosts + expect(setHost({}, "web", "10.0.0.1").hosts?.web).toBe("10.0.0.1"); + + // Tags with auto-prefix + expect(setTagOwner({}, "web", ["group:ops"]).tagOwners?.["tag:web"]).toEqual(["group:ops"]); + }); + + test("remove existing, no-op for missing, and auto-prefix", () => { + const p = parsePolicy(FULL); + + const r = removeGroup(p, "dev"); // auto-prefixed + expect(r.groups?.["group:dev"]).toBeUndefined(); + expect(r.groups?.["group:admin"]).toBeDefined(); + + expect(removeHost(p, "server1").hosts?.server1).toBeUndefined(); + expect(removeTagOwner(p, "ci").tagOwners?.["tag:ci"]).toBeUndefined(); + + // No-op for missing keys + const noOp = removeGroup(p, "nonexistent"); + expect(Object.keys(noOp.groups ?? {})).toEqual(Object.keys(p.groups ?? {})); + }); +}); + +describe("immutability", () => { + test("no mutation function alters the source policy", () => { + const p = parsePolicy(FULL); + const snapshot = stringifyPolicy(p); + + addAclRule(p, rule()); + removeAclRule(p, 0); + updateAclRule(p, 0, rule({ src: ["changed"] })); + addSshRule(p, ssh()); + removeSshRule(p, 0); + updateSshRule(p, 0, ssh({ users: ["ubuntu"] })); + setGroup(p, "new", ["x"]); + removeGroup(p, "group:admin"); + setHost(p, "server1", "10.0.0.99"); + removeHost(p, "server1"); + setTagOwner(p, "tag:server", ["changed"]); + removeTagOwner(p, "tag:ci"); + + expect(stringifyPolicy(p)).toBe(snapshot); + }); +}); + +describe("comment preservation", () => { + test("section-level comments survive mutations on any field", () => { + const p = parsePolicy(WITH_COMMENTS); + const output = stringifyPolicy(setHost(p, "web", "10.0.0.5")); + expect(output).toContain("// Main access rules"); + expect(output).toContain("// Team groups"); + }); + + test("inline array element comments are lost on array mutation", () => { + // comment-json stores inline comments as Symbols on array elements. + // Spreading into a new array drops them — known tradeoff of immutability. + const p = parsePolicy(WITH_COMMENTS); + const output = stringifyPolicy(addAclRule(p, rule())); + expect(output).not.toContain("// Allow admins everywhere"); + expect(output).toContain("// Main access rules"); // section-level preserved + }); +}); + +describe("field isolation", () => { + test("autoApprovers and tests survive unrelated mutations", () => { + let p = parsePolicy(WITH_EXTRA_FIELDS); + p = addAclRule(p, rule({ src: ["group:ops"] })); + p = setGroup(p, "ops", ["admin1"]); + p = setHost(p, "web", "10.0.0.5"); + + const final = parsePolicy(stringifyPolicy(p)); + expect(final.autoApprovers?.routes?.["10.0.0.0/8"]).toEqual(["group:admin"]); + expect(final.autoApprovers?.exitNode).toEqual(["group:admin"]); + expect(final.tests).toHaveLength(1); + expect(final.acls).toHaveLength(2); + }); + + test("groups/hosts/tags/ssh survive acl removal", () => { + const r = removeAclRule(parsePolicy(FULL), 0); + expect(Object.keys(r.groups ?? {})).toEqual(["group:admin", "group:dev"]); + expect(Object.keys(r.hosts ?? {})).toEqual(["server1", "server2"]); + expect(r.ssh).toHaveLength(1); + }); +}); + +describe("error handling & edge cases", () => { + test("parsePolicy rejects invalid JSON but doesn't crash on non-object values", () => { + expect(() => parsePolicy("{invalid}")).toThrow(); + // comment-json wraps primitives in boxed objects, so just verify no crash + expect(() => parsePolicy('"just a string"')).not.toThrow(); + expect(() => parsePolicy("null")).not.toThrow(); + expect(() => parsePolicy("42")).not.toThrow(); + }); + + test("array ops on undefined fields default to empty", () => { + const empty: AclPolicy = {}; + // append creates the array + expect(addAclRule(empty, rule()).acls).toHaveLength(1); + expect(addSshRule(empty, ssh()).ssh).toHaveLength(1); + // remove/update on undefined returns same ref (no-op) + expect(removeAclRule(empty, 0)).toBe(empty); + expect(updateAclRule(empty, 0, rule())).toBe(empty); + expect(removeSshRule(empty, 0)).toBe(empty); + expect(updateSshRule(empty, 0, ssh())).toBe(empty); + }); + + test("record ops on undefined fields default to empty", () => { + const empty: AclPolicy = {}; + expect(setGroup(empty, "ops", ["a"]).groups?.["group:ops"]).toEqual(["a"]); + expect(setHost(empty, "web", "10.0.0.1").hosts?.web).toBe("10.0.0.1"); + expect(setTagOwner(empty, "web", ["a"]).tagOwners?.["tag:web"]).toEqual(["a"]); + // remove from undefined field doesn't crash + const r1 = removeGroup(empty, "ops"); + expect(r1.groups).toEqual({}); + const r2 = removeHost(empty, "web"); + expect(r2.hosts).toEqual({}); + const r3 = removeTagOwner(empty, "web"); + expect(r3.tagOwners).toEqual({}); + }); + + test("out-of-bounds array operations return same reference", () => { + const p = parsePolicy(FULL); + expect(removeAclRule(p, -1)).toBe(p); + expect(removeAclRule(p, 999)).toBe(p); + expect(updateAclRule(p, -1, rule())).toBe(p); + expect(updateAclRule(p, 999, rule())).toBe(p); + expect(removeSshRule(p, -1)).toBe(p); + expect(updateSshRule(p, 999, ssh())).toBe(p); + }); +}); + +describe("end-to-end workflows", () => { + test("build policy from scratch and round-trip", () => { + let p: AclPolicy = {}; + p = setGroup(p, "admin", ["alice", "bob"]); + p = setTagOwner(p, "server", ["group:admin"]); + p = setHost(p, "gateway", "100.64.0.1"); + p = addAclRule(p, rule({ src: ["group:admin"], dst: ["*:*"] })); + p = addSshRule(p, ssh()); + + const final = parsePolicy(stringifyPolicy(p)); + expect(final.groups?.["group:admin"]).toEqual(["alice", "bob"]); + expect(final.tagOwners?.["tag:server"]).toEqual(["group:admin"]); + expect(final.hosts?.gateway).toBe("100.64.0.1"); + expect(final.acls).toHaveLength(1); + expect(final.ssh).toHaveLength(1); + }); + + test("chained add/remove/update across sections", () => { + let p = parsePolicy(FULL); + p = addAclRule(p, rule({ src: ["group:temp"] })); + p = removeAclRule(p, 2); // remove the one we just added + p = updateAclRule(p, 0, rule({ src: ["group:ops"] })); + p = setGroup(p, "temp", ["user1"]); + p = removeGroup(p, "temp"); + + const final = parsePolicy(stringifyPolicy(p)); + expect(final.acls).toHaveLength(2); + expect(final.acls?.[0].src).toEqual(["group:ops"]); + expect(final.groups?.["group:temp"]).toBeUndefined(); + expect(final.groups?.["group:admin"]).toEqual(["user1", "user2"]); + }); + + test("optional fields (proto, checkPeriod) survive round-trips", () => { + let p: AclPolicy = {}; + p = addAclRule(p, rule({ proto: "udp", dst: ["*:53"] })); + p = addSshRule(p, ssh({ action: "check", checkPeriod: "24h" })); + + const final = parsePolicy(stringifyPolicy(p)); + expect(final.acls?.[0].proto).toBe("udp"); + expect(final.ssh?.[0].checkPeriod).toBe("24h"); + }); +});