Skip to content

Commit a997070

Browse files
KM-13371 Improve email validation logic and add check for empty strings
1 parent f104849 commit a997070

27 files changed

Lines changed: 292 additions & 74 deletions

LocalPackages/PIALibrary/Sources/PIALibrary/UI/Shared/Validator.swift

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,44 @@ import Foundation
2626
Provides useful validation methods.
2727
*/
2828
public class Validator {
29-
29+
public enum EmailValidationError: Error {
30+
case emailIsEmpty
31+
case emailIsInvalid
32+
}
33+
3034
/**
3135
Validates an email address.
3236

3337
- Parameter email: The email address to validate.
34-
- Returns: `true` if the address syntax is valid.
38+
- Throws: A `EmailValidationError` if validation fails.
3539
*/
36-
public static func validate(email: String) -> Bool {
37-
return NSPredicate(format: "SELF MATCHES %@", "^[^\\s]+@((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,}$").evaluate(with: email)
38-
}
40+
public static func validate(email: String?) throws(EmailValidationError) {
41+
guard let email, !email.isEmpty else {
42+
throw EmailValidationError.emailIsEmpty
43+
}
3944

40-
/**
41-
Validates a gift code.
42-
43-
- Parameter giftCode: The gift code to validate.
44-
- Returns: `true` if the code syntax is valid.
45-
*/
46-
public static func validate(giftCode: String, withDashes: Bool = false) -> Bool {
47-
if withDashes {
48-
return NSPredicate(format: "SELF MATCHES %@", "^(\\d{4}-){3}\\d{4}$").evaluate(with: giftCode)
49-
} else {
50-
return NSPredicate(format: "SELF MATCHES %@", "^\\d{16}$").evaluate(with: giftCode)
45+
// Check for consecutive dots
46+
guard !email.contains("..") else {
47+
throw EmailValidationError.emailIsInvalid
48+
}
49+
50+
// Check for multiple @ symbols
51+
guard email.filter({ $0 == "@" }).count == 1 else {
52+
throw EmailValidationError.emailIsInvalid
53+
}
54+
55+
// Email regex explanation:
56+
// Local part: ^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?
57+
// - Must start with alphanumeric
58+
// - Can contain letters, numbers, dots, underscores, plus, hyphen in the middle
59+
// - Must end with alphanumeric (or be single character)
60+
// - This prevents leading/trailing dots
61+
// Domain part: @((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,}$
62+
// - Standard domain validation
63+
let emailRegex = "^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?@((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,}$"
64+
65+
guard NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email) else {
66+
throw EmailValidationError.emailIsInvalid
5167
}
5268
}
5369
}

LocalPackages/PIALibrary/Tests/PIALibraryTests/AccountSignupTests.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,4 @@ class AccountSignupTests: XCTestCase {
7171
}
7272
waitForExpectations(timeout: 10.0, handler: nil)
7373
}
74-
75-
func testGiftCodeSyntax() {
76-
XCTAssertTrue(Validator.validate(giftCode: "1234123412341234"))
77-
XCTAssertFalse(Validator.validate(giftCode: "1234123412341234", withDashes: true))
78-
XCTAssertFalse(Validator.validate(giftCode: "1234-1234-1234-1234"))
79-
XCTAssertTrue(Validator.validate(giftCode: "1234-1234-1234-1234", withDashes: true))
80-
}
8174
}

LocalPackages/PIALibrary/Tests/PIALibraryTests/ValidatorTests.swift

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,96 @@
2020
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
2121
//
2222

23-
import XCTest
23+
import Testing
2424
@testable import PIALibrary
2525

