Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ios_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,15 @@ jobs:
- name: Run iOS unit tests
run: bundle exec fastlane iOStests

- name: Upload iOS test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: ios-test-results
path: |
fastlane/test_output/
**/*.xcresult
build/reports/
retention-days: 30
if-no-files-found: warn

12 changes: 12 additions & 0 deletions .github/workflows/tvos_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ jobs:
- name: Run tvOS unit tests
run: bundle exec fastlane tvOStests

- name: Upload tvOS test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: tvos-test-results
path: |
fastlane/test_output/
**/*.xcresult
build/reports/
retention-days: 30
if-no-files-found: warn

# - name: Run tvOS snapshot tests
# run: bundle exec fastlane tvos_snapshot_tests

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,44 @@ import Foundation
Provides useful validation methods.
*/
public class Validator {

public enum EmailValidationError: Error {
case emailIsEmpty
case emailIsInvalid
}

/**
Validates an email address.

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

/**
Validates a gift code.

- Parameter giftCode: The gift code to validate.
- Returns: `true` if the code syntax is valid.
*/
public static func validate(giftCode: String, withDashes: Bool = false) -> Bool {
if withDashes {
return NSPredicate(format: "SELF MATCHES %@", "^(\\d{4}-){3}\\d{4}$").evaluate(with: giftCode)
} else {
return NSPredicate(format: "SELF MATCHES %@", "^\\d{16}$").evaluate(with: giftCode)
// Check for consecutive dots
guard !email.contains("..") else {
throw EmailValidationError.emailIsInvalid
}

// Check for multiple @ symbols
guard email.filter({ $0 == "@" }).count == 1 else {
throw EmailValidationError.emailIsInvalid
}

// Email regex explanation:
// Local part: ^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?
// - Must start with alphanumeric
// - Can contain letters, numbers, dots, underscores, plus, hyphen in the middle
// - Must end with alphanumeric (or be single character)
// - This prevents leading/trailing dots
// Domain part: @((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,}$
// - Standard domain validation
let emailRegex = "^[A-Za-z0-9]([A-Za-z0-9._+-]*[A-Za-z0-9])?@((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,}$"

guard NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email) else {
throw EmailValidationError.emailIsInvalid
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,4 @@ class AccountSignupTests: XCTestCase {
}
waitForExpectations(timeout: 10.0, handler: nil)
}

func testGiftCodeSyntax() {
XCTAssertTrue(Validator.validate(giftCode: "1234123412341234"))
XCTAssertFalse(Validator.validate(giftCode: "1234123412341234", withDashes: true))
XCTAssertFalse(Validator.validate(giftCode: "1234-1234-1234-1234"))
XCTAssertTrue(Validator.validate(giftCode: "1234-1234-1234-1234", withDashes: true))
}
}
99 changes: 88 additions & 11 deletions LocalPackages/PIALibrary/Tests/PIALibraryTests/ValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,96 @@
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
//

import XCTest
import Testing
@testable import PIALibrary

class ValidatorTests: XCTestCase {

func testValidatorGiftCardIsValid() {
let giftCode = "1234567812345678"
XCTAssertTrue(Validator.validate(giftCode: giftCode))
@Suite("Email Validation Tests")
struct ValidatorTests {

// MARK: - Valid Email Tests

@Test("Valid email addresses should pass validation")
func validEmails() throws {
let validEmails = [
"user@example.com",
"test.user@example.com",
"test+tag@example.co.uk",
"user123@test-domain.com",
"first.last@subdomain.example.org",
"a@example.com",
"test_user@example.com"
]

for email in validEmails {
try Validator.validate(email: email)
}
}

func testValidatorGiftCardIsInvalid() {
let giftCode = "12345678678"
XCTAssertFalse(Validator.validate(giftCode: giftCode))

// MARK: - Empty Email Tests

@Test("Nil email should throw emailIsEmpty error")
func nilEmail() {
#expect(throws: Validator.EmailValidationError.emailIsEmpty) {
try Validator.validate(email: nil)
}
}

@Test("Empty string should throw emailIsEmpty error")
func emptyEmail() {
#expect(throws: Validator.EmailValidationError.emailIsEmpty) {
try Validator.validate(email: "")
}
}

@Test("Whitespace-only email should throw emailIsInvalid error")
func whitespaceOnlyEmail() {
#expect(throws: Validator.EmailValidationError.emailIsInvalid) {
try Validator.validate(email: " ")
}
}

// MARK: - Invalid Email Tests

@Test("Invalid email formats should throw emailIsInvalid error", arguments: [
"plaintext",
"@example.com",
"user@",
"user @example.com",
"user@example .com",
"user@.com",
"user@example.",
"user..name@example.com",
"user@example..com",
"user@-example.com",
"user@example-.com",
"user@example",
"user name@example.com",
"user@exam ple.com",
".user@example.com",
"user.@example.com",
"user@@example.com",
"user@example@com",
])
func invalidEmails(email: String) {
#expect(throws: Validator.EmailValidationError.emailIsInvalid) {
try Validator.validate(email: email)
}
}

// MARK: - Edge Cases

@Test("Email with leading/trailing spaces should be handled by caller")
func emailWithSpaces() {
// Note: The validator expects trimmed input
// The UI layer should trim before validation
#expect(throws: Validator.EmailValidationError.emailIsInvalid) {
try Validator.validate(email: " user@example.com ")
}
}

