diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e68b683 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x, 22.x] + fail-fast: false + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore index e2bed4b..e4b0b17 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,15 @@ node_modules encrypted/ !example/encrypted/ decrypted/ -test/ \ No newline at end of file + +# AI tooling artifacts (kept local) +AGENTS.md +CLAUDE.md +CLAUDE.local.md +.claude/ +docs/superpowers/ +.cursor/ +.aider* + +# Manual-test fixtures (used by scripts/build.sh, not by automated tests) +test/fixtures/legacy-html/ diff --git a/package-lock.json b/package-lock.json index 3db01a1..0af2b44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "staticrypt", - "version": "3.5.3", + "version": "3.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "staticrypt", - "version": "3.5.3", + "version": "3.5.4", "license": "MIT", "dependencies": { "dotenv": "^16.0.3", @@ -17,6 +17,7 @@ }, "devDependencies": { "husky": "^9.1.6", + "jsdom": "^24.0.0", "lint-staged": "^15.2.10", "prettier": "^2.8.8" }, @@ -28,6 +29,145 @@ "url": "https://github.com/robinmoisson/staticrypt?sponsor=1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", @@ -70,6 +210,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -83,6 +230,20 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chalk": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", @@ -250,6 +411,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -275,6 +449,41 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -293,6 +502,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -305,6 +531,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/emoji-regex": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", @@ -312,6 +553,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -325,6 +579,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -378,6 +681,33 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -400,6 +730,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", @@ -413,6 +782,102 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -439,6 +904,19 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", @@ -462,6 +940,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -482,6 +967,47 @@ "dev": true, "license": "ISC" }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -594,6 +1120,23 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -615,6 +1158,29 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -677,6 +1243,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -693,6 +1266,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -745,6 +1331,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -754,6 +1370,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -794,6 +1417,33 @@ "dev": true, "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -904,6 +1554,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -917,6 +1574,117 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -951,6 +1719,45 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index c0dcf93..671d23d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "/lib" ], "bin": { - "staticrypt": "./cli/index.js" + "staticrypt": "cli/index.js" }, "dependencies": { "dotenv": "^16.0.3", @@ -30,7 +30,8 @@ "scripts": { "build": "bash ./scripts/build.sh", "format": "prettier --write \"**/*.{js,json,html}\"", - "prepare": "husky" + "prepare": "husky", + "test": "node --test --test-reporter=spec test/*.test.js" }, "lint-staged": { "**/*.{js,json,html}": [ @@ -57,6 +58,7 @@ "homepage": "https://github.com/robinmoisson/staticrypt", "devDependencies": { "husky": "^9.1.6", + "jsdom": "^24.0.0", "lint-staged": "^15.2.10", "prettier": "^2.8.8" } diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js new file mode 100644 index 0000000..779549a --- /dev/null +++ b/test/cli-e2e.test.js @@ -0,0 +1,105 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const cryptoEngine = require("../lib/cryptoEngine.js"); +const codec = require("../lib/codec.js").init(cryptoEngine); + +const CLI = path.join(__dirname, "..", "cli", "index.js"); +const PASSWORD = "longenoughtestpassword123"; +const SALT = "abcd1234abcd1234abcd1234abcd1234"; + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "staticrypt-e2e-")); +} + +test("CLI encrypts an HTML file producing the expected wire-format payload", async () => { + const tmp = mkTmp(); + const input = path.join(tmp, "input.html"); + fs.writeFileSync(input, "

secret

