Tester le renouvellement concurrent des jetons iOS sur un Mac cloud

Tester le renouvellement concurrent des jetons iOS sur un Mac cloud

Tester le renouvellement concurrent des jetons iOS sur un Mac cloud

La liste, l’avatar et le compteur de messages sollicitent l’API en même temps. Lorsque l’ancien jeton n’est plus valide, les trois requêtes échouent à l’authentification. Si chacune lance son propre renouvellement, la rotation des informations de renouvellement côté serveur peut entraîner des écrasements mutuels. Et si l’utilisateur se déconnecte entre-temps, une réponse tardive risque de rétablir son état de connexion. Un Mac cloud permet de transformer cette condition de concurrence en un test reproductible : sans appeler l’API de production ni compter sur un ralentissement fortuit du réseau, on vérifie uniquement le comportement du coordinateur d’authentification.

Définir d’abord trois critères d’acceptation

Premièrement, les renouvellements déclenchés simultanément dans une même session ne doivent donner lieu qu’à une seule opération. Deuxièmement, après la déconnexion, le résultat d’un ancien renouvellement ne doit plus modifier les informations d’authentification courantes. Troisièmement, une requête métier ne peut entraîner qu’un seul renouvellement suivi d’une seule nouvelle tentative. C’est le protocole de l’API qui détermine si un échec d’authentification autorise un renouvellement : toutes les erreurs de permission ne signifient pas que le jeton a expiré.

Le coordinateur reçoit le jeton effectivement utilisé par la requête concernée. Si le jeton courant est déjà différent, une autre requête a terminé le renouvellement : il suffit de renvoyer la nouvelle valeur, sans renouveler à nouveau. Sans cette vérification, une réponse d’échec tardive déclencherait un deuxième renouvellement inutile.

Un actor garantit uniquement l’accès isolé à son état, pas l’exécution sans entrelacement d’une méthode asynchrone entière. Lorsqu’elle atteint un await, la déconnexion et d’autres requêtes peuvent toujours entrer dans l’actor.

Dans cet exemple, le jeton est représenté par une simple chaîne et le transport chargé du renouvellement est injecté sous forme de closure. En production, il faut aussi gérer l’expiration, la rotation des informations de renouvellement et leur stockage sécurisé. Ne consignez jamais les informations d’authentification complètes dans les rapports de test.

Mutualiser le renouvellement avec une Task partagée

Ajoutez le coordinateur suivant à la cible de tests du projet. Il ne conserve qu’un état en mémoire et n’envoie pas les requêtes métier. La closure de renouvellement doit lever une erreur lorsque le serveur renvoie un échec explicite ou que le délai d’attente est dépassé ; elle ne doit pas attendre indéfiniment.

import Foundation

actor RefreshGate {
    enum Failure: Error { case signedOut }

    private var token: String?
    private var generation = 0
    private var flight: (id: UUID, task: Task<String, Error>)?
    private let refresh: @Sendable () async throws -> String

    init(token: String, refresh: @escaping @Sendable () async throws -> String) {
        self.token = token
        self.refresh = refresh
    }

    func recover(rejected: String) async throws -> String {
        guard let current = token else { throw Failure.signedOut }
        if current != rejected {
            try Task.checkCancellation()
            return current
        }

        let epoch = generation
        let active: (id: UUID, task: Task<String, Error>)
        if let existing = flight {
            active = existing
        } else {
            let operation = refresh
            active = (UUID(), Task { try await operation() })
            flight = active
        }

        do {
            let updated = try await active.task.value
            guard generation == epoch else { throw Failure.signedOut }
            if flight?.id == active.id {
                token = updated
                flight = nil
            }
            try Task.checkCancellation()
            return updated
        } catch {
            if flight?.id == active.id {
                flight = nil
            }
            throw error
        }
    }

    func logout() {
        generation += 1
        token = nil
        flight?.task.cancel()
        flight = nil
    }
}

Il faut enregistrer la Task avant d’attendre son résultat pour que les autres appels puissent la réutiliser. Lors du nettoyage, la comparaison des identifiants empêche un ancien appel qui reprend son exécution de supprimer une nouvelle tâche par erreur. Cette Task non structurée n’est pas automatiquement annulée lorsqu’un des appelants en attente est annulé. Ce comportement partagé est volontaire : il ne faut pas ajouter de gestionnaire d’annulation pour l’interrompre.

En cas de succès, le coordinateur met d’abord à jour l’état partagé, puis vérifie l’annulation de l’appelant. Ainsi, une page déjà fermée ne continue pas à exploiter le résultat, tandis que les requêtes encore en attente peuvent récupérer le nouveau jeton. Comme cette vérification n’a lieu qu’une fois l’attente terminée, cette interface ne revient pas immédiatement en cas d’annulation.

