@startsimpli/auth 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./client": "./src/client/index.ts",
10
+ "./token": "./src/client/token.ts",
10
11
  "./components": "./src/components/index.ts",
11
12
  "./server": "./src/server/index.ts",
12
13
  "./types": "./src/types/index.ts",
@@ -30,7 +31,16 @@
30
31
  },
31
32
  "peerDependencies": {
32
33
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
- "react": "^18.0.0 || ^19.0.0"
34
+ "react": "^18.0.0 || ^19.0.0",
35
+ "expo-secure-store": ">=12.0.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "next": {
39
+ "optional": true
40
+ },
41
+ "expo-secure-store": {
42
+ "optional": true
43
+ }
34
44
  },
35
45
  "devDependencies": {
36
46
  "@types/node": "^20.19.39",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * AuthBackend decoupling contract (bead mcr-ios-app-dp4.1).
3
+ *
4
+ * AuthProvider now drives its state from any object satisfying AuthBackend,
5
+ * not just the Django AuthClient. This file pins:
6
+ * 1. AuthClient structurally implements AuthBackend (compile-time).
7
+ * 2. A minimal non-Django object satisfies AuthBackend (compile-time) — this
8
+ * is the shape backendless apps inject.
9
+ * 3. restoreSession() delegates to the cookie bootstrap on AuthClient
10
+ * (behavioral) so renaming the provider's call didn't change semantics.
11
+ *
12
+ * The end-to-end "provider renders off a mock backend" proof lives in the MCR
13
+ * app's Playwright e2e (the actual consumer); this package stays dependency-free.
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import { AuthClient } from '../client/auth-client';
18
+ import type { AuthBackend } from '../client/backend';
19
+ import type { Session } from '../types';
20
+
21
+ // (1) AuthClient must satisfy the interface. The `implements` clause in
22
+ // auth-client.ts enforces this under type-check; this binding documents it.
23
+ const _clientIsBackend: AuthBackend = new AuthClient({ apiBaseUrl: 'http://localhost' });
24
+
25
+ // (2) A minimal, network-free object also satisfies the interface.
26
+ const _minimalBackend: AuthBackend = {
27
+ async login() {
28
+ return null as unknown as Session;
29
+ },
30
+ async logout() {},
31
+ async register() {
32
+ return null as unknown as Session;
33
+ },
34
+ async getCurrentUser() {
35
+ return null as never;
36
+ },
37
+ async getAccessToken() {
38
+ return null;
39
+ },
40
+ getSession() {
41
+ return null;
42
+ },
43
+ setSession() {},
44
+ async restoreSession() {
45
+ return null;
46
+ },
47
+ async signInWithGoogle() {
48
+ return '';
49
+ },
50
+ async completeGoogleCallback() {
51
+ return null as unknown as Session;
52
+ },
53
+ destroy() {},
54
+ };
55
+
56
+ describe('AuthBackend contract', () => {
57
+ beforeEach(() => {
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ it('AuthClient and a minimal object both satisfy AuthBackend', () => {
62
+ expect(_clientIsBackend).toBeInstanceOf(AuthClient);
63
+ expect(typeof _minimalBackend.restoreSession).toBe('function');
64
+ });
65
+
66
+ it('restoreSession() delegates to bootstrapFromCookies()', async () => {
67
+ const client = new AuthClient({ apiBaseUrl: 'http://localhost:8001' });
68
+ const sentinel: Session | null = null;
69
+ const spy = vi
70
+ .spyOn(client, 'bootstrapFromCookies')
71
+ .mockResolvedValue(sentinel);
72
+
73
+ const result = await client.restoreSession();
74
+
75
+ expect(spy).toHaveBeenCalledTimes(1);
76
+ expect(result).toBe(sentinel);
77
+ });
78
+
79
+ it('setOnSessionExpired is optional on the contract', () => {
80
+ // AuthClient routes expiry through AuthConfig.onSessionExpired, so it does
81
+ // not implement the optional method — the provider must null-guard it.
82
+ expect((_clientIsBackend as AuthBackend).setOnSessionExpired).toBeUndefined();
83
+ });
84
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The session AuthUser (types/index.ts) now carries `groups`/`permissions` so
3
+ * a signed-in user from useAuth() works directly with the role helpers — no
4
+ * separate fetch of the functional-API user shape (bead mcr-ios-app-dp4.4).
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { hasGroup, hasPermission } from '../client';
8
+ import type { AuthUser } from '../types';
9
+
10
+ const staff: AuthUser = {
11
+ id: 'u1',
12
+ email: 'staff@x.test',
13
+ firstName: 'MCR',
14
+ lastName: 'Admin',
15
+ isEmailVerified: true,
16
+ createdAt: '',
17
+ updatedAt: '',
18
+ groups: ['mcr-staff'],
19
+ permissions: ['artist:view-all'],
20
+ };
21
+
22
+ describe('session AuthUser role membership', () => {
23
+ it('resolves group membership via hasGroup', () => {
24
+ expect(hasGroup(staff, 'mcr-staff')).toBe(true);
25
+ expect(hasGroup(staff, 'artist')).toBe(false);
26
+ });
27
+
28
+ it('resolves permissions via hasPermission', () => {
29
+ expect(hasPermission(staff, 'artist:view-all')).toBe(true);
30
+ expect(hasPermission(staff, 'nope')).toBe(false);
31
+ });
32
+
33
+ it('treats a user with no groups as a member of nothing', () => {
34
+ const bare: AuthUser = {
35
+ id: 'u2',
36
+ email: 'a@x.test',
37
+ firstName: '',
38
+ lastName: '',
39
+ isEmailVerified: false,
40
+ createdAt: '',
41
+ updatedAt: '',
42
+ };
43
+ expect(hasGroup(bare, 'mcr-staff')).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createMockAuthBackend } from '../mock-backend';
3
+ import { createMemorySessionStorage } from '../session-storage';
4
+ import type { AuthUser } from '../../types';
5
+
6
+ function user(email: string, over: Partial<AuthUser> = {}): AuthUser {
7
+ return {
8
+ id: `u-${email}`,
9
+ email,
10
+ firstName: 'A',
11
+ lastName: 'B',
12
+ isEmailVerified: true,
13
+ createdAt: '',
14
+ updatedAt: '',
15
+ ...over,
16
+ };
17
+ }
18
+
19
+ const seed = (over: Partial<{ password: string; user: AuthUser }> = {}) => [
20
+ { email: 'a@x.test', password: 'pw', user: user('a@x.test'), ...over },
21
+ ];
22
+
23
+ describe('createMockAuthBackend', () => {
24
+ it('logs in with valid credentials', async () => {
25
+ const b = createMockAuthBackend({ accounts: seed() });
26
+ const s = await b.login('a@x.test', 'pw');
27
+ expect(s.user.email).toBe('a@x.test');
28
+ expect(s.accessToken).toMatch(/^mock-access\./);
29
+ expect(b.getSession()?.user.email).toBe('a@x.test');
30
+ expect(await b.getAccessToken()).toBe(s.accessToken);
31
+ });
32
+
33
+ it('rejects wrong password and unknown email identically', async () => {
34
+ const b = createMockAuthBackend({ accounts: seed() });
35
+ await expect(b.login('a@x.test', 'nope')).rejects.toThrow(/invalid email or password/i);
36
+ await expect(b.login('ghost@x.test', 'pw')).rejects.toThrow(/invalid email or password/i);
37
+ });
38
+
39
+ it('matches email case-insensitively', async () => {
40
+ const b = createMockAuthBackend({ accounts: [{ email: 'A@X.test', password: 'pw', user: user('A@X.test') }] });
41
+ const s = await b.login('a@x.TEST', 'pw');
42
+ expect(s.user.email).toBe('A@X.test');
43
+ });
44
+
45
+ it('registers a new account (unverified) and can sign in after', async () => {
46
+ const b = createMockAuthBackend();
47
+ const s = await b.register({ email: 'new@x.test', password: 'pw', passwordConfirm: 'pw', name: 'New Artist' });
48
+ expect(s.user.email).toBe('new@x.test');
49
+ expect(s.user.isEmailVerified).toBe(false);
50
+ expect(s.user.name).toBe('New Artist');
51
+ await b.logout();
52
+ expect((await b.login('new@x.test', 'pw')).user.email).toBe('new@x.test');
53
+ });
54
+
55
+ it('rejects duplicate registration and mismatched passwords', async () => {
56
+ const b = createMockAuthBackend({ accounts: seed() });
57
+ await expect(
58
+ b.register({ email: 'a@x.test', password: 'pw', passwordConfirm: 'pw' })
59
+ ).rejects.toThrow(/already exists/i);
60
+ await expect(
61
+ b.register({ email: 'b@x.test', password: 'pw', passwordConfirm: 'nope' })
62
+ ).rejects.toThrow(/do not match/i);
63
+ });
64
+
65
+ it('persists across a "restart" via shared storage', async () => {
66
+ const storage = createMemorySessionStorage();
67
+ const b1 = createMockAuthBackend({ accounts: seed(), storage });
68
+ await b1.login('a@x.test', 'pw');
69
+
70
+ const b2 = createMockAuthBackend({ accounts: seed(), storage });
71
+ const restored = await b2.restoreSession();
72
+ expect(restored?.user.email).toBe('a@x.test');
73
+ expect(b2.getSession()?.user.email).toBe('a@x.test');
74
+ });
75
+
76
+ it('logout clears the persisted session', async () => {
77
+ const storage = createMemorySessionStorage();
78
+ const b = createMockAuthBackend({ accounts: seed(), storage });
79
+ await b.login('a@x.test', 'pw');
80
+ await b.logout();
81
+ expect(await b.restoreSession()).toBeNull();
82
+ });
83
+
84
+ it('expires the session and fires onSessionExpired', async () => {
85
+ let t = 1000;
86
+ const b = createMockAuthBackend({ accounts: seed(), sessionTtlMs: 100, now: () => t });
87
+ let expired = false;
88
+ b.setOnSessionExpired?.(() => {
89
+ expired = true;
90
+ });
91
+ await b.login('a@x.test', 'pw');
92
+ expect(b.getSession()).not.toBeNull();
93
+ t = 2000;
94
+ expect(b.getSession()).toBeNull();
95
+ expect(expired).toBe(true);
96
+ });
97
+
98
+ it('restoreSession drops an expired persisted session', async () => {
99
+ let t = 1000;
100
+ const storage = createMemorySessionStorage();
101
+ const b1 = createMockAuthBackend({ accounts: seed(), storage, sessionTtlMs: 100, now: () => t });
102
+ await b1.login('a@x.test', 'pw');
103
+ t = 5000;
104
+ const b2 = createMockAuthBackend({ accounts: seed(), storage, sessionTtlMs: 100, now: () => t });
105
+ expect(await b2.restoreSession()).toBeNull();
106
+ });
107
+
108
+ it('password reset flow swaps the password', async () => {
109
+ const b = createMockAuthBackend({ accounts: seed() });
110
+ const { token } = await b.requestPasswordReset('a@x.test');
111
+ await b.resetPassword(token, 'newpw');
112
+ await expect(b.login('a@x.test', 'pw')).rejects.toThrow();
113
+ expect((await b.login('a@x.test', 'newpw')).user.email).toBe('a@x.test');
114
+ });
115
+
116
+ it('rejects reset for unknown email and bogus token', async () => {
117
+ const b = createMockAuthBackend({ accounts: seed() });
118
+ await expect(b.requestPasswordReset('ghost@x.test')).rejects.toThrow(/no account/i);
119
+ await expect(b.resetPassword('bogus', 'x')).rejects.toThrow(/invalid or expired/i);
120
+ });
121
+
122
+ it('email verification flips the flag on the live session', async () => {
123
+ const b = createMockAuthBackend({
124
+ accounts: [{ email: 'a@x.test', password: 'pw', user: user('a@x.test', { isEmailVerified: false }) }],
125
+ });
126
+ await b.login('a@x.test', 'pw');
127
+ expect(b.getSession()?.user.isEmailVerified).toBe(false);
128
+ const { token } = await b.requestEmailVerification('a@x.test');
129
+ await b.verifyEmail(token);
130
+ expect(b.getSession()?.user.isEmailVerified).toBe(true);
131
+ });
132
+
133
+ it('upsertAccount adds a runtime account that can sign in', async () => {
134
+ const b = createMockAuthBackend();
135
+ b.upsertAccount({ email: 'late@x.test', password: 'pw', user: user('late@x.test') });
136
+ expect((await b.login('late@x.test', 'pw')).user.email).toBe('late@x.test');
137
+ });
138
+
139
+ it('oauth methods throw not-supported', async () => {
140
+ const b = createMockAuthBackend();
141
+ await expect(b.signInWithGoogle()).rejects.toThrow(/not supported/i);
142
+ await expect(b.completeGoogleCallback('c', 's')).rejects.toThrow(/not supported/i);
143
+ });
144
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ // expo-secure-store is loaded through the guarded loader; mock that seam so the
4
+ // test controls whether the (optional) native module is "present".
5
+ const store = vi.hoisted(() => new Map<string, string>());
6
+ const mocked = vi.hoisted(() => ({
7
+ getItemAsync: vi.fn(async (k: string) => (store.has(k) ? store.get(k)! : null)),
8
+ setItemAsync: vi.fn(async (k: string, v: string) => void store.set(k, v)),
9
+ deleteItemAsync: vi.fn(async (k: string) => void store.delete(k)),
10
+ }));
11
+ const loadSecureStore = vi.hoisted(() => vi.fn());
12
+ vi.mock('../optional-secure-store', () => ({ loadSecureStore }));
13
+
14
+ import { createSecureSessionStorage } from '../secure-session-storage.native';
15
+ import { SESSION_STORAGE_KEY } from '../session-storage';
16
+ import type { Session } from '../../types';
17
+
18
+ const session: Session = {
19
+ user: {
20
+ id: 'u1',
21
+ email: 'a@x.test',
22
+ firstName: 'A',
23
+ lastName: 'B',
24
+ isEmailVerified: true,
25
+ createdAt: '',
26
+ updatedAt: '',
27
+ },
28
+ accessToken: 'tok',
29
+ expiresAt: Date.now() + 1e6,
30
+ };
31
+
32
+ describe('createSecureSessionStorage (native module present)', () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ store.clear();
36
+ loadSecureStore.mockReturnValue(mocked);
37
+ });
38
+
39
+ it('round-trips a session through SecureStore under the default key', async () => {
40
+ const s = createSecureSessionStorage();
41
+ await s.save(session);
42
+ expect(mocked.setItemAsync).toHaveBeenCalledWith(
43
+ SESSION_STORAGE_KEY,
44
+ expect.stringContaining('a@x.test')
45
+ );
46
+ const loaded = await s.load();
47
+ expect(loaded?.user.email).toBe('a@x.test');
48
+ });
49
+
50
+ it('returns null when nothing is stored', async () => {
51
+ const s = createSecureSessionStorage('empty.key');
52
+ expect(await s.load()).toBeNull();
53
+ });
54
+
55
+ it('clears by deleting the key', async () => {
56
+ const s = createSecureSessionStorage();
57
+ await s.clear();
58
+ expect(mocked.deleteItemAsync).toHaveBeenCalledWith(SESSION_STORAGE_KEY);
59
+ });
60
+ });
61
+
62
+ describe('createSecureSessionStorage (native module absent — graceful fallback)', () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ loadSecureStore.mockReturnValue(null);
66
+ });
67
+
68
+ it('falls back to in-memory and never touches SecureStore', async () => {
69
+ const s = createSecureSessionStorage();
70
+ expect(await s.load()).toBeNull();
71
+ await s.save(session);
72
+ expect((await s.load())?.user.email).toBe('a@x.test'); // in-memory round-trip
73
+ expect(mocked.setItemAsync).not.toHaveBeenCalled();
74
+ });
75
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ // expo-secure-store is loaded through the guarded loader; mock that seam so the
4
+ // test controls whether the (optional) native module is "present".
5
+ const mocked = vi.hoisted(() => ({
6
+ getItemAsync: vi.fn(),
7
+ setItemAsync: vi.fn(),
8
+ deleteItemAsync: vi.fn(),
9
+ }));
10
+ const loadSecureStore = vi.hoisted(() => vi.fn());
11
+ vi.mock('../optional-secure-store', () => ({ loadSecureStore }));
12
+
13
+ import { SecureTokenStorage, REFRESH_TOKEN_KEY } from '../secure-token-storage.native';
14
+
15
+ describe('SecureTokenStorage (native module present)', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ loadSecureStore.mockReturnValue(mocked);
19
+ });
20
+
21
+ it('reads the refresh token from SecureStore under a stable key', async () => {
22
+ mocked.getItemAsync.mockResolvedValueOnce('rt');
23
+ const storage = new SecureTokenStorage();
24
+ expect(await storage.getRefreshToken()).toBe('rt');
25
+ expect(mocked.getItemAsync).toHaveBeenCalledWith(REFRESH_TOKEN_KEY);
26
+ });
27
+
28
+ it('returns null when nothing is stored', async () => {
29
+ mocked.getItemAsync.mockResolvedValueOnce(null);
30
+ const storage = new SecureTokenStorage();
31
+ expect(await storage.getRefreshToken()).toBeNull();
32
+ });
33
+
34
+ it('writes the refresh token under the same key', async () => {
35
+ const storage = new SecureTokenStorage();
36
+ await storage.setRefreshToken('rt2');
37
+ expect(mocked.setItemAsync).toHaveBeenCalledWith(REFRESH_TOKEN_KEY, 'rt2');
38
+ });
39
+
40
+ it('clears by deleting the key', async () => {
41
+ const storage = new SecureTokenStorage();
42
+ await storage.clear();
43
+ expect(mocked.deleteItemAsync).toHaveBeenCalledWith(REFRESH_TOKEN_KEY);
44
+ });
45
+
46
+ it('honors a custom key', async () => {
47
+ const storage = new SecureTokenStorage('custom.refresh');
48
+ await storage.setRefreshToken('x');
49
+ expect(mocked.setItemAsync).toHaveBeenCalledWith('custom.refresh', 'x');
50
+ });
51
+ });
52
+
53
+ describe('SecureTokenStorage (native module absent — graceful fallback)', () => {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ loadSecureStore.mockReturnValue(null);
57
+ });
58
+
59
+ it('falls back to in-memory and never touches SecureStore', async () => {
60
+ const storage = new SecureTokenStorage();
61
+ expect(await storage.getRefreshToken()).toBeNull();
62
+ await storage.setRefreshToken('rt');
63
+ expect(await storage.getRefreshToken()).toBe('rt');
64
+ await storage.clear();
65
+ expect(await storage.getRefreshToken()).toBeNull();
66
+ expect(mocked.getItemAsync).not.toHaveBeenCalled();
67
+ expect(mocked.setItemAsync).not.toHaveBeenCalled();
68
+ });
69
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ createMemorySessionStorage,
4
+ createWebSessionStorage,
5
+ createRememberAwareSessionStorage,
6
+ SESSION_STORAGE_KEY,
7
+ } from '../session-storage';
8
+ import type { Session } from '../../types';
9
+
10
+ const session: Session = {
11
+ user: {
12
+ id: 'u1',
13
+ email: 'a@x.test',
14
+ firstName: 'A',
15
+ lastName: 'B',
16
+ isEmailVerified: true,
17
+ createdAt: '',
18
+ updatedAt: '',
19
+ },
20
+ accessToken: 'tok',
21
+ expiresAt: Date.now() + 1e6,
22
+ };
23
+
24
+ describe('createMemorySessionStorage', () => {
25
+ it('round-trips and clears', async () => {
26
+ const s = createMemorySessionStorage();
27
+ expect(await s.load()).toBeNull();
28
+ await s.save(session);
29
+ expect((await s.load())?.user.email).toBe('a@x.test');
30
+ await s.clear();
31
+ expect(await s.load()).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe('createWebSessionStorage', () => {
36
+ beforeEach(() => localStorage.clear());
37
+
38
+ it('persists to localStorage and reads it back', async () => {
39
+ const s = createWebSessionStorage();
40
+ await s.save(session);
41
+ expect(localStorage.getItem(SESSION_STORAGE_KEY)).toBeTruthy();
42
+ expect((await s.load())?.accessToken).toBe('tok');
43
+ });
44
+
45
+ it('honors a custom key and an injected Storage', async () => {
46
+ const backing = createMemoryStorage();
47
+ const s = createWebSessionStorage({ key: 'k2', storage: backing });
48
+ await s.save(session);
49
+ expect(backing.getItem('k2')).toBeTruthy();
50
+ expect((await s.load())?.user.id).toBe('u1');
51
+ await s.clear();
52
+ expect(backing.getItem('k2')).toBeNull();
53
+ });
54
+
55
+ it('returns null on malformed JSON rather than throwing', async () => {
56
+ localStorage.setItem(SESSION_STORAGE_KEY, '{not json');
57
+ const s = createWebSessionStorage();
58
+ expect(await s.load()).toBeNull();
59
+ });
60
+
61
+ it('returns null when the stored value is not a Session shape', async () => {
62
+ localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ nope: true }));
63
+ const s = createWebSessionStorage();
64
+ expect(await s.load()).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe('createRememberAwareSessionStorage', () => {
69
+ it('persists to the persistent store when remembering', async () => {
70
+ const persistent = createMemorySessionStorage();
71
+ const s = createRememberAwareSessionStorage(persistent, () => true);
72
+ await s.save(session);
73
+ expect((await persistent.load())?.accessToken).toBe('tok');
74
+ expect((await s.load())?.accessToken).toBe('tok');
75
+ });
76
+
77
+ it('does NOT persist (and clears) when not remembering', async () => {
78
+ const persistent = createMemorySessionStorage();
79
+ const s = createRememberAwareSessionStorage(persistent, () => false);
80
+ await s.save(session);
81
+ expect(await persistent.load()).toBeNull();
82
+ expect(await s.load()).toBeNull();
83
+ });
84
+
85
+ it('clears the persistent store when switching from remember to not', async () => {
86
+ const persistent = createMemorySessionStorage();
87
+ let remember = true;
88
+ const s = createRememberAwareSessionStorage(persistent, () => remember);
89
+ await s.save(session);
90
+ expect(await s.load()).not.toBeNull();
91
+ remember = false;
92
+ await s.save(session);
93
+ expect(await persistent.load()).toBeNull();
94
+ });
95
+
96
+ it('clear() wipes both stores', async () => {
97
+ const persistent = createMemorySessionStorage();
98
+ const s = createRememberAwareSessionStorage(persistent, () => true);
99
+ await s.save(session);
100
+ await s.clear();
101
+ expect(await s.load()).toBeNull();
102
+ });
103
+ });
104
+
105
+ // Minimal Storage impl for the injected-storage test.
106
+ function createMemoryStorage(): Storage {
107
+ const map = new Map<string, string>();
108
+ return {
109
+ get length() {
110
+ return map.size;
111
+ },
112
+ clear: () => map.clear(),
113
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
114
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
115
+ removeItem: (k: string) => void map.delete(k),
116
+ setItem: (k: string, v: string) => void map.set(k, String(v)),
117
+ } as Storage;
118
+ }