|
| 1 | +import { sealData, unsealData } from './seal'; |
| 2 | + |
| 3 | +describe('seal', () => { |
| 4 | + const password = 'test-password-at-least-32-characters-long'; |
| 5 | + |
| 6 | + describe('sealData', () => { |
| 7 | + it('seals data and appends version delimiter', async () => { |
| 8 | + const data = { foo: 'bar' }; |
| 9 | + const sealed = await sealData(data, { password }); |
| 10 | + |
| 11 | + expect(sealed).toContain('~2'); |
| 12 | + expect(sealed.startsWith('Fe26.2*')).toBe(true); |
| 13 | + }); |
| 14 | + |
| 15 | + it('seals with TTL', async () => { |
| 16 | + const data = { foo: 'bar' }; |
| 17 | + const sealed = await sealData(data, { password, ttl: 3600 }); |
| 18 | + |
| 19 | + expect(sealed).toContain('~2'); |
| 20 | + }); |
| 21 | + }); |
| 22 | + |
| 23 | + describe('unsealData', () => { |
| 24 | + it('round-trips seal/unseal', async () => { |
| 25 | + const data = { userId: '123', email: '[email protected]' }; |
| 26 | + const sealed = await sealData(data, { password }); |
| 27 | + const unsealed = await unsealData<typeof data>(sealed, { password }); |
| 28 | + |
| 29 | + expect(unsealed).toEqual(data); |
| 30 | + }); |
| 31 | + |
| 32 | + it('round-trips complex nested data', async () => { |
| 33 | + const data = { |
| 34 | + user: { id: '123', name: 'Test' }, |
| 35 | + roles: ['admin', 'user'], |
| 36 | + metadata: { nested: { deep: true } }, |
| 37 | + }; |
| 38 | + const sealed = await sealData(data, { password }); |
| 39 | + const unsealed = await unsealData<typeof data>(sealed, { password }); |
| 40 | + |
| 41 | + expect(unsealed).toEqual(data); |
| 42 | + }); |
| 43 | + |
| 44 | + it('handles seals with TTL (expiration embedded in seal)', async () => { |
| 45 | + const data = { foo: 'bar' }; |
| 46 | + // Seal with TTL - expiration timestamp is embedded in the seal |
| 47 | + const sealed = await sealData(data, { password, ttl: 3600 }); |
| 48 | + |
| 49 | + // Verify expiration field is present (5th component, non-empty) |
| 50 | + const parts = sealed.split('~')[0].split('*'); |
| 51 | + expect(parts[5]).toMatch(/^\d+$/); // Non-empty expiration timestamp |
| 52 | + |
| 53 | + // Should unseal successfully within TTL window |
| 54 | + const unsealed = await unsealData(sealed, { password }); |
| 55 | + expect(unsealed).toEqual(data); |
| 56 | + }); |
| 57 | + |
| 58 | + it('returns empty object for bad password', async () => { |
| 59 | + const data = { foo: 'bar' }; |
| 60 | + const sealed = await sealData(data, { password }); |
| 61 | + const unsealed = await unsealData(sealed, { |
| 62 | + password: 'wrong-password-at-least-32-characters', |
| 63 | + }); |
| 64 | + |
| 65 | + expect(unsealed).toEqual({}); |
| 66 | + }); |
| 67 | + |
| 68 | + it('returns empty object for tampered seal', async () => { |
| 69 | + const data = { foo: 'bar' }; |
| 70 | + const sealed = await sealData(data, { password }); |
| 71 | + // Tamper with the sealed data |
| 72 | + const tampered = sealed.replace('Fe26.2', 'Fe26.2tampered'); |
| 73 | + const unsealed = await unsealData(tampered, { password }); |
| 74 | + |
| 75 | + expect(unsealed).toEqual({}); |
| 76 | + }); |
| 77 | + |
| 78 | + it('handles legacy tokens with persistent field (version 1)', async () => { |
| 79 | + // Manually construct a v1-style token that would have persistent wrapper |
| 80 | + const data = { persistent: { userId: '123' } }; |
| 81 | + const sealed = await sealData(data, { password }); |
| 82 | + // Replace ~2 with ~1 to simulate v1 token |
| 83 | + const v1Sealed = sealed.replace('~2', '~1'); |
| 84 | + |
| 85 | + const unsealed = await unsealData<{ userId: string }>(v1Sealed, { |
| 86 | + password, |
| 87 | + }); |
| 88 | + expect(unsealed).toEqual({ userId: '123' }); |
| 89 | + }); |
| 90 | + |
| 91 | + it('handles tokens without version delimiter', async () => { |
| 92 | + const data = { foo: 'bar' }; |
| 93 | + const sealed = await sealData(data, { password }); |
| 94 | + // Remove version delimiter to simulate pre-versioned token |
| 95 | + const noVersion = sealed.split('~')[0]; |
| 96 | + |
| 97 | + const unsealed = await unsealData<typeof data>(noVersion, { password }); |
| 98 | + expect(unsealed).toEqual(data); |
| 99 | + }); |
| 100 | + }); |
| 101 | + |
| 102 | + describe('iron-session compatibility', () => { |
| 103 | + // This test verifies that tokens sealed with this implementation |
| 104 | + // produce the same format as iron-session (Fe26.2 prefix with ~2 suffix) |
| 105 | + it('produces iron-session compatible format', async () => { |
| 106 | + const data = { userId: '123' }; |
| 107 | + const sealed = await sealData(data, { password }); |
| 108 | + |
| 109 | + // Verify format: Fe26.2*...*~2 |
| 110 | + const parts = sealed.split('~'); |
| 111 | + expect(parts).toHaveLength(2); |
| 112 | + expect(parts[1]).toBe('2'); |
| 113 | + |
| 114 | + const ironPart = parts[0]; |
| 115 | + expect(ironPart.startsWith('Fe26.2*')).toBe(true); |
| 116 | + |
| 117 | + // Iron format has 8 asterisk-delimited components |
| 118 | + const components = ironPart.split('*'); |
| 119 | + expect(components).toHaveLength(8); |
| 120 | + expect(components[0]).toBe('Fe26.2'); |
| 121 | + }); |
| 122 | + |
| 123 | + // Verify that tokens can be round-tripped and maintain the expected format |
| 124 | + // This ensures compatibility with iron-session which uses the same Fe26.2 format |
| 125 | + it('can unseal self-generated tokens (simulates iron-session compatibility)', async () => { |
| 126 | + const data = { userId: '123', email: '[email protected]' }; |
| 127 | + const sealed = await sealData(data, { password }); |
| 128 | + |
| 129 | + // Verify the format matches iron-session's expected output |
| 130 | + expect(sealed).toMatch(/^Fe26\.2\*1\*[^*]+\*[^*]+\*[^*]+\*\*[^*]+\*[^*]+~2$/); |
| 131 | + |
| 132 | + const unsealed = await unsealData<typeof data>(sealed, { password }); |
| 133 | + expect(unsealed).toEqual(data); |
| 134 | + }); |
| 135 | + }); |
| 136 | +}); |
0 commit comments