26-
class ValidatorTests: XCTestCase {
27-
28-
func testValidatorGiftCardIsValid() {
29-
let giftCode = "1234567812345678"
30-
XCTAssertTrue(Validator.validate(giftCode: giftCode))
26+
@Suite("Email Validation Tests")
27+
struct ValidatorTests {
28+
29+
// MARK: - Valid Email Tests
30+
31+
@Test("Valid email addresses should pass validation")
32+
func validEmails() throws {
33+
let validEmails = [
34+
"user@example.com",
35+
"test.user@example.com",
36+
"test+tag@example.co.uk",
37+
"user123@test-domain.com",
38+
"first.last@subdomain.example.org",
39+
"a@example.com",
40+
"test_user@example.com"
41+
]
42+
43+
for email in validEmails {
44+
try Validator.validate(email: email)
45+
}
3146
}
32-
33-
func testValidatorGiftCardIsInvalid() {
34-
let giftCode = "12345678678"
35-
XCTAssertFalse(Validator.validate(giftCode: giftCode))
47+
48+
// MARK: - Empty Email Tests
49+
50+
@Test("Nil email should throw emailIsEmpty error")
51+
func nilEmail() {
52+
#expect(throws: Validator.EmailValidationError.emailIsEmpty) {
53+
try Validator.validate(email: nil)
54+
}
55+
}
56+
57+
@Test("Empty string should throw emailIsEmpty error")
58+
func emptyEmail() {
59+
#expect(throws: Validator.EmailValidationError.emailIsEmpty) {
60+
try Validator.validate(email: "")
61+
}
62+
}
63+
64+
@Test("Whitespace-only email should throw emailIsInvalid error")
65+
func whitespaceOnlyEmail() {
66+
#expect(throws: Validator.EmailValidationError.emailIsInvalid) {
67+
try Validator.validate(email: " ")
68+
}
69+
}
70+
71+
// MARK: - Invalid Email Tests
72+
73+
@Test("Invalid email formats should throw emailIsInvalid error", arguments: [
74+
"plaintext",
75+
"@example.com",
76+
"user@",
77+
"user @example.com",
78+
"user@example .com",
79+
"user@.com",
80+
"user@example.",
81+
"user..name@example.com",
82+
"user@example..com",
83+
"user@-example.com",
84+
"user@example-.com",
85+
"user@example",
86+
"user name@example.com",
87+
"user@exam ple.com",
88+
".user@example.com",
89+
"user.@example.com",
90+
"user@@example.com",
91+
"user@example@com",
92+
])
93+
func invalidEmails(email: String) {
94+
#expect(throws: Validator.EmailValidationError.emailIsInvalid) {
95+
try Validator.validate(email: email)
96+
}
97+
}
98+
99+
// MARK: - Edge Cases
100+
101+
@Test("Email with leading/trailing spaces should be handled by caller")
102+
func emailWithSpaces() {
103+
// Note: The validator expects trimmed input
104+
// The UI layer should trim before validation
105+
#expect(throws: Validator.EmailValidationError.emailIsInvalid) {
106+
try Validator.validate(email: " user@example.com ")
107+
}
108+
}
109+
110+
@Test("Very long valid email should pass")
111+
func longValidEmail() throws {
112+
let longEmail = "very.long.email.address.with.many.parts@very.long.domain.name.example.com"
113+
try Validator.validate(email: longEmail)
36114
}
37-
38115
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// EmailValidationError+ErrorMessage.swift
3+
// PIA VPN
4+
//
5+
// Created by Diego Trevisan on 16.12.25.
6+
// Copyright © 2025 Private Internet Access, Inc.
7+
//
8+
// This file is part of the Private Internet Access iOS Client.
9+
//
10+
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
11+
// modify it under the terms of the GNU General Public License as published by the Free
12+
// Software Foundation, either version 3 of the License, or (at your option) any later version.
13+
//
14+
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
15+
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
16+
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17+
// details.
18+
//
19+
// You should have received a copy of the GNU General Public License along with the Private
20+
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
21+
//
22+
23+
import PIALibrary
24+
25+
extension Validator.EmailValidationError {
26+
var errorMessage: String {
27+
switch self {
28+
case .emailIsEmpty: L10n.Localizable.Email.Validation.empty
29+
case .emailIsInvalid: L10n.Localizable.Email.Validation.invalid
30+
}
31+
}
32+
}

