Skip to content

Commit 9302db8

Browse files
committed
test: add tests and helper Debug-only APIs
1 parent 3b0d695 commit 9302db8

5 files changed

Lines changed: 352 additions & 1 deletion

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { beforeEach, describe, expect, test } from 'react-native-harness';
2+
import { NativeCookie, NativeCookieTestHelpers } from 'mendix-native';
3+
4+
// Cookie sizing constants.
5+
// 10 cookies × 7 000-char value → serialised blob ≈ 70–80 KB, above the 64 KB chunk threshold.
6+
const LARGE_COUNT = 10;
7+
const LARGE_VALUE_SIZE = 7_000;
8+
// 3 cookies with tiny values → blob well under 64 KB (single-item path).
9+
const SMALL_COUNT = 3;
10+
const SMALL_VALUE_SIZE = 10;
11+
12+
describe('SessionCookieStore', () => {
13+
beforeEach(async () => {
14+
await NativeCookie.clearAll();
15+
});
16+
17+
// ---------------------------------------------------------------------------
18+
// Small-blob (single-item keychain format, ≤ 64 KB)
19+
// ---------------------------------------------------------------------------
20+
21+
describe('small-blob round-trip (single-item format)', () => {
22+
test('persists and restores small cookies', async () => {
23+
await NativeCookieTestHelpers.seedTestCookies(
24+
SMALL_COUNT,
25+
SMALL_VALUE_SIZE
26+
);
27+
await NativeCookieTestHelpers.persistSessionCookies();
28+
await NativeCookieTestHelpers.clearHTTPCookies();
29+
30+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
31+
32+
expect(names.length).toBe(SMALL_COUNT);
33+
for (let i = 0; i < SMALL_COUNT; i++) {
34+
expect(names).toContain(`testCookie${i}`);
35+
}
36+
});
37+
38+
test('single-item write does not create a chunk commit-marker', async () => {
39+
await NativeCookieTestHelpers.seedTestCookies(
40+
SMALL_COUNT,
41+
SMALL_VALUE_SIZE
42+
);
43+
await NativeCookieTestHelpers.persistSessionCookies();
44+
45+
const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
46+
47+
expect(chunkCount).toBe(0);
48+
});
49+
50+
test('keychain is empty after restore (cleared on read)', async () => {
51+
await NativeCookieTestHelpers.seedTestCookies(
52+
SMALL_COUNT,
53+
SMALL_VALUE_SIZE
54+
);
55+
await NativeCookieTestHelpers.persistSessionCookies();
56+
await NativeCookieTestHelpers.clearHTTPCookies();
57+
await NativeCookieTestHelpers.restoreSessionCookies();
58+
59+
// A second restore should find nothing.
60+
await NativeCookieTestHelpers.clearHTTPCookies();
61+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
62+
63+
expect(names.length).toBe(0);
64+
});
65+
});
66+
67+
// ---------------------------------------------------------------------------
68+
// Large-blob (chunked keychain format, > 64 KB)
69+
// ---------------------------------------------------------------------------
70+
71+
describe('large-blob round-trip (chunked format)', () => {
72+
test('persists and restores large cookies', async () => {
73+
await NativeCookieTestHelpers.seedTestCookies(
74+
LARGE_COUNT,
75+
LARGE_VALUE_SIZE
76+
);
77+
await NativeCookieTestHelpers.persistSessionCookies();
78+
await NativeCookieTestHelpers.clearHTTPCookies();
79+
80+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
81+
82+
expect(names.length).toBe(LARGE_COUNT);
83+
for (let i = 0; i < LARGE_COUNT; i++) {
84+
expect(names).toContain(`testCookie${i}`);
85+
}
86+
});
87+
88+
test('chunked write creates a commit-marker with count > 1', async () => {
89+
await NativeCookieTestHelpers.seedTestCookies(
90+
LARGE_COUNT,
91+
LARGE_VALUE_SIZE
92+
);
93+
await NativeCookieTestHelpers.persistSessionCookies();
94+
95+
const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
96+
97+
expect(chunkCount).toBeGreaterThan(1);
98+
});
99+
100+
test('commit-marker is removed after restore (chunked keys cleared on read)', async () => {
101+
await NativeCookieTestHelpers.seedTestCookies(
102+
LARGE_COUNT,
103+
LARGE_VALUE_SIZE
104+
);
105+
await NativeCookieTestHelpers.persistSessionCookies();
106+
await NativeCookieTestHelpers.clearHTTPCookies();
107+
await NativeCookieTestHelpers.restoreSessionCookies();
108+
109+
const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
110+
111+
expect(chunkCount).toBe(0);
112+
});
113+
114+
test('keychain is empty after restore (no second restore possible)', async () => {
115+
await NativeCookieTestHelpers.seedTestCookies(
116+
LARGE_COUNT,
117+
LARGE_VALUE_SIZE
118+
);
119+
await NativeCookieTestHelpers.persistSessionCookies();
120+
await NativeCookieTestHelpers.clearHTTPCookies();
121+
await NativeCookieTestHelpers.restoreSessionCookies();
122+
123+
await NativeCookieTestHelpers.clearHTTPCookies();
124+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
125+
126+
expect(names.length).toBe(0);
127+
});
128+
});
129+
130+
// ---------------------------------------------------------------------------
131+
// Format transitions
132+
// ---------------------------------------------------------------------------
133+
134+
describe('format transitions', () => {
135+
test('overwriting large (chunked) with small (single-item) leaves no chunk marker', async () => {
136+
await NativeCookieTestHelpers.seedTestCookies(
137+
LARGE_COUNT,
138+
LARGE_VALUE_SIZE
139+
);
140+
await NativeCookieTestHelpers.persistSessionCookies();
141+
142+
// Replace with a small set.
143+
await NativeCookieTestHelpers.clearHTTPCookies();
144+
await NativeCookieTestHelpers.seedTestCookies(
145+
SMALL_COUNT,
146+
SMALL_VALUE_SIZE
147+
);
148+
await NativeCookieTestHelpers.persistSessionCookies();
149+
150+
const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
151+
expect(chunkCount).toBe(0);
152+
153+
// Data is still correct.
154+
await NativeCookieTestHelpers.clearHTTPCookies();
155+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
156+
expect(names.length).toBe(SMALL_COUNT);
157+
});
158+
159+
test('overwriting small (single-item) with large (chunked) round-trips correctly', async () => {
160+
await NativeCookieTestHelpers.seedTestCookies(
161+
SMALL_COUNT,
162+
SMALL_VALUE_SIZE
163+
);
164+
await NativeCookieTestHelpers.persistSessionCookies();
165+
166+
// Replace with a large set.
167+
await NativeCookieTestHelpers.clearHTTPCookies();
168+
await NativeCookieTestHelpers.seedTestCookies(
169+
LARGE_COUNT,
170+
LARGE_VALUE_SIZE
171+
);
172+
await NativeCookieTestHelpers.persistSessionCookies();
173+
174+
await NativeCookieTestHelpers.clearHTTPCookies();
175+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
176+
expect(names.length).toBe(LARGE_COUNT);
177+
});
178+
});
179+
180+
// ---------------------------------------------------------------------------
181+
// clearAll
182+
// ---------------------------------------------------------------------------
183+
184+
describe('clearAll', () => {
185+
test('removes cookies after a small-blob persist', async () => {
186+
await NativeCookieTestHelpers.seedTestCookies(
187+
SMALL_COUNT,
188+
SMALL_VALUE_SIZE
189+
);
190+
await NativeCookieTestHelpers.persistSessionCookies();
191+
await NativeCookie.clearAll();
192+
193+
await NativeCookieTestHelpers.clearHTTPCookies();
194+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
195+
expect(names.length).toBe(0);
196+
});
197+
198+
test('removes cookies and chunk marker after a large-blob persist', async () => {
199+
await NativeCookieTestHelpers.seedTestCookies(
200+
LARGE_COUNT,
201+
LARGE_VALUE_SIZE
202+
);
203+
await NativeCookieTestHelpers.persistSessionCookies();
204+
await NativeCookie.clearAll();
205+
206+
const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
207+
expect(chunkCount).toBe(0);
208+
209+
await NativeCookieTestHelpers.clearHTTPCookies();
210+
const names = await NativeCookieTestHelpers.restoreSessionCookies();
211+
expect(names.length).toBe(0);
212+
});
213+
214+
test('does not throw when called on an already-empty store', async () => {
215+
await expect(NativeCookie.clearAll()).resolves.not.toThrow();
216+
await expect(NativeCookie.clearAll()).resolves.not.toThrow();
217+
});
218+
});
219+
});