@Test("Very long valid email should pass")
func longValidEmail() throws {
let longEmail = "very.long.email.address.with.many.parts@very.long.domain.name.example.com"
try Validator.validate(email: longEmail)
}

}
16 changes: 12 additions & 4 deletions PIA VPN-tvOS/SignupEmail/Presentation/SignupEmailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ class SignupEmailViewModel: ObservableObject {

func signup(email: String) {
let cleanedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard Validator.validate(email: cleanedEmail) else {
handleError(error: nil)

do {
try Validator.validate(email: cleanedEmail)
} catch {
handleError(error: error)
return
}

Expand All @@ -47,9 +50,14 @@ class SignupEmailViewModel: ObservableObject {
}
}
}

private func handleError(error: Validator.EmailValidationError) {
errorMessage = error.errorMessage
shouldShowErrorMessage = true
}

private func handleError(error: Error?) {
errorMessage = error != nil ? L10n.Localizable.Tvos.Signup.Email.Error.Message.generic : L10n.Welcome.Purchase.Error.validation
private func handleError(error: Error) {
errorMessage = L10n.Localizable.Tvos.Signup.Email.Error.Message.generic
shouldShowErrorMessage = true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ final class SignupEmailIntegrationTests: XCTestCase {
XCTAssertEqual(capturedLoadingState, [])

XCTAssertTrue(sut.shouldShowErrorMessage)
XCTAssertEqual(sut.errorMessage, L10n.Welcome.Purchase.Error.validation)
XCTAssertEqual(sut.errorMessage, Validator.EmailValidationError.emailIsInvalid.errorMessage)
}

func test_signup_shows_a_generic_error_when_there_is_no_error_and_no_userAccount() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ final class SignupEmailViewModelTests: XCTestCase {
wait(for: [expectation], timeout: 1.0)
XCTAssertNil(capturedUserAccount)
XCTAssertEqual(capturedLoadingState, [])
XCTAssertEqual(sut.errorMessage, L10n.Welcome.Purchase.Error.validation)
XCTAssertEqual(sut.errorMessage, Validator.EmailValidationError.emailIsInvalid.errorMessage)
XCTAssertTrue(sut.shouldShowErrorMessage)
}

Expand Down
1 change: 1 addition & 0 deletions PIA VPN.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@
3F0EF5CC2EDF25880013E4FB /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
"Core/Extensions/EmailValidationError+ErrorMessage.swift",
"Core/Extensions/Server+Automatic.swift",
"Core/Extensions/ServerProvider+UI.swift",
Core/Models/AboutComponent.swift,
Expand Down
32 changes: 32 additions & 0 deletions PIA VPN/Core/Extensions/EmailValidationError+ErrorMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// EmailValidationError+ErrorMessage.swift
// PIA VPN
//
// Created by Diego Trevisan on 16.12.25.
// Copyright © 2025 Private Internet Access, Inc.
//
// This file is part of the Private Internet Access iOS Client.
//
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any later version.
//
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with the Private
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
//

import PIALibrary

extension Validator.EmailValidationError {
var errorMessage: String {
switch self {
case .emailIsEmpty: L10n.Localizable.Email.Validation.empty
case .emailIsInvalid: L10n.Localizable.Email.Validation.invalid
}
}
}
8 changes: 8 additions & 0 deletions PIA VPN/Resources/L10n/SwiftGen+Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,14 @@ internal enum L10n {
}
}
}
internal enum Email {
internal enum Validation {
/// Email can't be empty.
internal static let empty = L10n.tr("Localizable", "email.validation.empty", fallback: "Email can't be empty.")
/// Invalid email. Please try again.
internal static let invalid = L10n.tr("Localizable", "email.validation.invalid", fallback: "Invalid email. Please try again.")
}
}
internal enum ErrorAlert {
internal enum ConnectionError {
internal enum NoNetwork {
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "كلمة المرور";
"set.email.error.validation" = "يجب إدخال عنوان البريد الإلكتروني.";

// EMAIL VALIDATOR
"email.validation.invalid" = "البريد الإلكتروني غير صحيح. يرجى إعادة المحاولة.";
"email.validation.empty" = "لا يمكن أن يكون البريد الإلكتروني فارغًا.";

// RATING
"rating.enjoy.question" = "هل تستمتع بـ PIA VPN؟";
"rating.enjoy.subtitle" = "نأمل أن يلبي منتجنا لشبكات VPN توقعاتك";
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/da.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "Adgangskode";
"set.email.error.validation" = "Du skal angive en e-mailadresse.";

// EMAIL VALIDATOR
"email.validation.invalid" = "Ugyldig e-mail. Prøv igen.";
"email.validation.empty" = "E-mail må ikke være tom.";

// RATING
"rating.enjoy.question" = "Har du glæde af PIA VPN?";
"rating.enjoy.subtitle" = "Vi håber, at vores VPN lever op til dine forventninger.";
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "Passwort";
"set.email.error.validation" = "Sie müssen eine E-Mail-Adresse eingeben.";

// EMAIL VALIDATOR
"email.validation.invalid" = "Ungültige E-Mail. Bitte erneut versuchen.";
"email.validation.empty" = "E-Mail darf nicht leer sein.";

// RATING
"rating.enjoy.question" = "Gefällt Ihnen PIA VPN?";
"rating.enjoy.subtitle" = "Wir hoffen, dass unsere VPN-Lösung Ihre Erwartungen erfüllt.";
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "Password";
"set.email.error.validation" = "You must enter an email address.";

// EMAIL VALIDATOR
"email.validation.invalid" = "Invalid email. Please try again.";
"email.validation.empty" = "Email can't be empty.";

// RATING
"rating.enjoy.question" = "Are you enjoying PIA VPN?";
"rating.enjoy.subtitle" = "We hope our VPN product is meeting your expectations";
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/es-MX.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "Contraseña";
"set.email.error.validation" = "Debes introducir una dirección de correo electrónico.";

// EMAIL VALIDATOR
"email.validation.invalid" = "Correo electrónico inválido. Por favor, intente de nuevo.";
"email.validation.empty" = "El correo electrónico no puede estar vacío.";

// RATING
"rating.enjoy.question" = "¿Te gusta PIA VPN?";
"rating.enjoy.subtitle" = "Esperamos que nuestro producto de VPN cumpla tus expectativas.";
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "Mot de passe";
"set.email.error.validation" = "Vous devez saisir une adresse e-mail";

// EMAIL VALIDATOR
"email.validation.invalid" = "E-mail invalide. Veuillez réessayer.";
"email.validation.empty" = "L'e-mail ne peut pas être vide.";

// RATING
"rating.enjoy.question" = "Vous appréciez PIA VPN ?";
"rating.enjoy.subtitle" = "Nous espérons que notre produit VPN répond à vos attentes";
Expand Down
4 changes: 4 additions & 0 deletions PIA VPN/Resources/L10n/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@
"set.email.password.caption" = "Password";
"set.email.error.validation" = "Devi inserire un indirizzo email.";

// EMAIL VALIDATOR
"email.validation.invalid" = "Indirizzo email non valido. Riprova.";
"email.validation.empty" = "L'email non può essere vuota.";

// RATING
"rating.enjoy.question" = "Ti piace PIA VPN?";
"rating.enjoy.subtitle" = "Speriamo che il nostro prodotto VPN soddisfi le tue aspettative";
Expand Down
Loading