クラウド Mac で検証する iOS Cookie 分離とログアウトの境界

クラウド Mac で検証する iOS Cookie 分離とログアウトの境界

クラウド Mac で検証する iOS Cookie 分離とログアウトの境界

同じクラウド Mac 上でアカウント切り替えの回帰テストを実行し、アカウント B でログインしたのに A のデータが返ってきても、すぐにシミュレータを初期化する必要はありません。まずは Cookie の保存領域、リクエストヘッダー、実行中のタスクを切り分けて調べます。古いセッションの混入元は、共有ストレージかもしれませんし、ネットワークのラッパーが手動で設定したヘッダーや、ログアウト後に届いたレスポンスかもしれません。

ここでは、まず macOS 標準のネットワークフレームワークを使って通信なしで検証し、続いて同じアサーションを iOS シミュレータでのテストに組み込みます。検証コードをリポジトリに入れておけば、前回の GUI セッションに残った状態に依存せず、クラウド Mac でチェックアウトするたびに繰り返し実行できます。

まずセッションの所有関係を明確にする

ログインするアカウントごとに独立したセッションオブジェクトを作り、設定、Cookie ストア、タスクの集合をそのオブジェクトに持たせます。変更可能な設定をグローバルなシングルトンにしてはいけません。また、元の設定を変更しても、作成済みの URLSession には反映されません。セッションの作成時に設定がコピーされるためです。

少なくとも、次の 3 つを制約として明文化します。A の Cookie が B のリクエストに混入しないこと、ログアウト時に削除するのは対象アカウントが所有するデータだけであること、ログアウト後に古いレスポンスが届いてもログイン状態が復活しないことです。匿名リクエストで Cookie が一切不要なら、自動処理を無効にするとともに、ラッパー層が Cookie ヘッダーを手動設定していないことも確認します。

ephemeral は、セッションデータをディスクに永続化しないという意味です。メモリ上に Cookie が存在しないという意味でも、アプリ内の 2 つのアカウントが自動的に分離されるという意味でもありません。

スクリプトで保存領域の独立性を確認する

以下を cookie-check.swift として保存します。使用するのは架空のセッション値だけで、ドメインはローカルの localhost です。ネットワークリクエストは送信せず、デフォルトの共有ストレージにもアクセスしません。

import Foundation

func require(_ condition: Bool, _ message: String) {
    if !condition {
        fatalError(message)
    }
}

func makeCookie(_ value: String) -> HTTPCookie {
    HTTPCookie(properties: [
        .domain: "localhost",
        .path: "/",
        .name: "session",
        .value: value
    ])!
}

let first = URLSessionConfiguration.ephemeral
let second = URLSessionConfiguration.ephemeral

guard let a = first.httpCookieStorage,
      let b = second.httpCookieStorage else {
    fatalError("Cookie storage unavailable")
}

require(a !== b, "Stores must be independent")

let target = URL(string: "http://localhost/account")!
a.setCookie(makeCookie("fixture-a"))
b.setCookie(makeCookie("fixture-b"))

func values(_ store: HTTPCookieStorage) -> [String] {
    (store.cookies(for: target) ?? [])
        .filter { $0.name == "session" }
        .map(\.value)
        .sorted()
}

require(values(a) == ["fixture-a"], "Account A mismatch")
require(values(b) == ["fixture-b"], "Account B mismatch")

for cookie in a.cookies ?? [] {
    a.deleteCookie(cookie)
}

require(values(a).isEmpty, "Account A was not cleared")
require(values(b) == ["fixture-b"], "Account B was affected")

コマンドラインツールの初期設定が済んだマシンで実行します。

set -eu
xcrun swift cookie-check.swift

正常終了時は何も出力されず、アサーションが失敗すると非ゼロの終了ステータスで終了します。ここで a 全体を空にできるのは、A 専用のストアであることが前提です。アプリがまだ共有ストレージを使っている場合は、先にアカウントの所有関係で絞り込む必要があり、この全件削除をそのまま流用してはいけません。

このスクリプトで確認できるのは、保存領域の独立性と削除範囲です。実際のリクエストで正しいストアが使われていることまでは証明できません。この検証に通っただけで、ネットワーク層の受け入れ条件も満たしたと判断しないでください。

実際のリクエストとレスポンスで検証する

アプリのテストターゲットでは、実際に使っているネットワーククライアントを再利用し、A と B のセッションを別々に作成します。制御可能なテストサーバーから、名前は同じで値が異なる Set-Cookie を返します。その後、受信内容を返すエコーエンドポイントにリクエストし、サーバーが受け取った Cookie がそれぞれ対応するアカウントのものであることを検証します。

