| @@ -0,0 +1,100 @@ |
| |
1 | import Foundation |
| |
2 | import Testing |
| |
3 | @testable import gitbay |
| |
4 | |
| |
5 | /// The trust-surface invariants from the v1 security sweep (#2). These |
| |
6 | /// pin behavior so a refactor cannot quietly loosen it. |
| |
7 | struct SecuritySweepTests { |
| |
8 | |
| |
9 | // MARK: - Transport |
| |
10 | |
| |
11 | @Test func theDefaultSessionStoresNothing() { |
| |
12 | let configuration = GitbayClient.makeEphemeralSession().configuration |
| |
13 | // No disk cache: private repo content must never land in Cache.db. |
| |
14 | #expect(configuration.urlCache == nil) |
| |
15 | // No cookies accepted or sent — the API is bearer-token only. |
| |
16 | #expect(configuration.httpCookieAcceptPolicy == .never) |
| |
17 | #expect(configuration.httpShouldSetCookies == false) |
| |
18 | // Ephemeral: no credential or cookie storage backed by disk. |
| |
19 | #expect(configuration.httpCookieStorage?.cookies?.isEmpty ?? true) |
| |
20 | } |
| |
21 | |
| |
22 | @Test func theTokenOnlyTravelsToTheInstanceHost() async throws { |
| |
23 | let box = StubProtocol.box() |
| |
24 | let client = GitbayClient( |
| |
25 | instance: try GitbayInstance(url: "https://gitbay.org"), |
| |
26 | token: "gb_secret", |
| |
27 | session: box.session() |
| |
28 | ) |
| |
29 | box.enqueue(.init(status: 200, json: |
| |
30 | #"{"protocol_version":1,"data":{"username":"cmc"},"exit_code":0}"#)) |
| |
31 | |
| |
32 | nonisolated struct Who: Decodable, Sendable { let username: String } |
| |
33 | _ = try await client.read(["whoami"], as: Who.self) |
| |
34 | |
| |
35 | let seen = try #require(box.seen.first) |
| |
36 | #expect(seen.url.host() == "gitbay.org") |
| |
37 | #expect(seen.headers["Authorization"] == "Bearer gb_secret") |
| |
38 | // And no cookie header ever accompanies it. |
| |
39 | #expect(seen.headers["Cookie"] == nil) |
| |
40 | } |
| |
41 | |
| |
42 | @Test func redirectsOffTheInstanceAreRefusedByHostCheck() throws { |
| |
43 | let instance = try GitbayInstance(url: "https://gitbay.org") |
| |
44 | // The exact checks RedirectGuard and perform() rely on. |
| |
45 | #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!)) |
| |
46 | #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!)) // downgrade |
| |
47 | #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/x")!)) // suffix trick |
| |
48 | #expect(!instance.isOwn(URL(string: "https://gitbay.org:8443/x")!)) // port swap |
| |
49 | #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!)) |
| |
50 | } |
| |
51 | |
| |
52 | @Test func plaintextHTTPIsRefusedForAnythingButLoopback() { |
| |
53 | #expect(throws: GitbayInstance.InvalidURL.self) { |
| |
54 | _ = try GitbayInstance(url: "http://forge.example") |
| |
55 | } |
| |
56 | #expect(throws: GitbayInstance.InvalidURL.self) { |
| |
57 | // A LAN address is not loopback; ATS would block it too. |
| |
58 | _ = try GitbayInstance(url: "http://192.168.1.10") |
| |
59 | } |
| |
60 | #expect((try? GitbayInstance(url: "http://localhost:3000")) != nil) |
| |
61 | #expect((try? GitbayInstance(url: "http://127.0.0.1:3000")) != nil) |
| |
62 | } |
| |
63 | |
| |
64 | // MARK: - Storage |
| |
65 | |
| |
66 | @Test func nothingTokenShapedReachesUserDefaults() async throws { |
| |
67 | let defaults = try #require(UserDefaults(suiteName: "security.\(UUID().uuidString)")) |
| |
68 | let store = MemoryTokenStore() |
| |
69 | let box = StubProtocol.box() |
| |
70 | box.enqueue(.init(status: 200, json: |
| |
71 | #"{"protocol_version":1,"data":{"username":"cmc"},"exit_code":0}"#)) |
| |
72 | let session = await SessionStore(store: store, defaults: defaults) { instance, token in |
| |
73 | GitbayClient(instance: instance, token: token, session: box.session()) |
| |
74 | } |
| |
75 | |
| |
76 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret_value") |
| |
77 | |
| |
78 | // The only thing persisted outside the token store is the account |
| |
79 | // id, and no persisted value contains the token. |
| |
80 | let persisted = defaults.persistentDomain(forName: defaults.description) ?? [:] |
| |
81 | _ = persisted |
| |
82 | for (key, value) in defaults.dictionaryRepresentation() { |
| |
83 | if let text = value as? String { |
| |
84 | #expect(!text.contains("gb_secret_value"), |
| |
85 | "token leaked into UserDefaults key \(key)") |
| |
86 | } |
| |
87 | } |
| |
88 | #expect(store.token(for: "https://gitbay.org#cmc") == "gb_secret_value") |
| |
89 | } |
| |
90 | |
| |
91 | @Test func theCacheKeyIsAOneWayDigestOfTheToken() throws { |
| |
92 | // Two clients with different tokens must not share ETag cache |
| |
93 | // keys, and the key must not contain the token itself. |
| |
94 | let store = ETagStore() |
| |
95 | _ = store |
| |
96 | let key = ETagStore.key(account: "0a1b2c3d4e5f6a7b", argv: ["repo", "list"]) |
| |
97 | #expect(!key.contains("gb_")) |
| |
98 | #expect(key.contains("repo")) |
| |
99 | } |
| |
100 | } |