"); + + const outDir = path.join(tmp, "encrypted"); + const result = spawnSync( + "node", + [CLI, input, "-p", PASSWORD, "--salt", SALT, "--short", "-d", outDir, "-c", "false"], + { encoding: "utf8" } + ); + assert.equal(result.status, 0, `CLI exited non-zero. stderr: ${result.stderr}\nstdout: ${result.stdout}`); + + const output = fs.readFileSync(path.join(outDir, "input.html"), "utf8"); + + // Extract the encrypted payload and salt from the output HTML. + const cipherMatch = output.match(/"staticryptEncryptedMsgUniqueVariableName":\s*"([^"]+)"/); + const saltMatch = output.match(/"staticryptSaltUniqueVariableName":\s*"([^"]+)"/); + assert.ok(cipherMatch, "output contains the encrypted message"); + assert.ok(saltMatch, "output contains the salt"); + assert.equal(saltMatch[1], SALT); + + // Verify the wire format & round-trip via the lib. + assert.match(cipherMatch[1].slice(0, 64), /^[0-9a-f]{64}$/, "first 64 chars are HMAC hex"); + assert.match(cipherMatch[1].slice(64, 96), /^[0-9a-f]{32}$/, "next 32 chars are IV hex"); + + const hashed = await cryptoEngine.hashPassword(PASSWORD, SALT); + const decoded = await codec.decode(cipherMatch[1], hashed, SALT); + assert.equal(decoded.success, true); + assert.equal(decoded.decoded, "

secret

"); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("CLI --decrypt reverses an encrypted file back to the original plaintext", () => { + const tmp = mkTmp(); + const input = path.join(tmp, "input.html"); + fs.writeFileSync(input, "

original content

"); + + const encDir = path.join(tmp, "enc"); + const encResult = spawnSync( + "node", + [CLI, input, "-p", PASSWORD, "--salt", SALT, "--short", "-d", encDir, "-c", "false"], + { encoding: "utf8" } + ); + assert.equal(encResult.status, 0, `encrypt step failed: ${encResult.stderr}`); + + const decDir = path.join(tmp, "dec"); + const decResult = spawnSync( + "node", + [ + CLI, + path.join(encDir, "input.html"), + "-p", + PASSWORD, + "--salt", + SALT, + "--decrypt", + "-d", + decDir, + "-c", + "false", + ], + { encoding: "utf8" } + ); + assert.equal(decResult.status, 0, `decrypt step failed: ${decResult.stderr}`); + + const decrypted = fs.readFileSync(path.join(decDir, "input.html"), "utf8"); + assert.equal(decrypted, "

original content

"); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("CLI rejects an invalid salt", () => { + const tmp = mkTmp(); + const input = path.join(tmp, "input.html"); + fs.writeFileSync(input, "

x