リクエストは必ずアプリのラッパー層を通し、テストのためにインターセプターを迂回しないようにします。特に、デフォルトのリクエストヘッダー、リトライ処理、リダイレクト処理が共有ストレージを再び参照していないかを確認してください。Cookie の自動処理を検証するときも、ヘッダーを手動で設定してはいけません。下位層の接続ミスが隠れてしまう可能性があります。

Path、Secure、有効期限のテストケースを追加します。狭いパスに限定された Cookie が無関係なパスへ送られないこと、有効期限切れの Cookie が送信され続けないことを確認します。Secure 属性付き Cookie は、制御可能な HTTPS 環境で検証してください。ローカルの HTTP フィクスチャでは、セキュアな接続に関する挙動は証明できません。

同じ名前の Cookie でも、ドメインやパスが異なれば共存できます。ストア内に session が常に 1 つだけあると仮定せず、名前、ドメイン、パス、値の組み合わせからなる集合を比較してください。また、リクエストヘッダー文字列内の並び順を過度に前提とせず、解析したうえで意味的に比較します。

ログアウトでは遅れて届くレスポンスも処理する

まずアカウントの状態を「ログアウト処理中」に切り替え、新しいタスクの作成を拒否します。次に、既存のタスクをキャンセルするか、完了するまで待ちます。その後で対象アカウントの Cookie を削除し、古いセッションを無効化して、次回のログイン用に新しいオブジェクトを作成します。

削除だけでは競合が残ります。古いリクエストが後から Set-Cookie を受け取り、古いストアに再び書き込む可能性があるためです。キャンセルも同期バリアにはならず、コールバックがすでにキューに入っている場合があります。そのため、ログインごとにセッションの世代を割り当て、レスポンスを処理する前に世代を照合します。古い世代から現在のユーザー状態を更新させてはいけません。

テストでは A のレスポンスを遅延させ、ログアウトを開始して B でログインした後に、A のレスポンスを返します。最後に、B の画面状態、アプリ上のアカウント識別情報、次のリクエストに含まれる Cookie をすべて確認します。ログアウトした瞬間にストアが空であることだけを調べても、この種の問題は検出できません。

回帰テストのゲートを 2 層に分けて実行する

マージのたびに、まず通信なしのストレージ検証スクリプトを実行し、その後、固定したシミュレータでリクエストのエコーテストと遅延レスポンスのテストを実行します。各テストは専用の設定とフィクスチャを作成し、終了時にはサーバーを停止してタスクをキャンセルします。変更可能なセッションを共有せず、並列実行で互いに影響しないようにしてください。

失敗時の記録には、リクエスト ID、セッションの世代、Cookie の名前と属性を残しますが、実際の Cookie 値は保存しません。値の比較が必要な場合は、テストフィクスチャ内の固定マーカーを使い、本番環境のログイン状態をクラウド Mac にコピーしないでください。

最後に、意図的に 2 つのアカウントでストアを共有させ、テストが失敗することを確認します。さらに、世代チェックを意図的に外し、遅れて届くレスポンスの問題を検出できることも確かめます。この 2 種類の回帰を安定して検出できて初めて、単にいくつかのアサーションが偶然通っているのではなく、アカウントの境界を検証するゲートになったと言えます。

よくある質問

ephemeral を使えば Cookie は混ざりませんか?

設定名だけでは判断できません。一時セッションもメモリ内に Cookie を保持します。設定を個別に生成し、保存領域の独立性と、通信層による共有ストアや Cookie ヘッダーの再注入を確認してください。

ログアウト時に reset を呼ぶだけで十分ですか?

不十分です。新規リクエストを止め、既存タスクを終了させてから対象アカウントの Cookie を削除し、旧セッションを無効化します。reset はキャッシュと認証情報にも影響するため、この手順の代わりにはなりません。

次のビルドは、専有のMacで。

仮想マシンではないクラウドMacを選び、既存のXcodeやCIのワークフローをそのまま利用できます。開発プロジェクトの負荷に合わせてマシンを選択してください。実際の利用可否はコンソールに表示される最新情報をご確認ください。

クラウドMacを今すぐレンタル

次のビルドは、専有の物理マシンに。

既存のコマンドやCIのワークフローを維持し、開発プロジェクトの負荷に合わせてクラウドMacを選べます。