From de6cb27f4850470e509a003bc9c0a7d5f2819d0b Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 4 Oct 2026 11:33:42 -0600 Subject: [PATCH] MongoDB sessions: no resurrection on save, legacy purge, key escaping; CI - save() now replaces with upsert: false, so a save racing destroy() (logout) can't bring the session back. When nothing matched it logs at debug level (it's expected after a logout). Trade-off: a session whose create() insert failed, or that the TTL removed, is no longer re-created by the next save. - setup() deletes documents left by the archived Perfect-Session-MongoDB: string token, string data, created/updated/idle, no expiresAt. This driver can't resume them and the TTL index never expires them. The purge isn't in clean(), so during a rolling deploy it doesn't keep deleting sessions that old instances are still writing. README (en + zh_CN) has a migration note. - Keys in session.data are escaped at every depth: "%" -> "%25", NUL -> "%00", "" -> "%". libmongoc rejects an empty key, which failed the whole write (only logged), and a key was cut short at NUL. Other keys are stored unchanged, so data. queries still work. Data written by main's driver with "%" in a key would be read back differently, but no tagged release contains the MongoDB driver yet. - Perfect-MongoDB floor raised to 4.0.2. - GitHub Actions CI. Linux (swift:6.4-resolute) runs every backend: SQLite through libsqlite3-dev, now that Perfect-SQLite builds on Linux (PerfectlySoft/Perfect-SQLite#23), plus real MongoDB, PostgreSQL, MySQL and Redis services. macOS builds and runs the core and SQLite tests. README (en + zh_CN) no longer calls the SQLite backend macOS-only. - The SQLite, PostgreSQL, MySQL and Redis suites are now serialized: each test sets the connector's global settings. The review saw the SQLite suite fail or segfault intermittently on main; 12 release runs here passed. The three new server tests (empty key, save after destroy, legacy purge) fail on main and pass here against MongoDB 8.3. The full suite passes in macOS debug and release, and on Linux (Swift 6.4, Ubuntu 26.04 container) with SQLite and against MongoDB 8, PostgreSQL 17, MySQL 8.4 and Redis 7. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 73 ++++++++++++ Package.swift | 2 +- README.md | 20 +++- README.zh_CN.md | 17 ++- .../MongoDBSessionDriver.swift | 112 ++++++++++++++++-- .../PerfectSessionMongoDBTests.swift | 110 +++++++++++++++++ .../PerfectSessionMySQLTests.swift | 3 +- .../PerfectSessionPostgreSQLTests.swift | 3 +- .../PerfectSessionRedisTests.swift | 3 +- .../PerfectSessionSQLiteTests.swift | 3 +- 10 files changed, 325 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1189b5b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + pull_request: + +jobs: + linux: + # Every backend against a real server (SQLite uses a local file). + name: Linux (Swift 6.4, all backends) + runs-on: ubuntu-24.04 + container: swift:6.4-resolute + services: + mongo: + image: mongo:8 + postgres: + image: postgres:17 + env: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: perfect_sessions_test + options: >- + --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 20 + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: perfect_sessions_test + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" --health-interval 5s --health-timeout 5s --health-retries 30 + redis: + image: redis:7 + env: + MONGODB_TESTS: "1" + MONGODB_URI: mongodb://mongo:27017 + PG_TESTS: "1" + PG_HOST: postgres + MYSQL_TESTS: "1" + MYSQL_HOST: mysql + REDIS_TESTS: "1" + REDIS_HOST: redis + SQLITE_TESTS: "1" + steps: + - uses: actions/checkout@v7 + + - name: Install client libraries + run: | + apt-get update + apt-get install -y --no-install-recommends pkg-config libmongoc-dev libbson-dev libpq-dev libmysqlclient-dev libsqlite3-dev + + - name: Test + run: swift test + + macos: + # GitHub's macOS runners have no database servers, so this covers the build plus the + # server-independent tests, including SQLite (a local file). + name: macOS (build, core and SQLite tests) + runs-on: macos-26 + env: + SQLITE_TESTS: "1" + steps: + - uses: actions/checkout@v7 + + - name: Install client libraries + run: | + brew install mongo-c-driver mysql-client libpq + # Keg-only; older SwiftPM releases don't find libpq through its brew provider alone. + echo "PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig" >> "$GITHUB_ENV" + + - name: Toolchain + run: swift --version + + - name: Test + run: swift test diff --git a/Package.swift b/Package.swift index 4a7b3a3..df3ede2 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", branch: "main"), .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", branch: "main"), .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.1"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.2"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), ], targets: [ diff --git a/README.md b/README.md index d9c4b0d..64ae9e3 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,7 @@ repos into one package with a product per backend; those repos are superseded by ## Requirements - Swift 6.2 or later (`swift-tools-version: 6.2`). Every target builds in Swift 6 language mode. -- macOS 12 or later, or Linux. On Linux (tested with Swift 6.4 on Ubuntu 24.04), the core and the MySQL, - PostgreSQL, Redis and MongoDB backends build. The **SQLite backend is macOS-only for now**, because - Perfect-SQLite uses the SQLite module from Apple's SDK. +- macOS 12 or later, or Linux. Every backend builds on Linux (tested with Swift 6.4 on Ubuntu 26.04). - Each backend needs its database's client library; see the list below. ## Building @@ -77,12 +75,26 @@ variables in that test file. | `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL client library (`libmysqlclient`) | `MYSQL_TESTS` | | `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` | | `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis), `swift-log` | — | `REDIS_TESTS` | -| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS (SQLite from the SDK) | `SQLITE_TESTS` | +| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS, Linux (`libsqlite3-dev` on Linux) | `SQLITE_TESTS` | | `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` | The **MongoDB** backend stores each session as a document keyed by its token, with `data` as a subdocument. Its `setup()` adds a TTL index, so MongoDB deletes expired sessions itself. Besides `MongoDBSessionConnector` (`uri`, `database`, `collection`), you can pass a URI or an existing `MongoClientPool` to its initializer. +`save` only updates a session that still exists, so a save that races a logout can't bring the session back; +a session that was never stored (its `create` failed) or has already expired isn't stored by `save` either. +libmongoc rejects an empty key, and a key is cut short at a NUL, so keys in `data` are escaped at every depth: +`%` is stored as `%25`, NUL as `%00`, and the empty key as `%`. Other keys are stored as is, so queries such as +`data.role` keep working. + +### Migrating from Perfect-Session-MongoDB + +The archived `Perfect-Session-MongoDB` stored each session under its own `_id`, with the token in a `token` +field, `data` as JSON text, and no expiry date. The MongoDB backend can't read those documents, so existing +sessions end when you switch and users sign in again. The TTL index never removes them either, so if you point +the new driver at the old collection, `setup()` deletes them: documents with a string `token`, string `data`, +`created`, `updated` and `idle` fields, and no `expiresAt`. Run `setup()` again once no old instances are left +writing to the collection. To keep the old documents, use a different `collection`. ## Further information diff --git a/README.zh_CN.md b/README.zh_CN.md index c183342..348fa88 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -21,9 +21,7 @@ Perfect 的会话存储与会话安全基础组件,针对 Swift 6 与严格并 ## 环境要求 - Swift 6.2 或更新版本(`swift-tools-version: 6.2`)。所有目标都在 Swift 6 语言模式下编译。 -- macOS 12 或更新版本,或 Linux。在 Linux 上(已用 Ubuntu 24.04 上的 Swift 6.4 测试),核心库以及 MySQL、 - PostgreSQL、Redis 和 MongoDB 后端均可编译。**SQLite 后端目前仅支持 macOS**,因为 Perfect-SQLite 使用的是 - Apple SDK 中的 SQLite 模块。 +- macOS 12 或更新版本,或 Linux。所有后端都可以在 Linux 上编译(已用 Ubuntu 26.04 上的 Swift 6.4 测试)。 - 每个后端都需要对应数据库的客户端库,详见下方列表。 ## 构建 @@ -73,12 +71,23 @@ Swift 6 版本尚未发布带标签的正式版本,因此请依赖 `main` 分 | `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL 客户端库(`libmysqlclient`) | `MYSQL_TESTS` | | `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` | | `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis)、`swift-log` | — | `REDIS_TESTS` | -| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS(使用 SDK 中的 SQLite) | `SQLITE_TESTS` | +| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS、Linux(Linux 上需要 `libsqlite3-dev`) | `SQLITE_TESTS` | | `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` | **MongoDB** 后端把每个会话存储为一个以其令牌为键的文档,`data` 以子文档形式存储。它的 `setup()` 会添加 TTL 索引,让 MongoDB 自动删除过期会话。除了 `MongoDBSessionConnector`(`uri`、`database`、`collection`)之外, 你也可以向其初始化方法传入 URI 或现有的 `MongoClientPool`。 +`save` 只会更新仍然存在的会话,因此与注销同时发生的保存不会让会话复活;从未存储成功(其 `create` 失败)或已经过期的 +会话,`save` 也不会再存储。libmongoc 拒绝空键,而键会在 NUL 处被截断,因此 `data` 中各层的键都会被转义:`%` +存为 `%25`,NUL 存为 `%00`,空键存为 `%`。其他键原样存储,因此 `data.role` 之类的查询照常可用。 + +### 从 Perfect-Session-MongoDB 迁移 + +已归档的 `Perfect-Session-MongoDB` 把每个会话存储在它自己的 `_id` 下,令牌放在 `token` 字段中,`data` 是 JSON +文本,并且没有过期时间。MongoDB 后端无法读取这些文档,因此切换后现有会话都会结束,用户需要重新登录。TTL 索引也 +永远不会删除它们,所以如果你让新驱动使用旧的集合,`setup()` 会删除这些文档:即 `token` 为字符串、`data` 为字符串、 +含有 `created`、`updated` 和 `idle` 字段且没有 `expiresAt` 的文档。等不再有旧实例写入该集合后,再运行一次 +`setup()`。如果想保留旧文档,请使用另一个 `collection`。 ## 更多信息 diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index beac734..6ebf75a 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -18,10 +18,16 @@ public struct MongoDBSessionConnector: Sendable { /// { _id: , userid, created, updated, idle, ipaddress, useragent, /// data: { ... }, expiresAt: } /// -/// `data` is a real subdocument, not JSON text. `setup()` adds a TTL index on `expiresAt` -/// (`updated + idle`), so MongoDB deletes expired sessions on its own; `clean()` still -/// deletes them immediately for callers that rely on it. Like the other drivers, `resume` -/// returns expired sessions too: callers check `PerfectSession.isValid()`. +/// `data` is a real subdocument, not JSON text. Keys libmongoc won't store are escaped (see +/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`), so MongoDB +/// deletes expired sessions on its own; `clean()` still deletes them immediately for callers +/// that rely on it. Like the other drivers, `resume` returns expired sessions too: callers +/// check `PerfectSession.isValid()`. +/// +/// `save` only updates an existing session, so a save racing a `destroy` (logout) can't bring +/// the session back. `setup()` also deletes documents left by the archived Perfect-Session-MongoDB +/// package (keyed by `token`, with no `expiresAt`): this driver can't resume them and the TTL +/// index never expires them. /// /// All database work goes through a `MongoClientPool`, off Swift's cooperative thread pool. public final class MongoDBSessionDriver: SessionDriver, Sendable { @@ -65,6 +71,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { } catch { logger.error("session setup failed: \(error)") } + await purgeLegacySessions() } public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { @@ -103,8 +110,13 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { public func save(_ session: PerfectSession) async { let document = SessionDocument(session) do { - _ = try await withCollection { sessions in - try sessions.replaceOne(filter: Self.filter(token: document._id), with: document, upsert: true) + let matched = try await withCollection { sessions in + try sessions.replaceOne(filter: Self.filter(token: document._id), with: document, upsert: false) + } + if matched == 0 { + // Expected after a logout when middleware saves at the end of the request. + logger.debug("session save matched no session (destroyed or expired); not recreated", + metadata: ["eventid": "\(session.token)"]) } } catch { logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"]) @@ -136,6 +148,27 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { // MARK: - Private helpers + /// Deletes documents written by the archived Perfect-Session-MongoDB: `{_id, token, userid, + /// created, updated, idle, data, ipaddress, useragent}` with `data` as JSON text and no + /// `expiresAt`. Documents this driver writes always have `expiresAt` and never a `token` field; + /// the rest of the shape keeps unrelated documents that happen to have a `token` safe. + private func purgeLegacySessions() async { + let filter = #""" + {"token": {"$type": "string"}, "data": {"$type": "string"}, "created": {"$exists": true}, + "updated": {"$exists": true}, "idle": {"$exists": true}, "expiresAt": {"$exists": false}} + """# + do { + let deleted = try await withCollection { sessions in + try sessions.deleteMany(filter: try BSON(json: filter)) + } + if deleted > 0 { + logger.notice("deleted \(deleted) legacy Perfect-Session-MongoDB session(s)") + } + } catch { + logger.error("legacy session purge failed: \(error)") + } + } + private func withCollection(_ body: @Sendable @escaping (MongoCollection) throws -> T) async throws -> T { let database = self.database let collection = self.collection @@ -156,6 +189,10 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { } /// The stored form of a `PerfectSession`. +/// +/// libmongoc rejects an empty key, and a key containing NUL would be cut short, so keys in +/// `data` (at any depth) are escaped: `%` → `%25`, NUL → `%00`, and the empty key → `%`. +/// Every other key is stored as is, so queries like `data.role` keep working. struct SessionDocument: Codable, Sendable { var _id: String var userid: String @@ -176,7 +213,8 @@ struct SessionDocument: Codable, Sendable { ipaddress = session.ipaddress useragent = session.useragent // Through JSON text, not [String: Any] casts: on Darwin a JSON true and 1 are both NSNumber. - data = (try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:]) + data = ((try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:])) + .mappingKeys(Self.escape) expiresAt = Date(timeIntervalSince1970: TimeInterval(session.updated + session.idle)) } @@ -189,11 +227,56 @@ struct SessionDocument: Codable, Sendable { session.idle = idle session.ipaddress = ipaddress session.useragent = useragent - if let json = try? JSONEncoder().encode(data) { + if let json = try? JSONEncoder().encode(data.mappingKeys(Self.unescape)) { session.fromjson(String(decoding: json, as: UTF8.self)) } return session } + + static func escape(_ key: String) -> String { + guard !key.isEmpty else { + return "%" + } + guard key.utf8.contains(where: { $0 == 0x25 || $0 == 0 }) else { + return key + } + var bytes: [UInt8] = [] + for byte in key.utf8 { + switch byte { + case 0x25: bytes += Array("%25".utf8) + case 0: bytes += Array("%00".utf8) + default: bytes.append(byte) + } + } + return String(decoding: bytes, as: UTF8.self) + } + + static func unescape(_ key: String) -> String { + guard key != "%" else { + return "" + } + guard key.utf8.contains(0x25) else { + return key + } + let utf8 = Array(key.utf8) + var bytes: [UInt8] = [] + var index = 0 + while index < utf8.count { + let escape = utf8[index] == 0x25 && index + 2 < utf8.count ? (utf8[index + 1], utf8[index + 2]) : nil + switch escape { + case (0x32, 0x35)?: // %25 + bytes.append(0x25) + index += 3 + case (0x30, 0x30)?: // %00 + bytes.append(0) + index += 3 + default: // Not an escape this driver writes: keep it as is. + bytes.append(utf8[index]) + index += 1 + } + } + return String(decoding: bytes, as: UTF8.self) + } } /// A JSON value, so `PerfectSession.data` can be stored as a BSON subdocument. @@ -225,6 +308,19 @@ enum JSONValue: Codable, Sendable, Equatable { } } + /// Rewrites object keys at every depth. + func mappingKeys(_ transform: (String) -> String) -> JSONValue { + switch self { + case .array(let values): + return .array(values.map { $0.mappingKeys(transform) }) + case .object(let members): + return .object(Dictionary(members.map { (transform($0.key), $0.value.mappingKeys(transform)) }, + uniquingKeysWith: { first, _ in first })) + default: + return self + } + } + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index ac43fec..85c7bd0 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -34,6 +34,116 @@ import PerfectMongoDB #expect(restored.data["name"] as? String == "x") } + @Test func keysBSONCantHoldAreEscapedAndRestored() throws { + let awkward = ["", "%", "%%", "50%", "%25", "%00", "a%b", "nul\u{0}x", "\u{0}", "plain", "a.b", "$x"] + for key in awkward { + #expect(SessionDocument.unescape(SessionDocument.escape(key)) == key, "\(key.debugDescription)") + let escaped = SessionDocument.escape(key) + #expect(!escaped.isEmpty && !escaped.utf8.contains(0), "\(key.debugDescription)") + } + #expect(SessionDocument.escape("plain") == "plain") + #expect(SessionDocument.escape("a.b") == "a.b") + + var session = PerfectSession() + session.token = "t2" + var data: [String: Any] = ["nested": ["": 2, "in\u{0}ner": ["%": 3]], "list": [["": 4]]] + for (index, key) in awkward.enumerated() { + data[key] = index + } + session.data = data + let document = SessionDocument(session) + guard case .object(let stored) = document.data else { + Issue.record("data is not an object") + return + } + #expect(stored["%"] == .int(0)) + #expect(stored[""] == nil) + + let restored = try BSONDecoder().decode(SessionDocument.self, from: try BSONEncoder().encode(document)).session + for (index, key) in awkward.enumerated() { + #expect(restored.data[key] as? Int == index, "\(key.debugDescription)") + } + let nested = restored.data["nested"] as? [String: Any] + #expect(nested?[""] as? Int == 2) + #expect((nested?["in\u{0}ner"] as? [String: Any])?["%"] as? Int == 3) + #expect(((restored.data["list"] as? [Any])?.first as? [String: Any])?[""] as? Int == 4) + } + + @Test func emptyKeySavesAndResumes() async throws { + guard Self.mongoEnabled else { return } + let driver = try getDriver() + var session = await driver.create() + session.data[""] = "empty" + session.data["visits"] = 2 + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.data[""] as? String == "empty") + #expect(resumed.data["visits"] as? Int == 2) + await driver.destroy(token: session.token) + } + + @Test func saveAfterDestroyDoesNotResurrect() async throws { + guard Self.mongoEnabled else { return } + let driver = try getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + @Test func setupPurgesLegacySessionsOnly() async throws { + guard Self.mongoEnabled else { return } + let driver = try getDriver(collection: "sessions_legacy_test") + func insertLegacy(_ token: String) async throws { + // The shape Perfect-Session-MongoDB wrote: its own _id, the token in a field, data as JSON text. + try await driver.pool.withClient { client in + let document = try BSON(json: """ + {"_id": "\(UUID().uuidString)", "token": "\(token)", "userid": "", "created": 0, "updated": 0, + "idle": 86400, "data": "{}", "ipaddress": "", "useragent": ""} + """) + let collection = client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_legacy_test") + if case .error(_, _, let message) = collection.insert(document: document) { + throw MongoError(domain: 0, code: 0, message: message) + } + } + } + func count(_ json: String) async throws -> Int { + try await driver.pool.withClient { client in + try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_legacy_test") + .countDocuments(filter: try BSON(json: json)) + } + } + try await driver.pool.withClient { client in + _ = client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_legacy_test").drop() + } + try await insertLegacy("legacy-1") + let current = await driver.create() + try await driver.pool.withClient { client in + // Not legacy sessions, though some have a token field: left alone. + let collection = client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_legacy_test") + for json in [#"{"_id": "unrelated", "note": "keep"}"#, + #"{"_id": "api-user", "email": "a@example.com", "token": "api-key-123"}"#, + #"{"_id": "reset", "token": null, "data": "{}", "created": 0, "updated": 0, "idle": 1}"#] { + _ = collection.insert(document: try BSON(json: json)) + } + } + + await driver.setup() + #expect(try await count(#"{"token": {"$regex": "^legacy-"}}"#) == 0) + #expect(try await count(#"{"_id": {"$in": ["unrelated", "api-user", "reset"]}}"#) == 3) + _ = try await driver.resume(token: current.token) + + // clean() only expires sessions; the purge is a setup() step. + try await insertLegacy("legacy-2") + await driver.clean() + #expect(try await count(#"{"token": "legacy-2"}"#) == 1) + _ = try await driver.resume(token: current.token) + await driver.destroy(token: current.token) + } + @Test func createAndResume() async throws { guard Self.mongoEnabled else { return } let driver = try getDriver() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 9b20c16..7c60d52 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -3,7 +3,8 @@ import Foundation @testable import PerfectSessionMySQL import PerfectSessionCore -@Suite struct PerfectSessionMySQLTests { +// Serialized: each test sets the connector's global settings. +@Suite(.serialized) struct PerfectSessionMySQLTests { static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 5976f6b..fc2b41f 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -3,7 +3,8 @@ import Foundation @testable import PerfectSessionPostgreSQL import PerfectSessionCore -@Suite struct PerfectSessionPostgreSQLTests { +// Serialized: each test sets the connector's global settings. +@Suite(.serialized) struct PerfectSessionPostgreSQLTests { static let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1" diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 3ca3eb0..66b7ff2 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -3,7 +3,8 @@ import Foundation @testable import PerfectSessionRedis import PerfectSessionCore -@Suite struct PerfectSessionRedisTests { +// Serialized: each test sets the connector's global settings. +@Suite(.serialized) struct PerfectSessionRedisTests { static let redisEnabled = ProcessInfo.processInfo.environment["REDIS_TESTS"] == "1" diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index a7d9acc..036137a 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -3,7 +3,8 @@ import Foundation @testable import PerfectSessionSQLite import PerfectSessionCore -@Suite struct PerfectSessionSQLiteTests { +// Serialized: each test sets the connector's global settings. +@Suite(.serialized) struct PerfectSessionSQLiteTests { static let sqliteEnabled = ProcessInfo.processInfo.environment["SQLITE_TESTS"] == "1"