diff --git a/src/components/SiweValidator/__tests__/lineBreakValidator.test.ts b/src/components/SiweValidator/__tests__/lineBreakValidator.test.ts index d8b76a6..2c5c242 100644 --- a/src/components/SiweValidator/__tests__/lineBreakValidator.test.ts +++ b/src/components/SiweValidator/__tests__/lineBreakValidator.test.ts @@ -2,6 +2,8 @@ import { LineBreakValidator } from '../lineBreakValidator'; import { ValidationEngine } from '../validationEngine'; +import { SiweMessageParser } from '../parser'; +import { FieldReplacer } from '../fieldReplacer'; describe('LineBreakValidator', () => { const validMessage = `example.com wants you to sign in with your Ethereum account: @@ -19,6 +21,7 @@ Expiration Time: 2025-08-19T05:16:33.555Z`; describe('User Reported Scenario', () => { test('detects extra line break between last two lines', () => { // Add extra line break between Issued At and Expiration Time + // Note: Expiration Time is an optional field, so this should use EXTRA_LINE_BREAKS_BEFORE_OPTIONAL_FIELD const messageWithExtraLineBreak = `example.com wants you to sign in with your Ethereum account: 0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 @@ -34,14 +37,14 @@ Expiration Time: 2025-08-19T05:16:33.555Z`; const errors = LineBreakValidator.validateLineBreaks(messageWithExtraLineBreak); - // Should detect extra line break between fields + // Should detect extra line break before optional field (Expiration Time) const extraLineBreakError = errors.find(e => - e.code === 'EXTRA_LINE_BREAKS_BETWEEN_FIELDS' + e.code === 'EXTRA_LINE_BREAKS_BEFORE_OPTIONAL_FIELD' ); expect(extraLineBreakError).toBeDefined(); expect(extraLineBreakError?.fixable).toBe(true); - expect(extraLineBreakError?.message).toContain('Extra empty lines between required fields'); + expect(extraLineBreakError?.message).toContain('Extra empty lines before Expiration Time field'); }); test('full validation engine prioritizes line break errors over parsing errors', () => { @@ -362,6 +365,198 @@ Not Before: 2025-08-19T05:06:33.555Z`; }); }); + describe('No Statement Line Break Handling (EIP-4361)', () => { + test('valid message with no statement and 2 empty lines should pass', () => { + const validNoStatement = `example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + + +URI: https://example.com +Version: 1 +Chain ID: 1 +Nonce: abc123defg4567 +Issued At: 2025-08-19T05:06:33.555Z`; + + const errors = LineBreakValidator.validateLineBreaks(validNoStatement); + const lineBreakErrors = errors.filter(e => + e.code.includes('LINE_BREAK') || e.code.includes('EXTRA') + ); + expect(lineBreakErrors).toHaveLength(0); + }); + + test('invalid message with no statement and only 1 empty line should fail', () => { + const invalidNoStatement = `example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + +URI: https://example.com +Version: 1 +Chain ID: 1 +Nonce: abc123defg4567 +Issued At: 2025-08-19T05:06:33.555Z`; + + const errors = LineBreakValidator.validateLineBreaks(invalidNoStatement); + const missingLineError = errors.find(e => e.code === 'MISSING_LINE_BREAK_NO_STATEMENT'); + expect(missingLineError).toBeDefined(); + expect(missingLineError?.message).toContain('expected 2'); + }); + + test('message with no statement and 3 empty lines should report extra', () => { + const tooManyLines = `example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + + + +URI: https://example.com +Version: 1 +Chain ID: 1 +Nonce: abc123defg4567 +Issued At: 2025-08-19T05:06:33.555Z`; + + const errors = LineBreakValidator.validateLineBreaks(tooManyLines); + const extraLinesError = errors.find(e => e.code === 'EXTRA_LINE_BREAKS_BEFORE_URI'); + expect(extraLinesError).toBeDefined(); + expect(extraLinesError?.message).toContain('expected 2'); + }); + + test('fixLineBreaks adds 2 empty lines when no statement', () => { + // Message with only 1 empty line (incorrect) + const invalidNoStatement = `example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + +URI: https://example.com +Version: 1 +Chain ID: 1 +Nonce: abc123defg4567 +Issued At: 2025-08-19T05:06:33.555Z`; + + const fixed = LineBreakValidator.fixLineBreaks(invalidNoStatement); + + // Should have 2 empty lines between address and URI + expect(fixed).toContain('0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890\n\n\nURI:'); + + // Validate the fixed message has no line break errors + const errors = LineBreakValidator.validateLineBreaks(fixed); + const lineBreakErrors = errors.filter(e => + e.code.includes('LINE_BREAK') || e.code.includes('EXTRA') + ); + expect(lineBreakErrors).toHaveLength(0); + }); + + test('SiweMessageParser.generateMessage outputs 2 empty lines when no statement', () => { + const fields = { + domain: 'example.com', + address: '0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890', + uri: 'https://example.com', + version: '1', + chainId: '1', + nonce: 'abc123defg4567', + issuedAt: '2025-08-19T05:06:33.555Z' + // No statement + }; + + const generated = SiweMessageParser.generateMessage(fields); + + // Should have 2 empty lines between address and URI + expect(generated).toContain('0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890\n\n\nURI:'); + + // Validate the generated message has no line break errors + const errors = LineBreakValidator.validateLineBreaks(generated); + const lineBreakErrors = errors.filter(e => + e.code.includes('LINE_BREAK') || e.code.includes('EXTRA') + ); + expect(lineBreakErrors).toHaveLength(0); + }); + + test('SiweMessageParser.generateMessage outputs 1 empty line when statement exists', () => { + const fields = { + domain: 'example.com', + address: '0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890', + statement: 'Sign in to our Web3 application.', + uri: 'https://example.com', + version: '1', + chainId: '1', + nonce: 'abc123defg4567', + issuedAt: '2025-08-19T05:06:33.555Z' + }; + + const generated = SiweMessageParser.generateMessage(fields); + + // Should have 1 empty line before statement, then statement, then 1 empty line before URI + expect(generated).toContain('0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890\n\nSign in'); + expect(generated).toContain('application.\n\nURI:'); + + // Validate the generated message has no line break errors + const errors = LineBreakValidator.validateLineBreaks(generated); + const lineBreakErrors = errors.filter(e => + e.code.includes('LINE_BREAK') || e.code.includes('EXTRA') + ); + expect(lineBreakErrors).toHaveLength(0); + }); + + test('FieldReplacer.applyFieldFix handles MISSING_LINE_BREAK_NO_STATEMENT error', () => { + // Message with only 1 empty line (incorrect per EIP-4361) + const invalidNoStatement = `example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + +URI: https://example.com +Version: 1 +Chain ID: 1 +Nonce: abc123defg4567 +Issued At: 2025-08-19T05:06:33.555Z`; + + const error = { + type: 'format' as const, + field: 'structure', + line: 3, + column: 1, + message: 'Missing empty line between address and URI field', + severity: 'error' as const, + fixable: true, + code: 'MISSING_LINE_BREAK_NO_STATEMENT' + }; + + const fixed = FieldReplacer.applyFieldFix(invalidNoStatement, error); + + // Should have 2 empty lines between address and URI + expect(fixed).toContain('0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890\n\n\nURI:'); + + // Validate the fixed message has no line break errors + const errors = LineBreakValidator.validateLineBreaks(fixed); + const lineBreakErrors = errors.filter(e => + e.code.includes('LINE_BREAK') || e.code.includes('EXTRA') + ); + expect(lineBreakErrors).toHaveLength(0); + }); + + test('SiweMessageParser.parse correctly handles 2 empty lines when no statement', () => { + const validNoStatement = `example.com wants you to sign in with your Ethereum account: +0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + + +URI: https://example.com +Version: 1 +Chain ID: 1 +Nonce: abc123defg4567 +Issued At: 2025-08-19T05:06:33.555Z`; + + const parsed = SiweMessageParser.parse(validNoStatement); + + // Should parse all fields correctly + expect(parsed.fields.domain).toBe('example.com'); + expect(parsed.fields.address).toBe('0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890'); + expect(parsed.fields.statement).toBeUndefined(); + expect(parsed.fields.uri).toBe('https://example.com'); + expect(parsed.fields.version).toBe('1'); + expect(parsed.fields.chainId).toBe('1'); + expect(parsed.fields.nonce).toBe('abc123defg4567'); + expect(parsed.fields.issuedAt).toBe('2025-08-19T05:06:33.555Z'); + + // Should have no parse errors + expect(parsed.parseErrors).toHaveLength(0); + expect(parsed.isValid).toBe(true); + }); + }); + describe('Message Structure Analysis', () => { test('correctly identifies all field positions', () => { const structure = (LineBreakValidator as any).analyzeMessageStructure(validMessage.split('\n')); diff --git a/src/components/SiweValidator/fieldReplacer.ts b/src/components/SiweValidator/fieldReplacer.ts index fd27f9f..5d4667c 100644 --- a/src/components/SiweValidator/fieldReplacer.ts +++ b/src/components/SiweValidator/fieldReplacer.ts @@ -107,6 +107,7 @@ export class FieldReplacer { case 'EXTRA_LINE_BREAKS_BEFORE_OPTIONAL_FIELD': case 'MISSING_LINE_BREAK_ADDRESS_STATEMENT': case 'MISSING_LINE_BREAK_STATEMENT_URI': + case 'MISSING_LINE_BREAK_NO_STATEMENT': case 'TRAILING_WHITESPACE': case 'TOO_MANY_CONSECUTIVE_EMPTY_LINES': return LineBreakValidator.fixLineBreaks(originalMessage); diff --git a/src/components/SiweValidator/lineBreakValidator.ts b/src/components/SiweValidator/lineBreakValidator.ts index be6b8d1..dc8bd86 100644 --- a/src/components/SiweValidator/lineBreakValidator.ts +++ b/src/components/SiweValidator/lineBreakValidator.ts @@ -49,20 +49,22 @@ export class LineBreakValidator { } } - // Check for extra empty lines before URI (should be exactly one after statement/address) + // Check for extra empty lines before URI + // Per EIP-4361: expect 1 empty line if statement exists, 2 empty lines if no statement if (structure.uriIndex !== -1) { const expectedEmptyLineIndex = this.getExpectedEmptyLineBeforeUri(lines, structure); if (expectedEmptyLineIndex !== -1) { // Count consecutive empty lines before URI const emptyLinesBefore = this.countConsecutiveEmptyLinesBefore(lines, structure.uriIndex); - if (emptyLinesBefore > 1) { + const expectedEmptyLines = structure.statementIndex === -1 ? 2 : 1; + if (emptyLinesBefore > expectedEmptyLines) { const extraLinesStart = structure.uriIndex - emptyLinesBefore + 1; errors.push({ type: 'format', field: 'structure', line: extraLinesStart + 1, column: 1, - message: `Extra empty lines before URI field (found ${emptyLinesBefore}, expected 1)`, + message: `Extra empty lines before URI field (found ${emptyLinesBefore}, expected ${expectedEmptyLines})`, severity: 'error', fixable: true, suggestion: 'Remove extra empty lines before URI field', @@ -190,6 +192,24 @@ export class LineBreakValidator { } } + // Check if there should be 2 empty lines when no statement (per EIP-4361) + if (structure.statementIndex === -1 && structure.addressIndex !== -1 && structure.uriIndex !== -1) { + const emptyLinesBefore = this.countConsecutiveEmptyLinesBefore(lines, structure.uriIndex); + if (emptyLinesBefore < 2) { + errors.push({ + type: 'format', + field: 'structure', + line: structure.addressIndex + 2, + column: 1, + message: `Missing empty line between address and URI field (found ${emptyLinesBefore}, expected 2 when no statement)`, + severity: 'error', + fixable: true, + suggestion: 'Add a second empty line between the address and URI field when there is no statement', + code: 'MISSING_LINE_BREAK_NO_STATEMENT' + }); + } + } + return errors; } @@ -394,9 +414,12 @@ export class LineBreakValidator { fixedLines.push(line); i++; - // Add empty line after address if there's a statement + // Per EIP-4361: add 1 empty line if there's a statement, 2 empty lines if no statement if (structure.statementIndex !== -1) { fixedLines.push(''); + } else { + fixedLines.push(''); + fixedLines.push(''); } } // Statement line diff --git a/src/components/SiweValidator/parser.ts b/src/components/SiweValidator/parser.ts index 780a283..a6d5c84 100644 --- a/src/components/SiweValidator/parser.ts +++ b/src/components/SiweValidator/parser.ts @@ -68,20 +68,21 @@ export class SiweMessageParser { )); } - // Skip empty line + // Skip empty line after address if (lineIndex < lines.length && lines[lineIndex] === '') { lineIndex++; } - // Parse optional statement + // Parse optional statement (non-empty line that doesn't start with URI:) if (lineIndex < lines.length && lines[lineIndex] && !lines[lineIndex].startsWith('URI:')) { fields.statement = lines[lineIndex]; lineIndex++; - - // Skip empty line after statement - if (lineIndex < lines.length && lines[lineIndex] === '') { - lineIndex++; - } + } + + // Skip empty line(s) before required fields + // Per EIP-4361: 1 empty line after statement, or 2 empty lines total when no statement + while (lineIndex < lines.length && lines[lineIndex] === '') { + lineIndex++; } // Parse required fields @@ -197,13 +198,14 @@ export class SiweMessageParser { lines.push(fields.address); } - // Empty line - lines.push(''); - - // Statement (optional) + // Per EIP-4361: 1 empty line + statement + 1 empty line, or 2 empty lines if no statement if (fields.statement) { + lines.push(''); lines.push(fields.statement); lines.push(''); + } else { + lines.push(''); + lines.push(''); } // Required fields diff --git a/src/components/SiweValidator/validationEngine.ts b/src/components/SiweValidator/validationEngine.ts index 0c687c1..4be7230 100644 --- a/src/components/SiweValidator/validationEngine.ts +++ b/src/components/SiweValidator/validationEngine.ts @@ -272,9 +272,11 @@ export class ValidationEngine { // Generate sample messages for testing public static generateSamples(): { [key: string]: string } { return { + // Per EIP-4361: 2 empty lines when no statement minimal: `example.com wants you to sign in with your Ethereum account: 0x742d35Cc6C4C1Ca5d428d9eE0e9B1E1234567890 + URI: https://example.com Version: 1 Chain ID: 1 diff --git a/src/setupTests.ts b/src/setupTests.ts index ec7f674..44e5b99 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,25 +1,27 @@ // Jest setup file for testing -// Mock CSS modules -jest.mock('*.module.css', () => ({}), { virtual: true }); +// CSS modules are handled by moduleNameMapper in jest.config.js -// Mock Docusaurus specific modules -jest.mock('@docusaurus/useDocusaurusContext', () => ({ - __esModule: true, - default: () => ({ - siteConfig: { - title: 'SIWE Docs', - tagline: 'Sign in with Ethereum Documentation', - }, - }), -})); +// Mock Docusaurus specific modules (only if they exist) +try { + jest.mock('@docusaurus/useDocusaurusContext', () => ({ + __esModule: true, + default: () => ({ + siteConfig: { + title: 'SIWE Docs', + tagline: 'Sign in with Ethereum Documentation', + }, + }), + }), { virtual: true }); -// Mock other Docusaurus hooks if needed -jest.mock('@docusaurus/Link', () => ({ - __esModule: true, - default: ({ children, ...props }: any) => - require('react').createElement('a', props, children), -})); + jest.mock('@docusaurus/Link', () => ({ + __esModule: true, + default: ({ children, ...props }: any) => + require('react').createElement('a', props, children), + }), { virtual: true }); +} catch { + // Ignore if Docusaurus modules are not available +} // Setup global test environment global.console = {