ios/Modules/NativeCookieModule/NativeCookieModule.swift

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,84 @@ public class NativeCookieModule: NSObject {
66
NativeCookieModule.clearAll()
77
promise.resolve(nil)
88
}
9-
9+
1010
static func clearAll() {
1111
let storage = HTTPCookieStorage.shared
1212
for cookie in (storage.cookies ?? []) {
1313
storage.deleteCookie(cookie)
1414
}
1515
SessionCookieStore.clear()
1616
}
17+
18+
// MARK: - Test / diagnostic helpers (DEBUG builds only)
19+
// These methods are excluded from release builds to prevent cookie injection,
20+
// session DoS, and keychain information disclosure from arbitrary JS callers.
21+
22+
#if DEBUG
23+
/// Seeds `count` session cookies (no expiry), each with a `valueSize`-byte value,
24+
/// into `HTTPCookieStorage.shared`. Used in harness tests to produce blobs of
25+
/// controlled size without relying on a live server.
26+
public func seedTestCookies(count: Int, valueSize: Int, promise: Promise) {
27+
let storage = HTTPCookieStorage.shared
28+
for i in 0..<count {
29+
if let cookie = HTTPCookie(properties: [
30+
.name: "testCookie\(i)",
31+
.value: String(repeating: "X", count: valueSize),
32+
.domain: "test.mendix.com",
33+
.path: "/",
34+
// no .expires → session cookie (expiresDate == nil)
35+
]) {
36+
storage.setCookie(cookie)
37+
}
38+
}
39+
promise.resolve(nil)
40+
}
41+
42+
/// Persists the current session cookies in `HTTPCookieStorage.shared` to the keychain
43+
/// and resolves the promise once the async write completes.
44+
public func persistSessionCookies(_ promise: Promise) {
45+
SessionCookieStore.persist(completion: {
46+
promise.resolve(nil)
47+
})
48+
}
49+
50+
/// Deletes all cookies from `HTTPCookieStorage.shared` without touching the keychain.
51+
/// Use this between `persistSessionCookies` and `restoreSessionCookies` to simulate
52+
/// an app restart.
53+
public func clearHTTPCookies(_ promise: Promise) {
54+
let storage = HTTPCookieStorage.shared
55+
(storage.cookies ?? []).forEach { storage.deleteCookie($0) }
56+
promise.resolve(nil)
57+
}
58+
59+
/// Calls `SessionCookieStore.restore()` and returns the names of every cookie
60+
/// currently in `HTTPCookieStorage.shared` after the restore.
61+
public func restoreSessionCookies(_ promise: Promise) {
62+
SessionCookieStore.restore()
63+
let names = (HTTPCookieStorage.shared.cookies ?? []).map(\.name)
64+
promise.resolve(names)
65+
}
66+
67+
/// Returns the integer stored in the `_chunkcount` commit-marker keychain item,
68+
/// or `0` if no chunked write exists (single-item or empty).
69+
public func getKeychainChunkCount(_ promise: Promise) {
70+
let bundleId = Bundle.main.bundleIdentifier ?? "com.mendix.app"
71+
let countKey = bundleId + "sessionCookies_chunkcount"
72+
let query: [CFString: Any] = [
73+
kSecClass: kSecClassGenericPassword,
74+
kSecAttrAccount: countKey,
75+
kSecReturnData: true,
76+
]
77+
var ref: CFTypeRef?
78+
let status = SecItemCopyMatching(query as CFDictionary, &ref)
79+
if status == errSecSuccess,
80+
let data = ref as? Data,
81+
let str = String(data: data, encoding: .utf8),
82+
let count = Int(str) {
83+
promise.resolve(NSNumber(value: count))
84+
} else {
85+
promise.resolve(NSNumber(value: 0))
86+
}
87+
}
88+
#endif
1789
}