"); + + const result = spawnSync( + "node", + [CLI, input, "-p", PASSWORD, "--salt", "not-32-hex-chars", "--short", "-c", "false"], + { encoding: "utf8" } + ); + assert.notEqual(result.status, 0, "CLI must exit non-zero on invalid salt"); + assert.match(result.stdout + result.stderr, /salt/i); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/test/cli-helpers.test.js b/test/cli-helpers.test.js new file mode 100644 index 0000000..7d44fb4 --- /dev/null +++ b/test/cli-helpers.test.js @@ -0,0 +1,55 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + convertCommonJSToBrowserJS, + buildStaticryptJS, + isCustomPasswordTemplateDefault, + getValidatedSalt, +} = require("../cli/helpers.js"); +const path = require("node:path"); + +test("convertCommonJSToBrowserJS produces an IIFE that returns exports", () => { + const result = convertCommonJSToBrowserJS("lib/cryptoEngine"); + assert.ok(result.startsWith("((function(){"), "starts with IIFE wrapper"); + assert.ok(result.endsWith("})())"), "ends with IIFE wrapper"); + assert.ok(result.includes("const exports = {};"), "declares exports object"); + assert.ok(result.includes("return exports;"), "returns exports"); +}); + +test("convertCommonJSToBrowserJS strips lines containing require(...)", () => { + const result = convertCommonJSToBrowserJS("lib/cryptoEngine"); + assert.ok(!result.includes("require("), "no require() calls remain in bundled output"); + assert.ok(!result.includes("node:crypto"), "node-only branch removed"); +}); + +test("buildStaticryptJS inlines codec and cryptoEngine into staticryptJs", () => { + const result = buildStaticryptJS(); + // The result should contain the IIFE-wrapped sub-modules in place of the tokens. + assert.ok(result.includes("hashPassword"), "cryptoEngine code is inlined"); + assert.ok(result.includes("function init(cryptoEngine)"), "codec init() is inlined"); + assert.ok(result.includes("function init(staticryptConfig, templateConfig)"), "staticryptJs init() is present"); + // After token replacement, no /*[|...|]*/0 placeholders remain. + assert.ok(!/\/\*\[\|\s*\w+\s*\|]\*\/\s*0/.test(result), "no template tokens left after inlining"); +}); + +test("isCustomPasswordTemplateDefault recognizes the default template path", () => { + const defaultPath = path.join(__dirname, "..", "lib", "password_template.html"); + assert.equal(isCustomPasswordTemplateDefault(defaultPath), true); + assert.equal(isCustomPasswordTemplateDefault("/some/other/template.html"), false); +}); + +test("getValidatedSalt prefers the --salt CLI flag over config and over generation", () => { + const namedArgs = { salt: "abcd1234abcd1234abcd1234abcd1234" }; + const config = { salt: "ffff0000ffff0000ffff0000ffff0000" }; + assert.equal(getValidatedSalt(namedArgs, config), "abcd1234abcd1234abcd1234abcd1234"); +}); + +test("getValidatedSalt falls back to config salt when no flag", () => { + const config = { salt: "ffff0000ffff0000ffff0000ffff0000" }; + assert.equal(getValidatedSalt({}, config), "ffff0000ffff0000ffff0000ffff0000"); +}); + +test("getValidatedSalt lowercases the --salt CLI flag", () => { + const namedArgs = { salt: "ABCD1234ABCD1234ABCD1234ABCD1234" }; + assert.equal(getValidatedSalt(namedArgs, {}), "abcd1234abcd1234abcd1234abcd1234"); +}); diff --git a/test/codec-compat.test.js b/test/codec-compat.test.js new file mode 100644 index 0000000..e6213bc --- /dev/null +++ b/test/codec-compat.test.js @@ -0,0 +1,36 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const cryptoEngine = require("../lib/cryptoEngine.js"); +const codec = require("../lib/codec.js").init(cryptoEngine); + +const FIXTURE = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "encrypted-blobs", "basic.json"), "utf8")); + +test("decode succeeds with fully hashed password (no retry needed)", async () => { + const result = await codec.decode(FIXTURE.encrypted, FIXTURE.hashFull, FIXTURE.salt); + assert.equal(result.success, true); + assert.equal(result.decoded, FIXTURE.plaintext); +}); + +test("backward-compat: decode succeeds with hashLegacyAndSecond (attempt 0 applies hashThirdRound)", async () => { + // Simulates an old localStorage token that was hashed with 1000 + 14000 iters but not the final 585k. + const result = await codec.decode(FIXTURE.encrypted, FIXTURE.hashLegacyAndSecond, FIXTURE.salt); + assert.equal(result.success, true); + assert.equal(result.decoded, FIXTURE.plaintext); +}); + +test("backward-compat: decode succeeds with hashLegacyOnly (attempt 1 applies hashSecondRound + hashThirdRound)", async () => { + // Simulates a very old token that was hashed with only the original 1000 iters SHA-1. + const result = await codec.decode(FIXTURE.encrypted, FIXTURE.hashLegacyOnly, FIXTURE.salt); + assert.equal(result.success, true); + assert.equal(result.decoded, FIXTURE.plaintext); +}); + +test("backward-compat: decode fails after both retry attempts with wrong password", async () => { + // A completely wrong hash should fail even after the retry chain. + const wrong = "0".repeat(64); + const result = await codec.decode(FIXTURE.encrypted, wrong, FIXTURE.salt); + assert.equal(result.success, false); + assert.equal(result.message, "Signature mismatch"); +}); diff --git a/test/codec.test.js b/test/codec.test.js new file mode 100644 index 0000000..f4157f0 --- /dev/null +++ b/test/codec.test.js @@ -0,0 +1,54 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const cryptoEngine = require("../lib/cryptoEngine.js"); +const codec = require("../lib/codec.js").init(cryptoEngine); + +const FIXTURE = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "encrypted-blobs", "basic.json"), "utf8")); + +test("encode + decode round-trip with raw password", async () => { + const encoded = await codec.encode("secret content", FIXTURE.password, FIXTURE.salt); + const result = await codec.decode(encoded, FIXTURE.hashFull, FIXTURE.salt); + assert.equal(result.success, true); + assert.equal(result.decoded, "secret content"); +}); + +test("encodeWithHashedPassword + decode round-trip", async () => { + const encoded = await codec.encodeWithHashedPassword("hello", FIXTURE.hashFull); + const result = await codec.decode(encoded, FIXTURE.hashFull, FIXTURE.salt); + assert.equal(result.success, true); + assert.equal(result.decoded, "hello"); +}); + +test("decode of the committed fixture succeeds with the full hash", async () => { + const result = await codec.decode(FIXTURE.encrypted, FIXTURE.hashFull, FIXTURE.salt); + assert.equal(result.success, true); + assert.equal(result.decoded, FIXTURE.plaintext); +}); + +test("decode fails on tampered HMAC", async () => { + const encoded = await codec.encodeWithHashedPassword("hello", FIXTURE.hashFull); + // flip a bit in the HMAC (first 64 chars) + const tampered = (encoded[0] === "0" ? "1" : "0") + encoded.slice(1); + const result = await codec.decode(tampered, FIXTURE.hashFull, FIXTURE.salt); + assert.equal(result.success, false); + assert.equal(result.message, "Signature mismatch"); +}); + +test("decode fails on tampered ciphertext (HMAC catches it)", async () => { + const encoded = await codec.encodeWithHashedPassword("hello", FIXTURE.hashFull); + // flip a hex digit somewhere in the IV+ciphertext part (after the 64-char HMAC) + const i = 70; + const tampered = encoded.slice(0, i) + (encoded[i] === "0" ? "1" : "0") + encoded.slice(i + 1); + const result = await codec.decode(tampered, FIXTURE.hashFull, FIXTURE.salt); + assert.equal(result.success, false); + assert.equal(result.message, "Signature mismatch"); +}); + +test("wire format: encoded string = 64 hex HMAC + 32 hex IV + ciphertext", async () => { + const encoded = await codec.encodeWithHashedPassword("xyz", FIXTURE.hashFull); + assert.match(encoded.slice(0, 64), /^[0-9a-f]{64}$/, "HMAC prefix is 64 hex chars"); + assert.match(encoded.slice(64, 96), /^[0-9a-f]{32}$/, "IV is the next 32 hex chars"); + assert.match(encoded.slice(96), /^[0-9a-f]+$/, "ciphertext is hex"); +}); diff --git a/test/cryptoEngine.test.js b/test/cryptoEngine.test.js new file mode 100644 index 0000000..15e4fdd --- /dev/null +++ b/test/cryptoEngine.test.js @@ -0,0 +1,87 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const cryptoEngine = require("../lib/cryptoEngine.js"); + +const FIXTURE = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "encrypted-blobs", "basic.json"), "utf8")); + +test("HexEncoder round-trips arbitrary bytes (via encrypt/decrypt path)", async () => { + // HexEncoder isn't exported directly; we exercise it through encrypt/decrypt. + const cipher = await cryptoEngine.encrypt("abc", FIXTURE.hashFull); + const plain = await cryptoEngine.decrypt(cipher, FIXTURE.hashFull); + assert.equal(plain, "abc"); +}); + +test("encrypt produces fresh ciphertext each call (random IV) but always decrypts", async () => { + const a = await cryptoEngine.encrypt("same input", FIXTURE.hashFull); + const b = await cryptoEngine.encrypt("same input", FIXTURE.hashFull); + assert.notEqual(a, b, "two encryptions of the same input must differ (random IV)"); + assert.equal(await cryptoEngine.decrypt(a, FIXTURE.hashFull), "same input"); + assert.equal(await cryptoEngine.decrypt(b, FIXTURE.hashFull), "same input"); +}); + +test("encrypt output starts with 32 hex chars of IV", async () => { + const cipher = await cryptoEngine.encrypt("x", FIXTURE.hashFull); + assert.match(cipher.slice(0, 32), /^[0-9a-f]{32}$/); +}); + +test("hashLegacyRound matches pinned fixture (1000 iters SHA-1)", async () => { + const hash = await cryptoEngine.hashLegacyRound(FIXTURE.password, FIXTURE.salt); + assert.equal(hash, FIXTURE.hashLegacyOnly); +}); + +test("hashSecondRound matches pinned fixture (14000 iters SHA-256 on top of legacy)", async () => { + const hash = await cryptoEngine.hashSecondRound(FIXTURE.hashLegacyOnly, FIXTURE.salt); + assert.equal(hash, FIXTURE.hashLegacyAndSecond); +}); + +test("hashThirdRound matches pinned fixture (585000 iters SHA-256 on top of 2nd)", async () => { + const hash = await cryptoEngine.hashThirdRound(FIXTURE.hashLegacyAndSecond, FIXTURE.salt); + assert.equal(hash, FIXTURE.hashFull); +}); + +test("hashPassword full chain matches the manual composition", async () => { + const hash = await cryptoEngine.hashPassword(FIXTURE.password, FIXTURE.salt); + assert.equal(hash, FIXTURE.hashFull); +}); + +test("hashPassword output is 64 hex chars (256-bit key)", async () => { + const hash = await cryptoEngine.hashPassword("other", FIXTURE.salt); + assert.match(hash, /^[0-9a-f]{64}$/); +}); + +test("generateRandomSalt returns 32 hex chars", () => { + const salt = cryptoEngine.generateRandomSalt(); + assert.match(salt, /^[0-9a-f]{32}$/); +}); + +test("generateRandomSalt returns different values across calls", () => { + assert.notEqual(cryptoEngine.generateRandomSalt(), cryptoEngine.generateRandomSalt()); +}); + +test("generateRandomString returns the requested length of alphanums", () => { + const s = cryptoEngine.generateRandomString(21); + assert.equal(s.length, 21); + assert.match(s, /^[A-Za-z0-9]{21}$/); +}); + +test("signMessage is deterministic for the same inputs", async () => { + const a = await cryptoEngine.signMessage(FIXTURE.hashFull, "message"); + const b = await cryptoEngine.signMessage(FIXTURE.hashFull, "message"); + assert.equal(a, b); + assert.match(a, /^[0-9a-f]{64}$/); // HMAC-SHA-256 = 32 bytes = 64 hex +}); + +test("signMessage changes with a single-bit message change", async () => { + const a = await cryptoEngine.signMessage(FIXTURE.hashFull, "message"); + const b = await cryptoEngine.signMessage(FIXTURE.hashFull, "messagf"); + assert.notEqual(a, b); +}); + +test("decrypt throws on tampered ciphertext", async () => { + const cipher = await cryptoEngine.encrypt("abc", FIXTURE.hashFull); + // flip a hex digit in the ciphertext portion (after the 32-char IV) + const tampered = cipher.slice(0, 32) + (cipher[32] === "0" ? "1" : "0") + cipher.slice(33); + await assert.rejects(() => cryptoEngine.decrypt(tampered, FIXTURE.hashFull)); +}); diff --git a/test/fixtures/encrypted-blobs/basic.json b/test/fixtures/encrypted-blobs/basic.json new file mode 100644 index 0000000..f1241d0 --- /dev/null +++ b/test/fixtures/encrypted-blobs/basic.json @@ -0,0 +1,9 @@ +{ + "plaintext": "Hello, world! héllo wörld", + "password": "testpassword123", + "salt": "abcd1234abcd1234abcd1234abcd1234", + "hashLegacyOnly": "2c604d17a2bd3f824ef29ee524a301a9f353f884814ca5b6b319fb59c59b04e5", + "hashLegacyAndSecond": "cb2b7fd427dfc23709b3e63647207228d1b97e202c2f650b4aa639292698ec0d", + "hashFull": "fae456c9423f706ed6784f2e5210c7f9e2013f6adc7e2a7d93ea168659613e81", + "encrypted": "21d5b2ba82eab7a5c495f706e9ea1061fe8d64ffcd484ddd8d617a9ba24181c8b013ab8ee437bb7c15a291f5e75bde3f5d8cb9907f6c05b0fed4c8813d4b27adf1120e2414612ca0434c678ebc57e737" +} diff --git a/test/fixtures/generate.js b/test/fixtures/generate.js new file mode 100644 index 0000000..a9e6a74 --- /dev/null +++ b/test/fixtures/generate.js @@ -0,0 +1,51 @@ +// Regenerates committed fixtures in test/fixtures/encrypted-blobs/. +// Run by hand: `node test/fixtures/generate.js`. +// CI does NOT run this; it consumes the committed JSON output. + +const fs = require("fs"); +const path = require("path"); +const cryptoEngine = require("../../lib/cryptoEngine.js"); +const codec = require("../../lib/codec.js").init(cryptoEngine); + +const OUT_DIR = path.join(__dirname, "encrypted-blobs"); + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + + // Fixed inputs — chosen for readability and to cover non-ASCII. + const password = "testpassword123"; + const salt = "abcd1234abcd1234abcd1234abcd1234"; + const plaintext = "Hello, world! héllo wörld"; + + // Compute every intermediate hash so the compat tests can exercise each retry branch. + const hashLegacyOnly = await cryptoEngine.hashLegacyRound(password, salt); + const hashLegacyAndSecond = await cryptoEngine.hashSecondRound(hashLegacyOnly, salt); + const hashFull = await cryptoEngine.hashThirdRound(hashLegacyAndSecond, salt); + + // Sanity: hashFull must equal the public hashPassword() output. + const hashFullViaPublic = await cryptoEngine.hashPassword(password, salt); + if (hashFull !== hashFullViaPublic) { + throw new Error("hashPassword() drifted from manual 3-round chain"); + } + + // Encode with the fully hashed password — this is what's stored in real pages. + const encrypted = await codec.encodeWithHashedPassword(plaintext, hashFull); + + const fixture = { + plaintext, + password, + salt, + hashLegacyOnly, + hashLegacyAndSecond, + hashFull, + encrypted, + }; + + fs.writeFileSync(path.join(OUT_DIR, "basic.json"), JSON.stringify(fixture, null, 2) + "\n"); + console.log("Wrote", path.join(OUT_DIR, "basic.json")); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/test/formater.test.js b/test/formater.test.js new file mode 100644 index 0000000..4887942 --- /dev/null +++ b/test/formater.test.js @@ -0,0 +1,38 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { renderTemplate } = require("../lib/formater.js"); + +test("replaces a /*[|key|]*/0 token with the matching data value", () => { + const out = renderTemplate("hello /*[|name|]*/0!", { name: "world" }); + assert.equal(out, "hello world!"); +}); + +test("accepts optional whitespace inside the token brackets", () => { + const out = renderTemplate("a /*[| name |]*/0 b", { name: "X" }); + assert.equal(out, "a X b"); +}); + +test("accepts optional whitespace before the trailing 0 (prettier-formatted)", () => { + const out = renderTemplate("a /*[|name|]*/ 0 b", { name: "X" }); + assert.equal(out, "a X b"); +}); + +test("replaces multiple occurrences of the same key", () => { + const out = renderTemplate("/*[|x|]*/0 /*[|x|]*/0", { x: "Y" }); + assert.equal(out, "Y Y"); +}); + +test("object values are JSON-stringified", () => { + const out = renderTemplate("config = /*[|cfg|]*/0;", { cfg: { a: 1, b: "two" } }); + assert.equal(out, 'config = {"a":1,"b":"two"};'); +}); + +test("missing key falls back to the bare key name (current behavior)", () => { + const out = renderTemplate("hello /*[|missing|]*/0", { other: "x" }); + assert.equal(out, "hello missing"); +}); + +test("non-token text is preserved unchanged", () => { + const out = renderTemplate("plain text with /* not a token */", {}); + assert.equal(out, "plain text with /* not a token */"); +}); diff --git a/test/setup/jsdomEnv.js b/test/setup/jsdomEnv.js new file mode 100644 index 0000000..39d43ec --- /dev/null +++ b/test/setup/jsdomEnv.js @@ -0,0 +1,29 @@ +const { JSDOM } = require("jsdom"); +const { webcrypto } = require("node:crypto"); + +/** + * Build a jsdom window with WebCrypto wired up the way browsers expose it. + * The staticrypt browser runtime references `crypto.getRandomValues` and + * `crypto.subtle` as globals — jsdom does not provide them by default. + * + * Also injects TextEncoder/TextDecoder which are used by cryptoEngine but + * not always available in jsdom's script environment. + * + * @param {string} html - initial body HTML + * @param {string} url - location URL (controls window.location.search/hash) + * @returns {object} the jsdom Window + */ +function buildWindow(html = "", url = "https://example.com/") { + const dom = new JSDOM(html, { url, runScripts: "dangerously" }); + Object.defineProperty(dom.window, "crypto", { value: webcrypto, configurable: true }); + // jsdom's runScripts context may not expose TextEncoder/TextDecoder from Node. + if (!dom.window.TextEncoder) { + dom.window.TextEncoder = TextEncoder; + } + if (!dom.window.TextDecoder) { + dom.window.TextDecoder = TextDecoder; + } + return dom.window; +} + +module.exports = { buildWindow }; diff --git a/test/staticryptJs.test.js b/test/staticryptJs.test.js new file mode 100644 index 0000000..e691346 --- /dev/null +++ b/test/staticryptJs.test.js @@ -0,0 +1,126 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const cryptoEngine = require("../lib/cryptoEngine.js"); +const codec = require("../lib/codec.js").init(cryptoEngine); +const { buildStaticryptJS } = require("../cli/helpers.js"); +const { buildWindow } = require("./setup/jsdomEnv.js"); + +const FIXTURE = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "encrypted-blobs", "basic.json"), "utf8")); + +const STATICRYPT_JS_SOURCE = buildStaticryptJS(); + +// Standard templateConfig matching what's hardcoded in lib/password_template.html. +const TEMPLATE_CONFIG_LITERAL = `{ + rememberExpirationKey: "staticrypt_expiration", + rememberPassphraseKey: "staticrypt_passphrase", + replaceHtmlCallback: (html) => { window.__replacedWith = html; }, + clearLocalStorageCallback: undefined +}`; + +/** + * Build a fresh jsdom window with the staticrypt runtime evaluated in it, + * exposing `window.staticrypt` (the init() result) and `window.__replacedWith` + * (whatever the page would have been replaced with). + */ +function bootStaticrypt(staticryptConfig, url) { + const window = buildWindow("", url); + const initScript = ` + const staticryptModuleExports = ${STATICRYPT_JS_SOURCE}; + window.staticrypt = staticryptModuleExports.init( + ${JSON.stringify(staticryptConfig)}, + ${TEMPLATE_CONFIG_LITERAL} + ); + `; + window.eval(initScript); + return window; +} + +const STD_CONFIG = { + staticryptEncryptedMsgUniqueVariableName: FIXTURE.encrypted, + staticryptSaltUniqueVariableName: FIXTURE.salt, + isRememberEnabled: true, + rememberDurationInDays: 30, +}; + +test("init exposes handleDecryptionOfPage, handleDecryptionOfPageFromHash, handleDecryptOnLoad", () => { + const window = bootStaticrypt(STD_CONFIG, "https://example.com/"); + assert.equal(typeof window.staticrypt.handleDecryptionOfPage, "function"); + assert.equal(typeof window.staticrypt.handleDecryptionOfPageFromHash, "function"); + assert.equal(typeof window.staticrypt.handleDecryptOnLoad, "function"); +}); + +test("correct password decrypts and triggers replaceHtmlCallback with plaintext", async () => { + const window = bootStaticrypt(STD_CONFIG, "https://example.com/"); + const result = await window.staticrypt.handleDecryptionOfPage(FIXTURE.password, false); + assert.equal(result.isSuccessful, true); + assert.equal(window.__replacedWith, FIXTURE.plaintext); +}); + +test("wrong password returns isSuccessful=false and does NOT replace HTML", async () => { + const window = bootStaticrypt(STD_CONFIG, "https://example.com/"); + const result = await window.staticrypt.handleDecryptionOfPage("wrong-password", false); + assert.equal(result.isSuccessful, false); + assert.equal(window.__replacedWith, undefined); +}); + +test("remember-me writes hashedPassword + expiration to localStorage under documented keys", async () => { + const window = bootStaticrypt(STD_CONFIG, "https://example.com/"); + await window.staticrypt.handleDecryptionOfPage(FIXTURE.password, true); + assert.equal(window.localStorage.getItem("staticrypt_passphrase"), FIXTURE.hashFull); + const exp = parseInt(window.localStorage.getItem("staticrypt_expiration"), 10); + assert.ok(Number.isFinite(exp), "expiration is a finite integer"); + assert.ok(exp > Date.now(), "expiration is in the future"); +}); + +test("handleDecryptOnLoad: remember-me path decrypts on revisit when localStorage has the hash", async () => { + // First visit: prime localStorage. + const w1 = bootStaticrypt(STD_CONFIG, "https://example.com/"); + await w1.staticrypt.handleDecryptionOfPage(FIXTURE.password, true); + const storedHash = w1.localStorage.getItem("staticrypt_passphrase"); + const storedExp = w1.localStorage.getItem("staticrypt_expiration"); + + // Second visit: new window, prime localStorage with the values, then call handleDecryptOnLoad. + const w2 = bootStaticrypt(STD_CONFIG, "https://example.com/"); + w2.localStorage.setItem("staticrypt_passphrase", storedHash); + w2.localStorage.setItem("staticrypt_expiration", storedExp); + const { isSuccessful } = await w2.staticrypt.handleDecryptOnLoad(); + assert.equal(isSuccessful, true); + assert.equal(w2.__replacedWith, FIXTURE.plaintext); +}); + +test("handleDecryptOnLoad: expired remember-me clears localStorage and does NOT decrypt", async () => { + const window = bootStaticrypt(STD_CONFIG, "https://example.com/"); + window.localStorage.setItem("staticrypt_passphrase", FIXTURE.hashFull); + window.localStorage.setItem("staticrypt_expiration", "1"); // long expired + const { isSuccessful } = await window.staticrypt.handleDecryptOnLoad(); + assert.equal(isSuccessful, false); + assert.equal(window.localStorage.getItem("staticrypt_passphrase"), null); + assert.equal(window.localStorage.getItem("staticrypt_expiration"), null); +}); + +test("handleDecryptOnLoad: URL fragment #staticrypt_pwd= auto-decrypts", async () => { + const window = bootStaticrypt(STD_CONFIG, `https://example.com/#staticrypt_pwd=${FIXTURE.hashFull}`); + const { isSuccessful } = await window.staticrypt.handleDecryptOnLoad(); + // decryptOnLoadFromUrl returns the full handleDecryptionOfPageFromHash result object (truthy on success) + assert.ok(isSuccessful, "expected isSuccessful to be truthy"); + assert.equal(window.__replacedWith, FIXTURE.plaintext); +}); + +test("handleDecryptOnLoad: legacy query param ?staticrypt_pwd= auto-decrypts", async () => { + const window = bootStaticrypt(STD_CONFIG, `https://example.com/?staticrypt_pwd=${FIXTURE.hashFull}`); + const { isSuccessful } = await window.staticrypt.handleDecryptOnLoad(); + // decryptOnLoadFromUrl returns the full handleDecryptionOfPageFromHash result object (truthy on success) + assert.ok(isSuccessful, "expected isSuccessful to be truthy"); + assert.equal(window.__replacedWith, FIXTURE.plaintext); +}); + +test("logout via ?staticrypt_logout clears localStorage and skips decrypt", async () => { + const window = bootStaticrypt(STD_CONFIG, "https://example.com/?staticrypt_logout"); + window.localStorage.setItem("staticrypt_passphrase", FIXTURE.hashFull); + window.localStorage.setItem("staticrypt_expiration", String(Date.now() + 1_000_000)); + const { isSuccessful } = await window.staticrypt.handleDecryptOnLoad(); + assert.equal(isSuccessful, false); + assert.equal(window.localStorage.getItem("staticrypt_passphrase"), null); +});