validator = new DatabaseIdentifierValidator(); }); it('validates correct identifier format', function () { // Valid identifiers expect(fn() => $this->validator->validate('users', 'table'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('user_profiles', 'table'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('_temp_table', 'table'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('table123', 'table'))->not->toThrow(\InvalidArgumentException::class); }); it('throws on empty identifier', function () { expect(fn() => $this->validator->validate('', 'table')) ->toThrow(\InvalidArgumentException::class, 'Table name cannot be empty'); expect(fn() => $this->validator->validate('', 'column')) ->toThrow(\InvalidArgumentException::class, 'Column name cannot be empty'); }); it('throws on identifier exceeding maximum length', function () { $validLength = str_repeat('a', 64); expect(fn() => $this->validator->validate($validLength, 'table'))->not->toThrow(\InvalidArgumentException::class); $tooLong = str_repeat('a', 65); expect(fn() => $this->validator->validate($tooLong, 'table')) ->toThrow(\InvalidArgumentException::class, 'exceeds maximum length'); }); it('returns maximum length constant', function () { expect($this->validator->getMaxLength())->toBe(64); }); it('throws on identifier not starting with letter or underscore', function () { expect(fn() => $this->validator->validate('123invalid', 'table')) ->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore'); expect(fn() => $this->validator->validate('-invalid', 'table')) ->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore'); expect(fn() => $this->validator->validate('$invalid', 'table')) ->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore'); }); it('throws on invalid format with special characters', function () { expect(fn() => $this->validator->validate('invalid-name', 'table')) ->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores'); expect(fn() => $this->validator->validate('invalid name', 'table')) ->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores'); expect(fn() => $this->validator->validate('invalid.name', 'table')) ->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores'); expect(fn() => $this->validator->validate('invalid@name', 'table')) ->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores'); }); it('detects SQL injection patterns', function () { // Note: Most patterns are caught by format validation (invalid characters) // SQL injection protection works through: // 1. Format validation (alphanumeric + underscores only) // 2. Additional checks for metacharacters // SQL comments (also fails format validation due to -) expect(fn() => $this->validator->validate('users--comment', 'table')) ->toThrow(\InvalidArgumentException::class); // SQL comment block (also fails format validation due to /) expect(fn() => $this->validator->validate('users/*comment*/', 'table')) ->toThrow(\InvalidArgumentException::class); // Statement separator (also fails format validation) expect(fn() => $this->validator->validate('users;DROP', 'table')) ->toThrow(\InvalidArgumentException::class); // Quotes (fail format validation) expect(fn() => $this->validator->validate("users'test", 'table')) ->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('users"test', 'table')) ->toThrow(\InvalidArgumentException::class); // Backslash (fails format validation) expect(fn() => $this->validator->validate('users\\test', 'table')) ->toThrow(\InvalidArgumentException::class); }); it('detects reserved SQL keywords', function () { expect($this->validator->isReservedKeyword('SELECT'))->toBeTrue(); expect($this->validator->isReservedKeyword('select'))->toBeTrue(); // Case-insensitive expect($this->validator->isReservedKeyword('INSERT'))->toBeTrue(); expect($this->validator->isReservedKeyword('UPDATE'))->toBeTrue(); expect($this->validator->isReservedKeyword('DELETE'))->toBeTrue(); expect($this->validator->isReservedKeyword('DROP'))->toBeTrue(); expect($this->validator->isReservedKeyword('CREATE'))->toBeTrue(); expect($this->validator->isReservedKeyword('ALTER'))->toBeTrue(); expect($this->validator->isReservedKeyword('TABLE'))->toBeTrue(); expect($this->validator->isReservedKeyword('INDEX'))->toBeTrue(); expect($this->validator->isReservedKeyword('FROM'))->toBeTrue(); expect($this->validator->isReservedKeyword('WHERE'))->toBeTrue(); expect($this->validator->isReservedKeyword('UNION'))->toBeTrue(); // Non-reserved words expect($this->validator->isReservedKeyword('users'))->toBeFalse(); expect($this->validator->isReservedKeyword('email'))->toBeFalse(); expect($this->validator->isReservedKeyword('profile'))->toBeFalse(); }); it('allows special values via allowedValues parameter', function () { // PRIMARY is a reserved keyword but still a valid identifier format // allowedValues can be used to explicitly allow values that might be reserved expect(fn() => $this->validator->validate('PRIMARY', 'index', ['PRIMARY'])) ->not->toThrow(\InvalidArgumentException::class); // UNIQUE can be allowed expect(fn() => $this->validator->validate('UNIQUE', 'index', ['UNIQUE'])) ->not->toThrow(\InvalidArgumentException::class); // Note: PRIMARY and UNIQUE are valid identifier formats (they pass all validation rules) // Reserved keyword checking is separate - use isReservedKeyword() for that expect(fn() => $this->validator->validate('PRIMARY', 'index', [])) ->not->toThrow(\InvalidArgumentException::class); expect($this->validator->isReservedKeyword('PRIMARY'))->toBeTrue(); expect($this->validator->isReservedKeyword('UNIQUE'))->toBeTrue(); }); it('uses custom type name in error messages', function () { try { $this->validator->validate('', 'custom_type'); expect(false)->toBeTrue(); // Should not reach here } catch (InvalidArgumentException $e) { expect($e->getMessage())->toContain('Custom_type'); } try { $this->validator->validate('123invalid', 'my_entity'); expect(false)->toBeTrue(); } catch (InvalidArgumentException $e) { expect($e->getMessage())->toContain('My_entity'); } }); it('validates identifiers with underscores correctly', function () { // Leading underscore expect(fn() => $this->validator->validate('_temp', 'table'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('__double', 'table'))->not->toThrow(\InvalidArgumentException::class); // Multiple underscores expect(fn() => $this->validator->validate('user_email_verified', 'column'))->not->toThrow(\InvalidArgumentException::class); // Trailing underscore expect(fn() => $this->validator->validate('temp_', 'table'))->not->toThrow(\InvalidArgumentException::class); }); it('validates identifiers with numbers correctly', function () { // Numbers in middle expect(fn() => $this->validator->validate('table123test', 'table'))->not->toThrow(\InvalidArgumentException::class); // Numbers at end expect(fn() => $this->validator->validate('table123', 'table'))->not->toThrow(\InvalidArgumentException::class); // Only numbers after first character expect(fn() => $this->validator->validate('t123456789', 'table'))->not->toThrow(\InvalidArgumentException::class); // But not at start expect(fn() => $this->validator->validate('123table', 'table')) ->toThrow(\InvalidArgumentException::class); }); it('is case-insensitive for reserved keywords', function () { expect($this->validator->isReservedKeyword('select'))->toBeTrue(); expect($this->validator->isReservedKeyword('SELECT'))->toBeTrue(); expect($this->validator->isReservedKeyword('SeLeCt'))->toBeTrue(); expect($this->validator->isReservedKeyword('table'))->toBeTrue(); expect($this->validator->isReservedKeyword('TABLE'))->toBeTrue(); }); it('validates identifiers that contain SQL keywords as substrings', function () { // Valid identifiers can contain SQL keywords as substrings // This is fine because they're alphanumeric and will be quoted in SQL expect(fn() => $this->validator->validate('usersunion', 'table'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('updated_at', 'column'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('created_at', 'column'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('deleted_at', 'column'))->not->toThrow(\InvalidArgumentException::class); }); it('validates edge cases', function () { // Single character expect(fn() => $this->validator->validate('a', 'table'))->not->toThrow(\InvalidArgumentException::class); expect(fn() => $this->validator->validate('_', 'table'))->not->toThrow(\InvalidArgumentException::class); // Exactly 64 characters (max length) $maxLength = str_repeat('a', 64); expect(fn() => $this->validator->validate($maxLength, 'table'))->not->toThrow(\InvalidArgumentException::class); }); it('provides contextual error messages', function () { try { $this->validator->validate('', 'table'); } catch (InvalidArgumentException $e) { expect($e->getMessage())->toContain('Table'); expect($e->getMessage())->toContain('cannot be empty'); } try { $this->validator->validate('', 'column'); } catch (InvalidArgumentException $e) { expect($e->getMessage())->toContain('Column'); expect($e->getMessage())->toContain('cannot be empty'); } try { $this->validator->validate('', 'index'); } catch (InvalidArgumentException $e) { expect($e->getMessage())->toContain('Index'); expect($e->getMessage())->toContain('cannot be empty'); } }); });