From 0296a588f002e19381e22c10b7a73e10484173b8 Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 2 Dec 2025 21:13:59 +0100 Subject: [PATCH 1/3] test: move makeStorageKey tests to unit file, fix test title - Rename misleading "should handle lookup consistently within tolerance window" to "should return lock info for active lock" - Move Storage Key Consistency tests from E2E to new crypto.test.ts - Add comprehensive unit tests for generateLockId, hashKey, formatFence --- test/unit/crypto.test.ts | 172 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 test/unit/crypto.test.ts diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts new file mode 100644 index 0000000..73a30d2 --- /dev/null +++ b/test/unit/crypto.test.ts @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2025-present Kriasoft +// SPDX-License-Identifier: MIT + +/** + * Unit tests for crypto utilities (makeStorageKey, generateLockId, etc.) + */ + +import { describe, expect, it } from "bun:test"; +import { + formatFence, + generateLockId, + hashKey, + makeStorageKey, +} from "../../common/crypto.js"; + +describe("makeStorageKey", () => { + it("should preserve user key when no truncation needed", () => { + const userKey = "resource:payment:12345"; + + // Redis-style (prefix with reserve for derived keys) + const redisReserve = 25; // "id:" + 22-char lockId + const redisKey = makeStorageKey("test", userKey, 1000, redisReserve); + + // Firestore-style (no prefix, no reserve) + const firestoreKey = makeStorageKey("", userKey, 1500, 0); + + expect(redisKey).toBe("test:resource:payment:12345"); + expect(firestoreKey).toBe(userKey); + }); + + it("should hash keys that exceed backend limits", () => { + const longKey = "x".repeat(2000); + const redisReserve = 25; + + const redisKey = makeStorageKey("test", longKey, 1000, redisReserve); + const firestoreKey = makeStorageKey("", longKey, 1500, 0); + + // When truncation occurs, both should use hashed form (base64url) + expect(redisKey.length).toBeLessThanOrEqual(1000 - redisReserve); + expect(firestoreKey.length).toBeLessThanOrEqual(1500); + // Hashed keys don't contain "xxxx" patterns - they use base64url chars + expect(redisKey).toMatch(/^test:[A-Za-z0-9_-]+$/); + expect(firestoreKey).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("should derive fence keys from base storage key (two-step pattern)", () => { + const userKey = "resource:critical:operation"; + + // Redis: two-step derivation + const redisReserve = 25; + const redisBaseKey = makeStorageKey("test", userKey, 1000, redisReserve); + const redisFenceKey = makeStorageKey( + "test", + `fence:${redisBaseKey}`, + 1000, + redisReserve, + ); + + // Firestore: two-step derivation + const firestoreBaseKey = makeStorageKey("", userKey, 1500, 0); + const firestoreFenceDocId = makeStorageKey( + "", + `fence:${firestoreBaseKey}`, + 1500, + 0, + ); + + // Verify both backends use two-step pattern + expect(redisFenceKey).toContain("fence:"); + expect(firestoreFenceDocId).toContain("fence:"); + }); + + it("should ensure 1:1 mapping for long keys with hash truncation", () => { + const longKey = "x".repeat(2000); + const redisReserve = 25; + + const redisBaseLong = makeStorageKey("test", longKey, 1000, redisReserve); + const redisFenceLong = makeStorageKey( + "test", + `fence:${redisBaseLong}`, + 1000, + redisReserve, + ); + const firestoreBaseLong = makeStorageKey("", longKey, 1500, 0); + const firestoreFenceLong = makeStorageKey( + "", + `fence:${firestoreBaseLong}`, + 1500, + 0, + ); + + // Both backends ensure keys stay within limits + expect(redisBaseLong.length).toBeLessThanOrEqual(1000 - redisReserve); + expect(redisFenceLong.length).toBeLessThanOrEqual(1000 - redisReserve); + expect(firestoreBaseLong.length).toBeLessThanOrEqual(1500); + expect(firestoreFenceLong.length).toBeLessThanOrEqual(1500); + }); + + it("should strip trailing colons from prefix", () => { + const key = "resource"; + + // Both should produce the same result + const withColon = makeStorageKey("prefix:", key, 1000, 0); + const withoutColon = makeStorageKey("prefix", key, 1000, 0); + + expect(withColon).toBe("prefix:resource"); + expect(withoutColon).toBe("prefix:resource"); + }); + + it("should throw on empty key", () => { + expect(() => makeStorageKey("test", "", 1000, 0)).toThrow( + "Key must not be empty", + ); + }); + + it("should throw when prefix exceeds backend limit", () => { + const longPrefix = "x".repeat(500); + expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow( + "Prefix exceeds backend limit", + ); + }); +}); + +describe("generateLockId", () => { + it("should generate 22-character base64url lock IDs", () => { + const lockId = generateLockId(); + expect(lockId).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it("should generate unique IDs", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateLockId()); + } + expect(ids.size).toBe(100); + }); +}); + +describe("hashKey", () => { + it("should produce 24-character hex hash", () => { + const hash = hashKey("test-key"); + expect(hash).toMatch(/^[0-9a-f]{24}$/); + }); + + it("should produce deterministic output", () => { + const hash1 = hashKey("same-key"); + const hash2 = hashKey("same-key"); + expect(hash1).toBe(hash2); + }); + + it("should produce different hashes for different keys", () => { + const hash1 = hashKey("key-1"); + const hash2 = hashKey("key-2"); + expect(hash1).not.toBe(hash2); + }); +}); + +describe("formatFence", () => { + it("should format fence as 15-digit zero-padded string", () => { + expect(formatFence(1)).toBe("000000000000001"); + expect(formatFence(123456)).toBe("000000000123456"); + expect(formatFence(999999999999999n)).toBe("999999999999999"); + }); + + it("should throw on negative values", () => { + expect(() => formatFence(-1)).toThrow("non-negative"); + }); + + it("should throw on values exceeding 15 digits", () => { + expect(() => formatFence(1000000000000000n)).toThrow("exceeds 15-digit"); + }); +}); From e37cb094125860629f267ea5a0444f4d7822c490 Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 2 Dec 2025 21:18:48 +0100 Subject: [PATCH 2/3] test: consolidate crypto tests into common/crypto.test.ts --- test/unit/crypto.test.ts | 172 --------------------------------------- 1 file changed, 172 deletions(-) delete mode 100644 test/unit/crypto.test.ts diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts deleted file mode 100644 index 73a30d2..0000000 --- a/test/unit/crypto.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -// SPDX-FileCopyrightText: 2025-present Kriasoft -// SPDX-License-Identifier: MIT - -/** - * Unit tests for crypto utilities (makeStorageKey, generateLockId, etc.) - */ - -import { describe, expect, it } from "bun:test"; -import { - formatFence, - generateLockId, - hashKey, - makeStorageKey, -} from "../../common/crypto.js"; - -describe("makeStorageKey", () => { - it("should preserve user key when no truncation needed", () => { - const userKey = "resource:payment:12345"; - - // Redis-style (prefix with reserve for derived keys) - const redisReserve = 25; // "id:" + 22-char lockId - const redisKey = makeStorageKey("test", userKey, 1000, redisReserve); - - // Firestore-style (no prefix, no reserve) - const firestoreKey = makeStorageKey("", userKey, 1500, 0); - - expect(redisKey).toBe("test:resource:payment:12345"); - expect(firestoreKey).toBe(userKey); - }); - - it("should hash keys that exceed backend limits", () => { - const longKey = "x".repeat(2000); - const redisReserve = 25; - - const redisKey = makeStorageKey("test", longKey, 1000, redisReserve); - const firestoreKey = makeStorageKey("", longKey, 1500, 0); - - // When truncation occurs, both should use hashed form (base64url) - expect(redisKey.length).toBeLessThanOrEqual(1000 - redisReserve); - expect(firestoreKey.length).toBeLessThanOrEqual(1500); - // Hashed keys don't contain "xxxx" patterns - they use base64url chars - expect(redisKey).toMatch(/^test:[A-Za-z0-9_-]+$/); - expect(firestoreKey).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("should derive fence keys from base storage key (two-step pattern)", () => { - const userKey = "resource:critical:operation"; - - // Redis: two-step derivation - const redisReserve = 25; - const redisBaseKey = makeStorageKey("test", userKey, 1000, redisReserve); - const redisFenceKey = makeStorageKey( - "test", - `fence:${redisBaseKey}`, - 1000, - redisReserve, - ); - - // Firestore: two-step derivation - const firestoreBaseKey = makeStorageKey("", userKey, 1500, 0); - const firestoreFenceDocId = makeStorageKey( - "", - `fence:${firestoreBaseKey}`, - 1500, - 0, - ); - - // Verify both backends use two-step pattern - expect(redisFenceKey).toContain("fence:"); - expect(firestoreFenceDocId).toContain("fence:"); - }); - - it("should ensure 1:1 mapping for long keys with hash truncation", () => { - const longKey = "x".repeat(2000); - const redisReserve = 25; - - const redisBaseLong = makeStorageKey("test", longKey, 1000, redisReserve); - const redisFenceLong = makeStorageKey( - "test", - `fence:${redisBaseLong}`, - 1000, - redisReserve, - ); - const firestoreBaseLong = makeStorageKey("", longKey, 1500, 0); - const firestoreFenceLong = makeStorageKey( - "", - `fence:${firestoreBaseLong}`, - 1500, - 0, - ); - - // Both backends ensure keys stay within limits - expect(redisBaseLong.length).toBeLessThanOrEqual(1000 - redisReserve); - expect(redisFenceLong.length).toBeLessThanOrEqual(1000 - redisReserve); - expect(firestoreBaseLong.length).toBeLessThanOrEqual(1500); - expect(firestoreFenceLong.length).toBeLessThanOrEqual(1500); - }); - - it("should strip trailing colons from prefix", () => { - const key = "resource"; - - // Both should produce the same result - const withColon = makeStorageKey("prefix:", key, 1000, 0); - const withoutColon = makeStorageKey("prefix", key, 1000, 0); - - expect(withColon).toBe("prefix:resource"); - expect(withoutColon).toBe("prefix:resource"); - }); - - it("should throw on empty key", () => { - expect(() => makeStorageKey("test", "", 1000, 0)).toThrow( - "Key must not be empty", - ); - }); - - it("should throw when prefix exceeds backend limit", () => { - const longPrefix = "x".repeat(500); - expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow( - "Prefix exceeds backend limit", - ); - }); -}); - -describe("generateLockId", () => { - it("should generate 22-character base64url lock IDs", () => { - const lockId = generateLockId(); - expect(lockId).toMatch(/^[A-Za-z0-9_-]{22}$/); - }); - - it("should generate unique IDs", () => { - const ids = new Set(); - for (let i = 0; i < 100; i++) { - ids.add(generateLockId()); - } - expect(ids.size).toBe(100); - }); -}); - -describe("hashKey", () => { - it("should produce 24-character hex hash", () => { - const hash = hashKey("test-key"); - expect(hash).toMatch(/^[0-9a-f]{24}$/); - }); - - it("should produce deterministic output", () => { - const hash1 = hashKey("same-key"); - const hash2 = hashKey("same-key"); - expect(hash1).toBe(hash2); - }); - - it("should produce different hashes for different keys", () => { - const hash1 = hashKey("key-1"); - const hash2 = hashKey("key-2"); - expect(hash1).not.toBe(hash2); - }); -}); - -describe("formatFence", () => { - it("should format fence as 15-digit zero-padded string", () => { - expect(formatFence(1)).toBe("000000000000001"); - expect(formatFence(123456)).toBe("000000000123456"); - expect(formatFence(999999999999999n)).toBe("999999999999999"); - }); - - it("should throw on negative values", () => { - expect(() => formatFence(-1)).toThrow("non-negative"); - }); - - it("should throw on values exceeding 15 digits", () => { - expect(() => formatFence(1000000000000000n)).toThrow("exceeds 15-digit"); - }); -}); From a5c01a757c74ff176e007b13afead1c2c72dd120 Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 2 Dec 2025 23:54:48 +0100 Subject: [PATCH 3/3] fix: enable OIDC auth for npm trusted publishing Remove registry-url to prevent actions/setup-node from creating token-based .npmrc. Clear any stale npm tokens to ensure OIDC flow is used for provenance signing. --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4540b9f..aaa734b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,6 @@ jobs: - uses: actions/setup-node@v6 with: node-version: "22" - registry-url: "https://registry.npmjs.org" - uses: actions/cache@v4 with: path: ~/.bun/install/cache @@ -34,5 +33,11 @@ jobs: restore-keys: ${{ runner.os }}-bun- - run: bun install --frozen-lockfile - run: bun run build - - run: npm publish --access public --provenance + - name: Publish to npm (OIDC) + run: | + rm -f ~/.npmrc + npm publish --access public --provenance working-directory: ./dist + env: + NODE_AUTH_TOKEN: "" + NPM_TOKEN: ""