PIA VPN/Resources/L10n/SwiftGen+Strings.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,14 @@ internal enum L10n {
360360
}
361361
}
362362
}
363+
internal enum Email {
364+
internal enum Validation {
365+
/// Email can't be empty.
366+
internal static let empty = L10n.tr("Localizable", "email.validation.empty", fallback: "Email can't be empty.")
367+
/// Invalid email. Please try again.
368+
internal static let invalid = L10n.tr("Localizable", "email.validation.invalid", fallback: "Invalid email. Please try again.")
369+
}
370+
}
363371
internal enum ErrorAlert {
364372
internal enum ConnectionError {
365373
internal enum NoNetwork {

PIA VPN/Resources/L10n/ar.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@
392392
"set.email.password.caption" = "كلمة المرور";
393393
"set.email.error.validation" = "يجب إدخال عنوان البريد الإلكتروني.";
394394

395+
// EMAIL VALIDATOR
396+
"email.validation.invalid" = "البريد الإلكتروني غير صحيح. يرجى إعادة المحاولة.";
397+
"email.validation.empty" = "لا يمكن أن يكون البريد الإلكتروني فارغًا.";
398+
395399
// RATING
396400
"rating.enjoy.question" = "هل تستمتع بـ PIA VPN؟";
397401
"rating.enjoy.subtitle" = "نأمل أن يلبي منتجنا لشبكات VPN توقعاتك";

PIA VPN/Resources/L10n/da.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@
392392
"set.email.password.caption" = "Adgangskode";
393393
"set.email.error.validation" = "Du skal angive en e-mailadresse.";
394394

395+
// EMAIL VALIDATOR
396+
"email.validation.invalid" = "Ugyldig e-mail. Prøv igen.";
397+
"email.validation.empty" = "E-mail må ikke være tom.";
398+
395399
// RATING
396400
"rating.enjoy.question" = "Har du glæde af PIA VPN?";
397401
"rating.enjoy.subtitle" = "Vi håber, at vores VPN lever op til dine forventninger.";

PIA VPN/Resources/L10n/de.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@
392392
"set.email.password.caption" = "Passwort";
393393
"set.email.error.validation" = "Sie müssen eine E-Mail-Adresse eingeben.";
394394

395+
// EMAIL VALIDATOR
396+
"email.validation.invalid" = "Ungültige E-Mail. Bitte erneut versuchen.";
397+
"email.validation.empty" = "E-Mail darf nicht leer sein.";
398+
395399
// RATING
396400
"rating.enjoy.question" = "Gefällt Ihnen PIA VPN?";
397401
"rating.enjoy.subtitle" = "Wir hoffen, dass unsere VPN-Lösung Ihre Erwartungen erfüllt.";

PIA VPN/Resources/L10n/en.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@
392392
"set.email.password.caption" = "Password";
393393
"set.email.error.validation" = "You must enter an email address.";
394394

395+
// EMAIL VALIDATOR
396+
"email.validation.invalid" = "Invalid email. Please try again.";
397+
"email.validation.empty" = "Email can't be empty.";
398+
395399
// RATING
396400
"rating.enjoy.question" = "Are you enjoying PIA VPN?";
397401
"rating.enjoy.subtitle" = "We hope our VPN product is meeting your expectations";

PIA VPN/Resources/L10n/es-MX.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@
392392
"set.email.password.caption" = "Contraseña";
393393
"set.email.error.validation" = "Debes introducir una dirección de correo electrónico.";
394394

395+
// EMAIL VALIDATOR
396+
"email.validation.invalid" = "Correo electrónico inválido. Por favor, intente de nuevo.";
397+
"email.validation.empty" = "El correo electrónico no puede estar vacío.";
398+
395399
// RATING
396400
"rating.enjoy.question" = "¿Te gusta PIA VPN?";
397401
"rating.enjoy.subtitle" = "Esperamos que nuestro producto de VPN cumpla tus expectativas.";

0 commit comments

Comments
 (0)