diff --git a/test/GoTrueClient.browser.test.ts b/test/GoTrueClient.browser.test.ts index 89248b8..a3fe50d 100644 --- a/test/GoTrueClient.browser.test.ts +++ b/test/GoTrueClient.browser.test.ts @@ -105,6 +105,31 @@ describe('GoTrueClient in browser environment', () => { expect(signinError).toBeNull() expect(signinData?.session).toBeDefined() }) + + it('should handle _handleVisibilityChange error handling', async () => { + const client = getClientWithSpecificStorage({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }) + + // Mock window.addEventListener to throw error + const originalAddEventListener = window.addEventListener + window.addEventListener = jest.fn().mockImplementation(() => { + throw new Error('addEventListener failed') + }) + + try { + // Initialize client to trigger _handleVisibilityChange + await client.initialize() + + // Should not throw error, should handle it gracefully + expect(client).toBeDefined() + } finally { + // Restore original addEventListener + window.addEventListener = originalAddEventListener + } + }) }) describe('Browser-specific helper functions', () => { @@ -234,6 +259,65 @@ describe('Callback URL handling', () => { } = await client.getSession() expect(session).toBeNull() }) + + it('should handle _initialize with detectSessionInUrl', async () => { + // Mock window.location with session parameters + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost:9999/callback?access_token=test&refresh_token=test&expires_in=3600&token_type=bearer&type=recovery', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + toString: () => 'http://localhost:9999/callback', + }, + writable: true, + }) + + const client = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + detectSessionInUrl: true, + autoRefreshToken: false, + }) + + // Initialize client to trigger _initialize with detectSessionInUrl + await client.initialize() + + expect(client).toBeDefined() + }) + + it('should handle _initialize with PKCE flow mismatch', async () => { + // Mock window.location with PKCE parameters + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost:9999/callback?code=test-code', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + toString: () => 'http://localhost:9999/callback', + }, + writable: true, + }) + + // Mock storage to return code verifier + const mockStorage = { + getItem: jest.fn().mockResolvedValue('test-code-verifier'), + setItem: jest.fn(), + removeItem: jest.fn(), + } + + const client = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + detectSessionInUrl: true, + autoRefreshToken: false, + storage: mockStorage, + flowType: 'implicit', // Mismatch with PKCE flow + }) + + // Initialize client to trigger flow mismatch + await client.initialize() + + expect(client).toBeDefined() + }) }) describe('GoTrueClient BroadcastChannel', () => { @@ -334,6 +418,30 @@ describe('Browser locks functionality', () => { expect(client).toBeDefined() }) + + it('should handle _acquireLock with empty pendingInLock', async () => { + const client = getClientWithSpecificStorage({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }) + + // Mock navigator.locks + const mockLock = { name: 'test-lock' } + const mockRequest = jest.fn().mockImplementation((_, __, callback) => + Promise.resolve(callback(mockLock)) + ) + + Object.defineProperty(navigator, 'locks', { + value: { request: mockRequest }, + writable: true, + }) + + // Initialize client to trigger lock acquisition + await client.initialize() + + expect(client).toBeDefined() + }) }) describe('Web3 functionality in browser', () => { @@ -376,3 +484,236 @@ describe('GoTrueClient constructor edge cases', () => { expect((client as any).userStorage).toBe(customUserStorage) }) }) + +describe('linkIdentity with skipBrowserRedirect false', () => { + + it('should linkIdentity with skipBrowserRedirect false', async () => { + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost:9999', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + toString: () => 'http://localhost:9999', + }, + writable: true, + }) + // Mock successful session + const mockSession = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + token_type: 'bearer', + user: { id: 'test-user' }, + } + + // Mock storage to return session + const mockStorage = { + getItem: jest.fn().mockResolvedValue(JSON.stringify(mockSession)), + setItem: jest.fn(), + removeItem: jest.fn(), + } + + // Create client with custom fetch + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ url: 'http://localhost:9999/oauth/callback' }), + text: () => Promise.resolve('{"url": "http://localhost:9999/oauth/callback"}'), + headers: new Headers(), + } as Response) + + const clientWithSession = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + storageKey: 'test-specific-storage', + autoRefreshToken: false, + persistSession: true, + storage: mockStorage, + fetch: mockFetch, + }) + + // Mock window.location.assign + const mockAssign = jest.fn() + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost:9999', + assign: mockAssign, + replace: jest.fn(), + reload: jest.fn(), + toString: () => 'http://localhost:9999', + }, + writable: true, + }) + + try { + const result = await clientWithSession.linkIdentity({ + provider: 'github', + options: { + skipBrowserRedirect: false, + }, + }) + + expect(result.data?.url).toBeDefined() + expect(mockFetch).toHaveBeenCalled() + // Note: linkIdentity might not always call window.location.assign depending on the response + // So we just verify the result is defined + } catch (error) { + console.error('Test error:', error) + throw error + } + }) +}) + +describe('Session Management Tests', () => { + it('should handle _recoverAndRefresh with Infinity expires_at', async () => { + // Mock session with null expires_at + const mockSession = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + expires_at: null, + token_type: 'bearer', + user: { id: 'test-user' }, + } + + const mockStorage = { + getItem: jest.fn().mockResolvedValue(JSON.stringify(mockSession)), + setItem: jest.fn(), + removeItem: jest.fn(), + } + + const client = getClientWithSpecificStorage(mockStorage) + + // Initialize client to trigger _recoverAndRefresh with Infinity expires_at + await client.initialize() + + expect(client).toBeDefined() + }) + + it('should handle _recoverAndRefresh with refresh token error', async () => { + // Mock session + const mockSession = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) - 100, // Expired + token_type: 'bearer', + user: { id: 'test-user' }, + } + + const mockStorage = { + getItem: jest.fn().mockResolvedValue(JSON.stringify(mockSession)), + setItem: jest.fn(), + removeItem: jest.fn(), + } + + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: 'invalid_grant' }), + text: () => Promise.resolve('{"error": "invalid_grant"}'), + headers: new Headers(), + } as Response) + + const client = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + autoRefreshToken: true, + persistSession: true, + storage: mockStorage, + fetch: mockFetch, + }) + + // Initialize client to trigger refresh token error + await client.initialize() + + expect(client).toBeDefined() + }) + + it('should handle _recoverAndRefresh with user proxy', async () => { + // Mock session with proxy user + const mockSession = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) + 3600, // Valid + token_type: 'bearer', + user: { __isUserNotAvailableProxy: true }, + } + + const mockStorage = { + getItem: jest.fn().mockResolvedValue(JSON.stringify(mockSession)), + setItem: jest.fn(), + removeItem: jest.fn(), + } + + // Mock fetch to return user data + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ user: { id: 'test-user', email: 'test@example.com' } }), + text: () => Promise.resolve('{"user": {"id": "test-user", "email": "test@example.com"}}'), + headers: new Headers(), + } as Response) + + const client = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + autoRefreshToken: true, + persistSession: true, + storage: mockStorage, + fetch: mockFetch, + }) + + // Initialize client to trigger user proxy handling + await client.initialize() + + expect(client).toBeDefined() + }) +}) + +describe('Additional Tests', () => { + it('should handle _initialize with storage returning boolean', async () => { + // Mock storage to return boolean + const mockStorage = { + getItem: jest.fn().mockResolvedValue(true), + setItem: jest.fn(), + removeItem: jest.fn(), + } + + const client = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + autoRefreshToken: false, + persistSession: true, + storage: mockStorage, + }) + + // Initialize client to trigger boolean handling + await client.initialize() + + expect(client).toBeDefined() + }) + + it('should handle _initialize with expires_at parameter', async () => { + // Mock window.location with expires_at parameter + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost:9999/callback?access_token=test&refresh_token=test&expires_in=3600&expires_at=1234567890&token_type=bearer', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + toString: () => 'http://localhost:9999/callback', + }, + writable: true, + }) + + const client = new (require('../src/GoTrueClient').default)({ + url: 'http://localhost:9999', + detectSessionInUrl: true, + autoRefreshToken: false, + }) + + // Initialize client to trigger _initialize with expires_at + await client.initialize() + + expect(client).toBeDefined() + }) +}) diff --git a/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index c5502ad..8e2f4cd 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -39,6 +39,39 @@ describe('GoTrueClient', () => { refreshAccessTokenSpy.mockClear() }) + test('should handle debug function in settings', async () => { + const debugSpy = jest.fn() + const client = new GoTrueClient({ + url: 'http://localhost:9999', + debug: debugSpy, + }) + + await client.initialize() + expect(debugSpy).toHaveBeenCalled() + }) + + test('should handle custom lock implementation', async () => { + const customLock = jest.fn().mockResolvedValue(undefined) + const client = new GoTrueClient({ + url: 'http://localhost:9999', + lock: customLock, + }) + + await client.initialize() + expect(customLock).toHaveBeenCalled() + }) + + test('should handle userStorage configuration', async () => { + const userStorage = memoryLocalStorageAdapter() + const client = new GoTrueClient({ + url: 'http://localhost:9999', + userStorage, + }) + + await client.initialize() + expect(client['userStorage']).toBe(userStorage) + }) + describe('Sessions', () => { test('refreshSession() should return a new session using a passed-in refresh token', async () => { const { email, password } = mockUserCredentials() @@ -557,6 +590,67 @@ describe('GoTrueClient', () => { expect(data.session).toBeNull() expect(data.user).toBeNull() }) + + test('should handle signUp with invalid credentials', async () => { + const { data, error } = await auth.signUp({ + email: 'invalid-email', + password: '123', // too short + }) + + expect(error).toBeDefined() + expect(data.user).toBeNull() + expect(data.session).toBeNull() + }) + + test('should handle signInWithPassword with invalid credentials', async () => { + const { data, error } = await auth.signInWithPassword({ + email: 'nonexistent@example.com', + password: 'wrongpassword', + }) + + expect(error).toBeDefined() + expect(data.user).toBeNull() + expect(data.session).toBeNull() + }) + + test('should handle signInWithOAuth with invalid provider', async () => { + const { data, error } = await auth.signInWithOAuth({ + provider: 'invalid-provider' as any, + }) + + expect(error).toBeNull() + expect(data.url).toBeDefined() + }) + + test('should handle signInWithOtp with invalid email', async () => { + const { data, error } = await auth.signInWithOtp({ + email: 'invalid-email', + }) + + expect(error).toBeDefined() + expect(data.user).toBeNull() + }) + + test('should handle signInWithOtp with invalid phone', async () => { + const { data, error } = await auth.signInWithOtp({ + phone: 'invalid-phone', + }) + + expect(error).toBeDefined() + expect(data.user).toBeNull() + }) + + test('should handle verifyOtp with invalid code', async () => { + const { data, error } = await auth.verifyOtp({ + email: 'test@example.com', + token: 'invalid-token', + type: 'email', + }) + + expect(error).toBeDefined() + expect(data.user).toBeNull() + expect(data.session).toBeNull() + }) }) describe('signInWithOtp', () => { @@ -1146,6 +1240,40 @@ describe('The auth client can signin with third-party oAuth providers', () => { expect(provider).toBeTruthy() }) + test('should handle exchangeCodeForSession with invalid code', async () => { + const { data, error } = await auth.exchangeCodeForSession('invalid-code') + expect(error).toBeDefined() + expect(data.session).toBeNull() + expect(data.user).toBeNull() + }) + + test('should handle signInWithOAuth with redirectTo option', async () => { + const { data, error } = await auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: 'http://localhost:3000/callback', + }, + }) + + expect(error).toBeNull() + expect(data.url).toBeDefined() + }) + + test('should handle signInWithOAuth with queryParams', async () => { + const { data, error } = await auth.signInWithOAuth({ + provider: 'github', + options: { + queryParams: { + access_type: 'offline', + prompt: 'consent', + }, + }, + }) + + expect(error).toBeNull() + expect(data.url).toBeDefined() + }) + describe('Developers can subscribe and unsubscribe', () => { const { data: { subscription }, @@ -1422,6 +1550,45 @@ describe('MFA', () => { expect(result.data.phone).toHaveLength(1) } }) + + test('should handle MFA enroll without session', async () => { + await auth.signOut() + const { data, error } = await auth.mfa.enroll({ + factorType: 'totp', + }) + + expect(error).toBeDefined() + expect(data?.id).toBeUndefined() + }) + + test('should handle MFA challenge without session', async () => { + const { data, error } = await auth.mfa.challenge({ + factorId: 'test-factor-id', + }) + + expect(error).toBeDefined() + expect(data?.id).toBeUndefined() + }) + + test('should handle MFA verify without session', async () => { + const { data, error } = await auth.mfa.verify({ + factorId: 'test-factor-id', + challengeId: 'test-challenge-id', + code: '123456', + }) + + expect(error).toBeDefined() + expect(data?.access_token).toBeUndefined() + }) + + test('should handle MFA unenroll without session', async () => { + const { data, error } = await auth.mfa.unenroll({ + factorId: 'test-factor-id', + }) + + expect(error).toBeDefined() + expect(data?.id).toBeUndefined() + }) }) describe('getClaims', () => { @@ -2038,6 +2205,74 @@ describe('Web3 Authentication', () => { '@supabase/auth-js: No accounts available. Please ensure the wallet is connected.' ) }) + + test('should handle signInWithWeb3 with unsupported chain', async () => { + const credentials = { + chain: 'polygon' as any, + message: 'test message', + signature: '0xtest-signature' as any, + } + + await expect( + auth.signInWithWeb3(credentials) + ).rejects.toThrow('Unsupported chain "polygon"') + }) + + test('should handle signInWithWeb3 with missing message', async () => { + const credentials = { + chain: 'ethereum', + signature: 'test signature', + } as any + + await expect( + auth.signInWithWeb3(credentials) + ).rejects.toThrow('Both wallet and url must be specified in non-browser environments') + }) + + test('should handle signInWithWeb3 with missing wallet', async () => { + const credentials = { + chain: 'ethereum', + message: 'test message', + } as any + + const { data, error } = await auth.signInWithWeb3(credentials) + expect(error).toBeDefined() + expect(data.session).toBeNull() + expect(data.user).toBeNull() + }) + + test('should handle signInWithWeb3 with invalid signature', async () => { + const credentials = { + chain: 'ethereum', + message: 'test message', + signature: '0xinvalid-signature' as any, + } as any + + const { data, error } = await auth.signInWithWeb3(credentials) + expect(error).toBeDefined() + expect(data.session).toBeNull() + expect(data.user).toBeNull() + }) + + test('should handle signInWithWeb3 in non-browser environment', async () => { + const credentials = { + chain: 'ethereum', + message: 'test message', + wallet: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + options: { + url: 'http://localhost:9999', + }, + } as any + + const { data, error } = await auth.signInWithWeb3(credentials) + expect(error).toBeDefined() + expect(data.session).toBeNull() + expect(data.user).toBeNull() + }) }) describe('ID Token Authentication', () => { @@ -2239,6 +2474,14 @@ describe('Auto Refresh', () => { expect(session?.access_token).not.toEqual(signUpData.session?.access_token) }) }) + + test('should handle auto refresh start/stop multiple times', async () => { + await autoRefreshClient.startAutoRefresh() + await autoRefreshClient.startAutoRefresh() // Should handle duplicate calls + + await autoRefreshClient.stopAutoRefresh() + await autoRefreshClient.stopAutoRefresh() // Should handle duplicate calls + }) }) describe('Session Management', () => { @@ -2285,6 +2528,60 @@ describe('Session Management', () => { // Cleanup subscription?.unsubscribe() }) + + test('should handle getSession when no session exists', async () => { + // Clear any existing session first + await auth.signOut() + const { data, error } = await auth.getSession() + expect(data.session).toBeNull() + expect(error).toBeNull() + }) + + test('should handle refreshSession with invalid refresh token', async () => { + const { data, error } = await auth.refreshSession({ + refresh_token: 'invalid-refresh-token', + }) + + expect(error).toBeDefined() + expect(data.session).toBeNull() + }) + + test('should handle setSession with invalid session data', async () => { + const { data, error } = await auth.setSession({ + access_token: 'invalid-token', + refresh_token: 'invalid-refresh-token', + }) + + expect(error).toBeDefined() + expect(data.session).toBeNull() + }) + + test('should handle getUser without valid session', async () => { + await auth.signOut() + const { data, error } = await auth.getUser() + expect(data.user).toBeNull() + expect(error).toBeDefined() + }) + + test('should handle updateUser without valid session', async () => { + await auth.signOut() + const { data, error } = await auth.updateUser({ + data: { name: 'Test User' }, + }) + + expect(error).toBeDefined() + expect(data.user).toBeNull() + }) + + test('should handle custom URL parameters', async () => { + const client = new GoTrueClient({ + url: 'http://localhost:9999', + detectSessionInUrl: false, + }) + + await client.initialize() + expect(client['detectSessionInUrl']).toBe(false) + }) }) describe('Session Management Edge Cases', () => { @@ -2538,6 +2835,22 @@ describe('Storage adapter edge cases', () => { expect(url).toContain('code_challenge=') expect(url).toContain('code_challenge_method=') }) + + test('should handle localStorage not supported', async () => { + // Mock supportsLocalStorage to return false + jest.doMock('../src/lib/helpers', () => ({ + ...jest.requireActual('../src/lib/helpers'), + supportsLocalStorage: () => false, + })) + + const client = new GoTrueClient({ + url: 'http://localhost:9999', + }) + + await client.initialize() + // Should fall back to memory storage + expect(client['storage']).toBeDefined() + }) }) describe('SSO Authentication', () => { diff --git a/test/ethereum.test.ts b/test/ethereum.test.ts new file mode 100644 index 0000000..13121e2 --- /dev/null +++ b/test/ethereum.test.ts @@ -0,0 +1,314 @@ +import { + getAddress, + fromHex, + toHex, + createSiweMessage, + type SiweMessage, + type Hex, +} from '../src/lib/web3/ethereum' + +describe('ethereum', () => { + describe('getAddress', () => { + test('should return lowercase address for valid Ethereum address', () => { + const validAddresses = [ + '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + '0x742d35cc6634c0532925a3b8d4c9db96c4b4d8b6', + '0x1234567890123456789012345678901234567890', + '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD', + ] + + validAddresses.forEach((address) => { + const result = getAddress(address) + expect(result).toBe(address.toLowerCase()) + expect(result).toMatch(/^0x[a-f0-9]{40}$/) + }) + }) + + test('should throw error for invalid address format', () => { + const invalidAddresses = [ + '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b', // too short + '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b67', // too long + '742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', // missing 0x + '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8bG', // invalid character + '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b!', // invalid character + '', // empty string + 'not-an-address', // random string + ] + + invalidAddresses.forEach((address) => { + expect(() => getAddress(address)).toThrow( + `@supabase/auth-js: Address "${address}" is invalid.` + ) + }) + }) + + test('should handle edge cases', () => { + // Valid address with all zeros + expect(getAddress('0x0000000000000000000000000000000000000000')).toBe( + '0x0000000000000000000000000000000000000000' + ) + + // Valid address with all f's + expect(getAddress('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF')).toBe( + '0xffffffffffffffffffffffffffffffffffffffff' + ) + }) + }) + + describe('fromHex', () => { + test('should convert hex to number', () => { + const testCases: Array<{ hex: Hex; expected: number }> = [ + { hex: '0x0', expected: 0 }, + { hex: '0x1', expected: 1 }, + { hex: '0xff', expected: 255 }, + { hex: '0x100', expected: 256 }, + { hex: '0xffff', expected: 65535 }, + { hex: '0x123456', expected: 1193046 }, + { hex: '0x7fffffff', expected: 2147483647 }, + ] + + testCases.forEach(({ hex, expected }) => { + expect(fromHex(hex)).toBe(expected) + }) + }) + + test('should handle uppercase and lowercase hex', () => { + expect(fromHex('0xFF')).toBe(255) + expect(fromHex('0xff')).toBe(255) + expect(fromHex('0xFf')).toBe(255) + }) + }) + + describe('toHex', () => { + test('should convert string to hex', () => { + const testCases: Array<{ input: string; expected: Hex }> = [ + { input: '', expected: '0x' }, + { input: 'a', expected: '0x61' }, + { input: 'hello', expected: '0x68656c6c6f' }, + { input: 'Hello World!', expected: '0x48656c6c6f20576f726c6421' }, + { input: '123', expected: '0x313233' }, + { input: 'привет', expected: '0xd0bfd180d0b8d0b2d0b5d182' }, + ] + + testCases.forEach(({ input, expected }) => { + expect(toHex(input)).toBe(expected) + }) + }) + + test('should handle special characters', () => { + expect(toHex('\n')).toBe('0x0a') + expect(toHex('\t')).toBe('0x09') + expect(toHex('\r')).toBe('0x0d') + expect(toHex(' ')).toBe('0x20') + expect(toHex('!@#$%^&*()')).toBe('0x21402324255e262a2829') + }) + }) + + describe('createSiweMessage', () => { + const baseMessage: SiweMessage = { + address: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + domain: 'example.com', + uri: 'https://example.com', + version: '1', + } + + test('should create basic SIWE message', () => { + const message = createSiweMessage(baseMessage) + + expect(message).toContain('example.com wants you to sign in with your Ethereum account:') + expect(message).toContain('0x742d35cc6634c0532925a3b8d4c9db96c4b4d8b6') + expect(message).toContain('URI: https://example.com') + expect(message).toContain('Version: 1') + expect(message).toContain('Chain ID: 1') + expect(message).toContain('Issued At:') + }) + + test('should include optional fields when provided', () => { + const messageWithOptions: SiweMessage = { + ...baseMessage, + statement: 'Please sign this message to authenticate', + nonce: '1234567890', + expirationTime: new Date('2024-12-31T23:59:59Z'), + notBefore: new Date('2024-01-01T00:00:00Z'), + requestId: 'req-123', + resources: ['https://example.com/resource1', 'https://example.com/resource2'], + scheme: 'https', + } + + const message = createSiweMessage(messageWithOptions) + + expect(message).toContain('Please sign this message to authenticate') + expect(message).toContain('Nonce: 1234567890') + expect(message).toContain('Expiration Time: 2024-12-31T23:59:59.000Z') + expect(message).toContain('Not Before: 2024-01-01T00:00:00.000Z') + expect(message).toContain('Request ID: req-123') + expect(message).toContain('Resources:') + expect(message).toContain('- https://example.com/resource1') + expect(message).toContain('- https://example.com/resource2') + expect(message).toContain('https://example.com wants you to sign in') + }) + + test('should handle scheme correctly', () => { + const messageWithScheme: SiweMessage = { + ...baseMessage, + scheme: 'https', + } + + const message = createSiweMessage(messageWithScheme) + expect(message).toContain('https://example.com wants you to sign in') + }) + + test('should validate chainId', () => { + const invalidChainId: SiweMessage = { + ...baseMessage, + chainId: 1.5, // non-integer + } + + expect(() => createSiweMessage(invalidChainId)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "chainId". Chain ID must be a EIP-155 chain ID. Provided value: 1.5' + ) + }) + + test('should validate domain', () => { + const invalidDomain: SiweMessage = { + ...baseMessage, + domain: '', // empty domain + } + + expect(() => createSiweMessage(invalidDomain)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "domain". Domain must be provided.' + ) + }) + + test('should validate nonce length', () => { + const shortNonce: SiweMessage = { + ...baseMessage, + nonce: '123', // too short + } + + expect(() => createSiweMessage(shortNonce)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "nonce". Nonce must be at least 8 characters. Provided value: 123' + ) + }) + + test('should validate uri', () => { + const invalidUri: SiweMessage = { + ...baseMessage, + uri: '', // empty uri + } + + expect(() => createSiweMessage(invalidUri)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "uri". URI must be provided.' + ) + }) + + test('should validate version', () => { + const invalidVersion: SiweMessage = { + ...baseMessage, + version: '2' as any, // invalid version + } + + expect(() => createSiweMessage(invalidVersion)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "version". Version must be \'1\'. Provided value: 2' + ) + }) + + test('should validate statement does not contain newlines', () => { + const invalidStatement: SiweMessage = { + ...baseMessage, + statement: 'Line 1\nLine 2', // contains newline + } + + expect(() => createSiweMessage(invalidStatement)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "statement". Statement must not include \'\\n\'. Provided value: Line 1\nLine 2' + ) + }) + + test('should validate resources array', () => { + const invalidResources: SiweMessage = { + ...baseMessage, + resources: ['valid-resource', '', 'another-valid'], // contains empty string + } + + expect(() => createSiweMessage(invalidResources)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "resources". Every resource must be a valid string. Provided value: ' + ) + }) + + test('should validate resources are strings', () => { + const invalidResources: SiweMessage = { + ...baseMessage, + resources: ['valid-resource', null as any, 'another-valid'], // contains null + } + + expect(() => createSiweMessage(invalidResources)).toThrow( + '@supabase/auth-js: Invalid SIWE message field "resources". Every resource must be a valid string. Provided value: null' + ) + }) + + test('should handle empty resources array', () => { + const messageWithEmptyResources: SiweMessage = { + ...baseMessage, + resources: [], + } + + const message = createSiweMessage(messageWithEmptyResources) + expect(message).toContain('Resources:') + }) + + test('should format message correctly with all optional fields', () => { + const fullMessage: SiweMessage = { + address: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 137, + domain: 'polygon.example.com', + uri: 'https://polygon.example.com/auth', + version: '1', + statement: 'Sign in to access your account', + nonce: 'abcdef1234567890', + expirationTime: new Date('2024-12-31T23:59:59Z'), + notBefore: new Date('2024-01-01T00:00:00Z'), + requestId: 'auth-request-12345', + resources: [ + 'https://polygon.example.com/api', + 'https://polygon.example.com/dashboard', + ], + scheme: 'https', + } + + const message = createSiweMessage(fullMessage) + + // Check the structure + const lines = message.split('\n') + expect(lines[0]).toBe('https://polygon.example.com wants you to sign in with your Ethereum account:') + expect(lines[1]).toBe('0x742d35cc6634c0532925a3b8d4c9db96c4b4d8b6') + expect(lines[2]).toBe('') + expect(lines[3]).toBe('Sign in to access your account') + expect(lines[4]).toBe('') + expect(lines[5]).toBe('URI: https://polygon.example.com/auth') + expect(lines[6]).toBe('Version: 1') + expect(lines[7]).toBe('Chain ID: 137') + expect(lines[8]).toBe('Nonce: abcdef1234567890') + expect(lines[9]).toMatch(/^Issued At: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + expect(lines[10]).toBe('Expiration Time: 2024-12-31T23:59:59.000Z') + expect(lines[11]).toBe('Not Before: 2024-01-01T00:00:00.000Z') + expect(lines[12]).toBe('Request ID: auth-request-12345') + expect(lines[13]).toBe('Resources:') + expect(lines[14]).toBe('- https://polygon.example.com/api') + expect(lines[15]).toBe('- https://polygon.example.com/dashboard') + }) + + test('should handle issuedAt default value', () => { + const beforeTest = new Date() + const message = createSiweMessage(baseMessage) + const afterTest = new Date() + + const issuedAtMatch = message.match(/Issued At: (.+)/) + expect(issuedAtMatch).toBeTruthy() + + const issuedAt = new Date(issuedAtMatch![1]) + expect(issuedAt.getTime()).toBeGreaterThanOrEqual(beforeTest.getTime()) + expect(issuedAt.getTime()).toBeLessThanOrEqual(afterTest.getTime()) + }) + }) +})