@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.
- package/README.md +191 -377
- package/package.json +25 -12
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +141 -0
- package/src/client/__tests__/secure-session-storage.test.ts +75 -0
- package/src/client/__tests__/secure-token-storage.test.ts +69 -0
- package/src/client/__tests__/session-storage.test.ts +118 -0
- package/src/client/__tests__/token-auth-core.test.ts +190 -0
- package/src/client/auth-client.ts +71 -11
- package/src/client/auth-context.tsx +94 -17
- package/src/client/backend.ts +67 -0
- package/src/client/functions.ts +38 -57
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +255 -0
- package/src/client/optional-secure-store.ts +21 -0
- package/src/client/secure-session-storage.native.ts +53 -0
- package/src/client/secure-session-storage.ts +20 -0
- package/src/client/secure-token-storage.native.ts +55 -0
- package/src/client/secure-token-storage.ts +32 -0
- package/src/client/session-storage.ts +142 -0
- package/src/client/token-auth-core.ts +190 -0
- package/src/client/token.ts +18 -0
- package/src/client/use-auth.ts +6 -1
- package/src/components/forgot-password-form.tsx +97 -0
- package/src/components/index.ts +5 -1
- package/src/components/oauth-callback.tsx +5 -2
- package/src/components/reset-password-form.tsx +124 -0
- package/src/components/sign-in-form.tsx +125 -0
- package/src/components/signup-form.tsx +161 -0
- package/src/components/use-oauth-callback.ts +14 -2
- package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
- package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
- package/src/hooks/__tests__/use-membership.test.tsx +136 -0
- package/src/hooks/index.ts +34 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +43 -1
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +5 -1
- package/src/utils/api-error.ts +54 -0
- package/src/utils/central-auth.ts +91 -0
- package/src/utils/index.ts +1 -0
- 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.
|
|
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
|
-
"
|
|
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,
|
|
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,
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|