ios/TurboModules/MxCookie/MxCookie.mm

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,39 @@ - (void)clearAll:(nonnull RCTPromiseResolveBlock)resolve
1919
[[[NativeCookieModule alloc] init] clearAll:promise];
2020
}
2121

22+
#if DEBUG
23+
- (void)seedTestCookies:(double)count valueSize:(double)valueSize
24+
resolve:(nonnull RCTPromiseResolveBlock)resolve
25+
reject:(nonnull RCTPromiseRejectBlock)reject {
26+
Promise *promise = [Promise instance:resolve reject:reject];
27+
[[[NativeCookieModule alloc] init] seedTestCookiesWithCount:(NSInteger)count
28+
valueSize:(NSInteger)valueSize
29+
promise:promise];
30+
}
31+
32+
- (void)persistSessionCookies:(nonnull RCTPromiseResolveBlock)resolve
33+
reject:(nonnull RCTPromiseRejectBlock)reject {
34+
Promise *promise = [Promise instance:resolve reject:reject];
35+
[[[NativeCookieModule alloc] init] persistSessionCookies:promise];
36+
}
37+
38+
- (void)clearHTTPCookies:(nonnull RCTPromiseResolveBlock)resolve
39+
reject:(nonnull RCTPromiseRejectBlock)reject {
40+
Promise *promise = [Promise instance:resolve reject:reject];
41+
[[[NativeCookieModule alloc] init] clearHTTPCookies:promise];
42+
}
43+
44+
- (void)restoreSessionCookies:(nonnull RCTPromiseResolveBlock)resolve
45+
reject:(nonnull RCTPromiseRejectBlock)reject {
46+
Promise *promise = [Promise instance:resolve reject:reject];
47+
[[[NativeCookieModule alloc] init] restoreSessionCookies:promise];
48+
}
49+
50+
- (void)getKeychainChunkCount:(nonnull RCTPromiseResolveBlock)resolve
51+
reject:(nonnull RCTPromiseRejectBlock)reject {
52+
Promise *promise = [Promise instance:resolve reject:reject];
53+
[[[NativeCookieModule alloc] init] getKeychainChunkCount:promise];
54+
}
55+
#endif
56+
2257
@end

