AtAuth
at main 271 lines 6.4 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2import { 3 decodeToken, 4 isTokenExpired, 5 getTokenRemainingSeconds, 6 getTokenAgeSeconds, 7 shouldRefreshToken, 8 getDisplayName, 9 isValidDid, 10 isValidHandle, 11} from './token'; 12 13// Helper to create a valid token payload 14function createPayload(overrides = {}) { 15 const now = Math.floor(Date.now() / 1000); 16 return { 17 did: 'did:plc:abc123', 18 handle: 'alice.bsky.social', 19 user_id: 1, 20 app_id: 'testapp', 21 iat: now, 22 exp: now + 3600, // 1 hour 23 nonce: 'test-nonce', 24 ...overrides, 25 }; 26} 27 28// Helper to encode a payload as a token (without signature) 29function encodePayload(payload: object): string { 30 const json = JSON.stringify(payload); 31 const b64 = Buffer.from(json).toString('base64url'); 32 return `${b64}.fake-signature`; 33} 34 35describe('decodeToken', () => { 36 it('decodes a valid token', () => { 37 const payload = createPayload(); 38 const token = encodePayload(payload); 39 const decoded = decodeToken(token); 40 41 expect(decoded).not.toBeNull(); 42 expect(decoded?.did).toBe(payload.did); 43 expect(decoded?.handle).toBe(payload.handle); 44 }); 45 46 it('returns null for invalid format (no dot)', () => { 47 expect(decodeToken('invalid-token')).toBeNull(); 48 }); 49 50 it('returns null for invalid format (too many dots)', () => { 51 expect(decodeToken('a.b.c')).toBeNull(); 52 }); 53 54 it('returns null for invalid base64', () => { 55 expect(decodeToken('!!!invalid!!!.signature')).toBeNull(); 56 }); 57 58 it('returns null for invalid JSON', () => { 59 const invalidJson = Buffer.from('not json').toString('base64url'); 60 expect(decodeToken(`${invalidJson}.signature`)).toBeNull(); 61 }); 62}); 63 64describe('isTokenExpired', () => { 65 beforeEach(() => { 66 vi.useFakeTimers(); 67 }); 68 69 afterEach(() => { 70 vi.useRealTimers(); 71 }); 72 73 it('returns false for non-expired token', () => { 74 const now = 1700000000000; 75 vi.setSystemTime(now); 76 77 const payload = createPayload({ 78 iat: 1700000000, 79 exp: 1700003600, // 1 hour from now 80 }); 81 82 expect(isTokenExpired(payload)).toBe(false); 83 }); 84 85 it('returns true for expired token', () => { 86 const now = 1700010000000; // Well past exp 87 vi.setSystemTime(now); 88 89 const payload = createPayload({ 90 iat: 1700000000, 91 exp: 1700003600, 92 }); 93 94 expect(isTokenExpired(payload)).toBe(true); 95 }); 96 97 it('respects clock skew tolerance', () => { 98 const now = 1700003610000; // 10 seconds past exp 99 vi.setSystemTime(now); 100 101 const payload = createPayload({ 102 iat: 1700000000, 103 exp: 1700003600, 104 }); 105 106 // With default 30s skew, should not be expired 107 expect(isTokenExpired(payload, 30)).toBe(false); 108 109 // With 0s skew, should be expired 110 expect(isTokenExpired(payload, 0)).toBe(true); 111 }); 112}); 113 114describe('getTokenRemainingSeconds', () => { 115 beforeEach(() => { 116 vi.useFakeTimers(); 117 }); 118 119 afterEach(() => { 120 vi.useRealTimers(); 121 }); 122 123 it('returns correct remaining time', () => { 124 const now = 1700000000000; 125 vi.setSystemTime(now); 126 127 const payload = createPayload({ 128 exp: 1700003600, // 3600 seconds from now 129 }); 130 131 expect(getTokenRemainingSeconds(payload)).toBe(3600); 132 }); 133 134 it('returns 0 for expired token', () => { 135 const now = 1700010000000; 136 vi.setSystemTime(now); 137 138 const payload = createPayload({ 139 exp: 1700003600, 140 }); 141 142 expect(getTokenRemainingSeconds(payload)).toBe(0); 143 }); 144}); 145 146describe('getTokenAgeSeconds', () => { 147 beforeEach(() => { 148 vi.useFakeTimers(); 149 }); 150 151 afterEach(() => { 152 vi.useRealTimers(); 153 }); 154 155 it('returns correct age', () => { 156 const now = 1700001800000; // 1800 seconds after iat 157 vi.setSystemTime(now); 158 159 const payload = createPayload({ 160 iat: 1700000000, 161 }); 162 163 expect(getTokenAgeSeconds(payload)).toBe(1800); 164 }); 165}); 166 167describe('shouldRefreshToken', () => { 168 beforeEach(() => { 169 vi.useFakeTimers(); 170 }); 171 172 afterEach(() => { 173 vi.useRealTimers(); 174 }); 175 176 it('returns false when plenty of time remaining', () => { 177 const now = 1700000000000; 178 vi.setSystemTime(now); 179 180 const payload = createPayload({ 181 exp: 1700003600, // 3600 seconds remaining 182 }); 183 184 expect(shouldRefreshToken(payload, 300)).toBe(false); 185 }); 186 187 it('returns true when below threshold', () => { 188 const now = 1700003400000; // 200 seconds remaining 189 vi.setSystemTime(now); 190 191 const payload = createPayload({ 192 exp: 1700003600, 193 }); 194 195 expect(shouldRefreshToken(payload, 300)).toBe(true); 196 }); 197}); 198 199describe('getDisplayName', () => { 200 it('extracts username from handle', () => { 201 expect(getDisplayName('alice.bsky.social')).toBe('alice'); 202 }); 203 204 it('handles single-part handle', () => { 205 expect(getDisplayName('alice')).toBe('alice'); 206 }); 207 208 it('handles empty string', () => { 209 expect(getDisplayName('')).toBe(''); 210 }); 211}); 212 213describe('isValidDid', () => { 214 it('accepts valid did:plc', () => { 215 expect(isValidDid('did:plc:abc123')).toBe(true); 216 }); 217 218 it('accepts valid did:web', () => { 219 expect(isValidDid('did:web:example.com')).toBe(true); 220 }); 221 222 it('rejects empty string', () => { 223 expect(isValidDid('')).toBe(false); 224 }); 225 226 it('rejects non-did string', () => { 227 expect(isValidDid('not-a-did')).toBe(false); 228 }); 229 230 it('rejects did with missing parts', () => { 231 expect(isValidDid('did:plc')).toBe(false); 232 expect(isValidDid('did:')).toBe(false); 233 }); 234 235 it('rejects oversized did', () => { 236 const longDid = 'did:plc:' + 'a'.repeat(600); 237 expect(isValidDid(longDid)).toBe(false); 238 }); 239}); 240 241describe('isValidHandle', () => { 242 it('accepts valid handle', () => { 243 expect(isValidHandle('alice.bsky.social')).toBe(true); 244 }); 245 246 it('accepts short TLD', () => { 247 expect(isValidHandle('user.co')).toBe(true); 248 }); 249 250 it('rejects empty string', () => { 251 expect(isValidHandle('')).toBe(false); 252 }); 253 254 it('rejects handle without dot', () => { 255 expect(isValidHandle('alice')).toBe(false); 256 }); 257 258 it('rejects single-char TLD', () => { 259 expect(isValidHandle('user.a')).toBe(false); 260 }); 261 262 it('rejects oversized handle', () => { 263 const longHandle = 'a'.repeat(200) + '.com'; 264 expect(isValidHandle(longHandle)).toBe(false); 265 }); 266 267 it('rejects handle with empty segment', () => { 268 expect(isValidHandle('alice..social')).toBe(false); 269 expect(isValidHandle('.bsky.social')).toBe(false); 270 }); 271});