// The firewall settings page of routers and home routers (konfig, // apps/firewallSettings.ts) against Java FILIUS' own: tools/java-oracle's // KonfigCases fed Java's page with the steps of firewall-settings.cases.txt // (firewall-settings..java.json). Each step's page and the firewall // after it must be Java's, in both languages, except where Java errs. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { applyFirewallSettings, firewallConfiguration, firewallRule, firewallSettingsPage, javaUrlDecode, type FirewallConfiguration, type FirewallRule, type PageLanguage, } from '../src/index.js'; const here = dirname(fileURLToPath(import.meta.url)); type JavaRule = [string, string, string, string, number, string, string]; interface JavaFirewall { isActive: boolean; dropIcmp: boolean; filterSynSegmentsOnly: boolean; defaultPolicy: string; rules: JavaRule[]; } interface JavaStep { post: string | null; page: string | null; firewall: JavaFirewall; } interface JavaCase { name: string; start: string; steps: JavaStep[]; } function javaRule(rule: FirewallRule): JavaRule { return [rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask, rule.port, rule.protocol, rule.action]; } function asJava(configuration: FirewallConfiguration): JavaFirewall { return { isActive: configuration.isActive, dropIcmp: configuration.dropIcmp, filterSynSegmentsOnly: configuration.filterSynSegmentsOnly, defaultPolicy: configuration.defaultPolicy, rules: configuration.rules.map(javaRule), }; } /** KonfigCases' start states. */ const STARTS: Record = { empty: firewallConfiguration(), three: firewallConfiguration({ isActive: true, rules: [ firewallRule({ port: 80, protocol: 'tcp', action: 'accept' }), firewallRule({ sourceIPAddress: '999.999.999.999', destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', action: 'drop' }), firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', protocol: 'all', action: 'drop' }), ], }), sourceRule: firewallConfiguration({ rules: [firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.255.255.0', port: 80, protocol: 'tcp', action: 'drop' })] }), portZero: firewallConfiguration({ rules: [firewallRule({ port: 0, protocol: 'icmp', action: 'accept' })] }), destinationRule: firewallConfiguration({ rules: [firewallRule({ destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', action: 'accept' })] }), }; type Cases = Record; const step = (cases: Cases, name: string, index: number) => cases[name]!.steps[index]!; /** A step as Java means it: another step's page and firewall, which Java answers for the same state. */ const asStep = (name: string, index: number) => (cases: Cases, measured: JavaStep): JavaStep => ({ ...step(cases, name, index), post: measured.post }); /** * Where Java errs, each step as Java means it (the core's departures, see * firewallSettings.ts). */ const JAVA_BUGS: Record JavaStep>> = { // Java checks the source IP against the mask not read yet, and the mask // against the IP not read yet: it refuses every rule with an address, // whichever field comes first. The core takes the rule. addSource: { 0: asStep('sourceRule', 0) }, addSourceMaskFirst: { 0: asStep('sourceRule', 0) }, addDestination: { 0: asStep('destinationRule', 0) }, addDestinationMaskFirst: { 0: asStep('destinationRule', 0) }, // Java keeps the error of a failed add for every later page. errors: { 1: asStep('getThree', 0) }, // Save and add at once: Java saves, but writes the add's errors as a bare // list and fills the form in. The core answers the saved firewall. errorAndSave: { 0: asStep('deactivate', 0) }, // Java writes the form's text into the page as it came. markup: { 0: (_, measured) => ({ ...measured, page: measured.page!.replace('value="""', 'value="<b>""') }) }, // A "$" in the form's text: Java's replaceAll reads it as a group, throws, // and the page fails (500). The core answers as for any other bad address. dollar: { 0: (cases, measured) => ({ ...measured, page: step(cases, 'markup', 0).page!.replace('value="""', 'value="$1"') }) }, // An "&" typed into a field: Java decodes the data before it splits it, // reads an empty source and adds a rule. The core keeps the "&" in its // field and answers as for any other bad address. ampersand: { 0: (cases) => ({ ...step(cases, 'markup', 0), page: step(cases, 'markup', 0).page!.replace('value="""', 'value="&"') }) }, // Moving rule 0 down: Java throws (its worker thread ends, no page comes). moveDownZero: { 0: asStep('getThree', 0) }, // An escape that is not one: Java's decoder throws. The core reads the // data as it came, as Java's code means to. malformed: { 0: asStep('moveUp', 0), 1: asStep('getThree', 0) }, }; function javaCases(language: PageLanguage): Cases { const list = JSON.parse(readFileSync(join(here, `firewall-settings.${language}.java.json`), 'utf8')) as JavaCase[]; return Object.fromEntries(list.map((each) => [each.name, each])); } describe('the firewall settings page as Java FILIUS answers it', () => { for (const language of ['de', 'en'] as const) { const cases = javaCases(language); it(`corrects Java only where it errs (${language})`, () => { for (const [name, steps] of Object.entries(JAVA_BUGS)) { for (const [index, correct] of Object.entries(steps)) { const measured = step(cases, name, Number(index)); expect(correct(cases, measured), `${name} ${index}`).not.toEqual(measured); } } }); for (const [name, javaCase] of Object.entries(cases)) { it(`${name} (${language})`, () => { let configuration = STARTS[javaCase.start]!; for (const [index, measured] of javaCase.steps.entries()) { const expected = JAVA_BUGS[name]?.[index]?.(cases, measured) ?? measured; const result = applyFirewallSettings(configuration, measured.post ?? undefined); configuration = result.configuration; expect(asJava(configuration), `step ${index}: the firewall`).toEqual(expected.firewall); expect(firewallSettingsPage(configuration, language, result.rejected), `step ${index}: the page`).toBe(expected.page); } }); } } }); describe("a form's data as Java's URLDecoder reads it", () => { it('turns + into spaces and runs of escapes into UTF-8 characters', () => { expect(javaUrlDecode('a+b%C3%BC%E2%82%AC%41')).toBe('a bü€A'); expect(javaUrlDecode('%+f%-0x')).toBe('\u000f\u0000x'); }); it('fails where Java throws: an escape cut off, not hexadecimal, or negative', () => { expect(javaUrlDecode('%zz')).toBeUndefined(); expect(javaUrlDecode('%4')).toBeUndefined(); expect(javaUrlDecode('%41%')).toBeUndefined(); expect(javaUrlDecode('%-1')).toBeUndefined(); }); });