src/cookie/NativeMxCookie.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native';
22

33
export interface Spec extends TurboModule {
44
clearAll(): Promise<void>;
5+
// The following methods are only implemented in DEBUG builds.
6+
// They must not be called in production; doing so will throw a TurboModule lookup error.
7+
seedTestCookies(count: number, valueSize: number): Promise<void>;
8+
persistSessionCookies(): Promise<void>;
9+
clearHTTPCookies(): Promise<void>;
10+
restoreSessionCookies(): Promise<string[]>;
11+
getKeychainChunkCount(): Promise<number>;
512
}
613

714
export default TurboModuleRegistry.getEnforcing<Spec>('MxCookie');

src/cookie/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,21 @@ import NativeMxCookie from './NativeMxCookie';
33
export const NativeCookie = {
44
clearAll: NativeMxCookie.clearAll,
55
};
6+
7+
/**
8+
* Test-only helpers — available in DEBUG builds only.
9+
* Never call these in production code; the native implementations are stripped
10+
* from release binaries and calls will throw a TurboModule lookup error.
11+
*/
12+
export const NativeCookieTestHelpers = {
13+
/** Seeds session cookies into HTTPCookieStorage. */
14+
seedTestCookies: NativeMxCookie.seedTestCookies,
15+
/** Persists current session cookies to the keychain. Resolves when the write completes. */
16+
persistSessionCookies: NativeMxCookie.persistSessionCookies,
17+
/** Clears all cookies from HTTPCookieStorage without touching the keychain (simulates an app restart). */
18+
clearHTTPCookies: NativeMxCookie.clearHTTPCookies,
19+
/** Restores cookies from the keychain into HTTPCookieStorage and returns their names. */
20+
restoreSessionCookies: NativeMxCookie.restoreSessionCookies,
21+
/** Returns the _chunkcount commit-marker value (> 1 = chunked write; 0 = single-item or empty). */
22+
getKeychainChunkCount: NativeMxCookie.getKeychainChunkCount,
23+
};

0 commit comments

Comments
 (0)