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"