@startsimpli/auth 0.4.15 → 0.4.17

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.
Files changed (48) hide show
  1. package/README.md +191 -377
  2. package/package.json +25 -12
  3. package/src/__tests__/auth-backend-contract.test.ts +84 -0
  4. package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
  5. package/src/__tests__/auth-functions.test.ts +0 -1
  6. package/src/__tests__/session-user-groups.test.ts +45 -0
  7. package/src/__tests__/useauth-shape-contract.test.ts +0 -1
  8. package/src/client/__tests__/mock-backend.test.ts +141 -0
  9. package/src/client/__tests__/secure-session-storage.test.ts +75 -0
  10. package/src/client/__tests__/secure-token-storage.test.ts +69 -0
  11. package/src/client/__tests__/session-storage.test.ts +118 -0
  12. package/src/client/__tests__/token-auth-core.test.ts +190 -0
  13. package/src/client/auth-client.ts +71 -11
  14. package/src/client/auth-context.tsx +94 -17
  15. package/src/client/backend.ts +67 -0
  16. package/src/client/functions.ts +38 -57
  17. package/src/client/index.ts +15 -0
  18. package/src/client/mock-backend.ts +255 -0
  19. package/src/client/optional-secure-store.ts +21 -0
  20. package/src/client/secure-session-storage.native.ts +53 -0
  21. package/src/client/secure-session-storage.ts +20 -0
  22. package/src/client/secure-token-storage.native.ts +55 -0
  23. package/src/client/secure-token-storage.ts +32 -0
  24. package/src/client/session-storage.ts +142 -0
  25. package/src/client/token-auth-core.ts +190 -0
  26. package/src/client/token.ts +18 -0
  27. package/src/client/use-auth.ts +6 -1
  28. package/src/components/forgot-password-form.tsx +97 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/oauth-callback.tsx +5 -2
  31. package/src/components/reset-password-form.tsx +124 -0
  32. package/src/components/sign-in-form.tsx +125 -0
  33. package/src/components/signup-form.tsx +161 -0
  34. package/src/components/use-oauth-callback.ts +14 -2
  35. package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
  36. package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
  37. package/src/hooks/__tests__/use-membership.test.tsx +136 -0
  38. package/src/hooks/index.ts +34 -0
  39. package/src/hooks/use-domain-claims.ts +144 -0
  40. package/src/hooks/use-invitations.ts +138 -0
  41. package/src/hooks/use-membership.ts +192 -0
  42. package/src/index.ts +43 -1
  43. package/src/server/index.ts +4 -0
  44. package/src/types/index.ts +5 -1
  45. package/src/utils/api-error.ts +54 -0
  46. package/src/utils/central-auth.ts +91 -0
  47. package/src/utils/index.ts +1 -0
  48. package/src/utils/validation.ts +10 -21
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
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",
@@ -19,25 +20,28 @@
19
20
  "publishConfig": {
20
21
  "access": "public"
21
22
  },
22
- "scripts": {
23
- "build": "tsup",
24
- "dev": "tsup --watch",
25
- "type-check": "tsc --noEmit",
26
- "test": "vitest run",
27
- "test:watch": "vitest",
28
- "test:coverage": "vitest run --coverage",
29
- "clean": "rm -rf dist"
30
- },
31
23
  "peerDependencies": {
24
+ "expo-secure-store": ">=12.0.0",
32
25
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
26
  "react": "^18.0.0 || ^19.0.0"
34
27
  },
28
+ "peerDependenciesMeta": {
29
+ "next": {
30
+ "optional": true
31
+ },
32
+ "expo-secure-store": {
33
+ "optional": true
34
+ }
35
+ },
35
36
  "devDependencies": {
37
+ "@testing-library/react": "^16.3.2",
38
+ "@types/jsdom": "^21.1.7",
36
39
  "@types/node": "^20.19.39",
37
40
  "@types/react": "^19.2.14",
38
41
  "@vitest/ui": "^4.1.5",
39
42
  "jsdom": "^29.0.2",
40
- "@types/jsdom": "^21.1.7",
43
+ "react": "^19.2.5",
44
+ "react-dom": "^19.2.5",
41
45
  "tsup": "^8.5.1",
42
46
  "typescript": "^6.0.3",
43
47
  "vitest": "^4.1.5"
@@ -51,5 +55,14 @@
51
55
  ],