Exécuter des tests de concurrence locaux sur un Mac cloud

Remplacez l’API de renouvellement par un compteur et utilisez uniquement des valeurs factices fixes pour les informations d’authentification. Placez la méthode suivante dans une sous-classe de XCTestCase ; il suffit que le coordinateur et le test appartiennent à la même cible de tests.

import XCTest

actor RefreshProbe {
    private(set) var calls = 0

    func run() async throws -> String {
        calls += 1
        try await Task.sleep(nanoseconds: 100_000_000)
        return "fresh-token"
    }
}

func testRefreshIsShared() async throws {
    let probe = RefreshProbe()
    let gate = RefreshGate(token: "old-token") {
        try await probe.run()
    }

    let values = try await withThrowingTaskGroup(of: String.self) { group in
        for _ in 0..<20 {
            group.addTask {
                try await gate.recover(rejected: "old-token")
            }
        }
        var output: [String] = []
        for try await value in group {
            output.append(value)
        }
        return output
    }

    let calls = await probe.calls
    XCTAssertEqual(calls, 1)
    XCTAssertEqual(values.count, 20)
    XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}

La courte pause augmente les chances de chevauchement ; ce n’est pas une mesure de performance. Ajoutez également un cas où un échec associé à l’ancien jeton arrive après la fin du renouvellement, puis vérifiez que le compteur d’appels reste à un. Ce test permet de repérer les implémentations qui mutualisent les appelants en attente, mais oublient les réponses tardives.

Pour tester la concurrence avec la déconnexion, ne vous appuyez pas sur une durée de sommeil fixe. Utilisez une continuation contrôlée : la fausse API signale au test que le renouvellement a commencé, le test appelle ensuite logout, puis autorise le retour du résultat. Même si la fausse API ignore l’annulation et renvoie un succès, les anciens appelants en attente doivent recevoir une erreur. Tout nouvel appel après la déconnexion doit, lui, échouer immédiatement.

Traiter séparément les échecs, l’annulation et les nouvelles tentatives

Ne pas transformer un échec partagé en avalanche de nouvelles tentatives

Lorsque le renouvellement lève une erreur, tous les appelants déjà en attente reçoivent ce même échec, puis le coordinateur supprime la tâche. La décision d’autoriser une nouvelle tentative relève de la politique de la couche supérieure : un problème réseau temporaire peut justifier un délai de backoff, tandis que des informations d’authentification explicitement invalides doivent entraîner la fermeture de la session. Chaque appelant en attente ne doit pas rappeler immédiatement recover dans son bloc catch.

Avant de relancer une requête métier, vérifiez aussi que son corps peut être envoyé de nouveau. Les flux de fichiers, les sources de données à usage unique et les opérations qui effectuent des écritures nécessitent soit de reconstruire la requête, soit de s’appuyer sur un protocole d’idempotence côté serveur. Un renouvellement réussi du jeton ne rend pas toutes les requêtes rejouables sans risque.

La déconnexion constitue une frontière d’état

L’annulation d’une tâche est coopérative : la couche de transport peut déjà avoir terminé son travail ou ne pas avoir réagi à temps. La déconnexion doit donc à la fois effacer les informations d’authentification et incrémenter la génération. En production, le stockage persistant doit être protégé par la même vérification. Si la closure de transport enregistre elle-même les nouvelles informations d’authentification, elle contourne la protection du coordinateur contre les mises à jour après déconnexion.

Intégrer les courses aux tests de non-régression

Avant de soumettre les modifications, couvrez au minimum les cas suivants :

Consignez uniquement le nom du test, le nombre de renouvellements et l’état final, jamais le contenu des jetons. En exécutant régulièrement cette série de tests sur un Mac cloud, l’objectif n’est pas de prouver qu’une course ne se produira jamais, mais de vérifier que, lorsqu’elle se produit, le résultat respecte toujours les frontières de la session.

Questions fréquentes

Fermer un écran doit-il annuler le renouvellement partagé ?

Non. D’autres requêtes peuvent attendre la même tâche. L’écran annule son traitement ; la déconnexion annule la tâche partagée et change la génération de session.

Faut-il renouveler encore après un second refus d’authentification ?

Non. Une requête métier ne peut renouveler le jeton et être relancée qu’une fois. Le second refus remonte comme erreur ; le protocole serveur détermine si la session doit être fermée.

Louez un Mac dédié dans le cloud pour vos projets de développement

Des nœuds physiques Apple Silicon, pas des machines virtuelles. Louez à la journée, à la semaine, au mois ou au trimestre. Modèles et tarifs affichés en toute transparence ; disponibilité réelle consultable en temps réel dans la console.

Louer un Mac dans le cloud Voir les modèles et les durées de location