52
56
  "dependencies": {
53
57
  "zod": "^4.3.6"
58
+ },
59
+ "scripts": {
60
+ "build": "tsup",
61
+ "dev": "tsup --watch",
62
+ "type-check": "tsc --noEmit",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest",
65
+ "test:coverage": "vitest run --coverage",
66
+ "clean": "rm -rf dist"
54
67
  }
55
- }
68
+ }
@@ -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
+ });
@@ -5,7 +5,7 @@
5
5
  * swap (bead q6vf). They are written to fail until the implementation lands.
6
6
  *
7
7
  * Scope of this file:
8
- * - AuthClient.register({ email, password, passwordConfirm, name? })
8
+ * - AuthClient.register({ email, password, name? })
9
9
  * - AuthClient.signInWithGoogle(redirectTo?)
10
10
  * - AuthClient.completeGoogleCallback(code, state)
11
11
  *
@@ -46,7 +46,7 @@ describe('AuthClient.register (contract)', () => {
46
46
  vi.restoreAllMocks()
47
47
  })
48
48
 
49
- it('POSTs { email, password, password_confirm, name } to /api/v1/auth/register/ and returns a Session', async () => {
49
+ it('POSTs { email, password, name } to /api/v1/auth/register/ and returns a Session', async () => {
50
50
  const client = new AuthClient(makeConfig())
51
51
  const mockFetch = vi.fn()
52
52
  .mockResolvedValueOnce({
@@ -62,7 +62,6 @@ describe('AuthClient.register (contract)', () => {
62
62
  const session = await client.register({
63
63
  email: 'new@example.com',
64
64
  password: 'SecurePass1!',
65
- passwordConfirm: 'SecurePass1!',
66
65
  name: 'New User',
67
66
  })
68
67
 
@@ -74,7 +73,6 @@ describe('AuthClient.register (contract)', () => {
74
73
  expect(body.email).toBe('new@example.com')
75
74
  expect(body.password).toBe('SecurePass1!')
76
75
  // Backend expects snake_case for confirmation field
77
- expect(body.password_confirm).toBe('SecurePass1!')
78
76
  expect(body.name).toBe('New User')
79
77
 
80
78
  // Return shape
@@ -97,7 +95,7 @@ describe('AuthClient.register (contract)', () => {
97
95
  }))
98
96
 
99
97
  await expect(
100
- client.register({ email: 'taken@example.com', password: 'x', passwordConfirm: 'x' })
98
+ client.register({ email: 'taken@example.com', password: 'x' })
101
99
  ).rejects.toThrow(/already exists/i)
102
100
  })
103
101
 
@@ -120,7 +118,6 @@ describe('AuthClient.register (contract)', () => {
120
118
  const session = await client.register({
121
119
  email: 'fetched@example.com',
122
120
  password: 'SecurePass1!',
123
- passwordConfirm: 'SecurePass1!',
124
121
  })
125
122
 
126
123
  expect(session.user.email).toBe('fetched@example.com')
@@ -134,7 +131,7 @@ describe('AuthClient.register (contract)', () => {
134
131
  json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
135
132
  }))
136
133
 
137
- await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
134
+ await client.register({ email: 'new@example.com', password: 'x' })
138
135
 
139
136
  // The refresh timer is a private member — asserting via internal state
140
137
  // is brittle, but the behavior we care about is observable: getSession
@@ -154,7 +151,7 @@ describe('AuthClient.register (contract)', () => {
154
151
  const { getAccessToken, setAccessToken } = await import('../client/functions')
155
152
  setAccessToken(null) // start clean
156
153
 
157
- await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
154
+ await client.register({ email: 'new@example.com', password: 'x' })
158
155
 
159
156
  // @startsimpli/api's FetchWrapper reads via this module-level getter.
160
157
  // Without the mirror, useAuth().session would have a token but API calls
@@ -131,7 +131,6 @@ describe('CSRF not required for signin/register (endpoints are @csrf_exempt)', (
131
131
  await registerAccount({
132
132
  email: 'new@test.com',
133
133
  password: 'securepassword',
134
- passwordConfirm: 'securepassword',
135
134
  });
136
135
 
137
136
  // Should NOT call the CSRF endpoint
@@ -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
+ });
@@ -31,7 +31,6 @@ describe('useAuth() shape contract (target API)', () => {
31
31
  const _: (payload: {
32
32
  email: string
33
33
  password: string
34
- passwordConfirm: string
35
34
  name?: string
36
35
  firstName?: string
37
36
  lastName?: string
@@ -0,0 +1,141 @@
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', 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', async () => {
56
+ const b = createMockAuthBackend({ accounts: seed() });
57
+ await expect(
58
+ b.register({ email: 'a@x.test', password: 'pw' })
59
+ ).rejects.toThrow(/already exists/i);
60
+ });
61
+
62
+ it('persists across a "restart" via shared storage', async () => {
63
+ const storage = createMemorySessionStorage();
64
+ const b1 = createMockAuthBackend({ accounts: seed(), storage });
65
+ await b1.login('a@x.test', 'pw');
66
+
67
+ const b2 = createMockAuthBackend({ accounts: seed(), storage });
68
+ const restored = await b2.restoreSession();
69
+ expect(restored?.user.email).toBe('a@x.test');
70
+ expect(b2.getSession()?.user.email).toBe('a@x.test');
71
+ });
72
+
73
+ it('logout clears the persisted session', async () => {
74
+ const storage = createMemorySessionStorage();
75
+ const b = createMockAuthBackend({ accounts: seed(), storage });
76
+ await b.login('a@x.test', 'pw');
77
+ await b.logout();
78
+ expect(await b.restoreSession()).toBeNull();
79
+ });
80
+
81
+ it('expires the session and fires onSessionExpired', async () => {
82
+ let t = 1000;
83
+ const b = createMockAuthBackend({ accounts: seed(), sessionTtlMs: 100, now: () => t });
84
+ let expired = false;
85
+ b.setOnSessionExpired?.(() => {
86
+ expired = true;
87
+ });
88
+ await b.login('a@x.test', 'pw');
89
+ expect(b.getSession()).not.toBeNull();
90
+ t = 2000;
91
+ expect(b.getSession()).toBeNull();
92
+ expect(expired).toBe(true);
93
+ });
94
+
95
+ it('restoreSession drops an expired persisted session', async () => {
96
+ let t = 1000;
97
+ const storage = createMemorySessionStorage();
98
+ const b1 = createMockAuthBackend({ accounts: seed(), storage, sessionTtlMs: 100, now: () => t });
99
+ await b1.login('a@x.test', 'pw');
100
+ t = 5000;
101
+ const b2 = createMockAuthBackend({ accounts: seed(), storage, sessionTtlMs: 100, now: () => t });
102
+ expect(await b2.restoreSession()).toBeNull();
103
+ });
104
+
105
+ it('password reset flow swaps the password', async () => {
106
+ const b = createMockAuthBackend({ accounts: seed() });
107
+ const { token } = await b.requestPasswordReset('a@x.test');
108
+ await b.resetPassword(token, 'newpw');
109
+ await expect(b.login('a@x.test', 'pw')).rejects.toThrow();
110
+ expect((await b.login('a@x.test', 'newpw')).user.email).toBe('a@x.test');
111
+ });
112
+
113
+ it('rejects reset for unknown email and bogus token', async () => {
114
+ const b = createMockAuthBackend({ accounts: seed() });
115
+ await expect(b.requestPasswordReset('ghost@x.test')).rejects.toThrow(/no account/i);
116
+ await expect(b.resetPassword('bogus', 'x')).rejects.toThrow(/invalid or expired/i);
117
+ });
118
+
119
+ it('email verification flips the flag on the live session', async () => {
120
+ const b = createMockAuthBackend({
121
+ accounts: [{ email: 'a@x.test', password: 'pw', user: user('a@x.test', { isEmailVerified: false }) }],
122
+ });
123
+ await b.login('a@x.test', 'pw');
124
+ expect(b.getSession()?.user.isEmailVerified).toBe(false);
125
+ const { token } = await b.requestEmailVerification('a@x.test');
126
+ await b.verifyEmail(token);
127
+ expect(b.getSession()?.user.isEmailVerified).toBe(true);
128
+ });
129
+
130
+ it('upsertAccount adds a runtime account that can sign in', async () => {
131
+ const b = createMockAuthBackend();
132
+ b.upsertAccount({ email: 'late@x.test', password: 'pw', user: user('late@x.test') });
133
+ expect((await b.login('late@x.test', 'pw')).user.email).toBe('late@x.test');
134
+ });
135
+
136
+ it('oauth methods throw not-supported', async () => {
137
+ const b = createMockAuthBackend();
138
+ await expect(b.signInWithGoogle()).rejects.toThrow(/not supported/i);
139
+ await expect(b.completeGoogleCallback('c', 's')).rejects.toThrow(/not supported/i);
140
+ });
141
+ });
@@ -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
+ });