Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 43 additions & 29 deletions apps/web/src/auth/CoreKitProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { COREKIT_STATUS } from '@web3auth/mpc-core-kit';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { sealedTestStore } from '../test/storeFakes';
import { createCoreKitSession } from './coreKit';
import type { SealedStore } from './sealedStore';
import { CoreKitProvider, useCoreKit } from './CoreKitProvider';

const STORE_KEY = 'corekit_store';
/** Every store shape the SDK's read throws on, since it parses and then indexes. */
const UNREADABLE_STORES: [string, string][] = [
// What an evicted or half-flushed store looks like: the parse itself throws.
// What an evicted or half-flushed write looks like: the parse itself throws.
['a truncated write', '{"sessionId":"a-sessio'],
// Parses cleanly, then the index step throws because `null` has no keys.
['a null literal', 'null'],
Expand All @@ -18,6 +20,12 @@ const LOGGED_IN_STORE = '{"sessionId":"a-fresh-session-id"}';
/** The SDK's own feature check is a bare `fetch`, so a restore fails offline. */
const OFFLINE = new Error('Failed to fetch');

/** The `IAsyncStorage` surface the SDK drives, which is what the seal implements. */
interface AsyncStore {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}

// The SDK reads its store as `JSON.parse(raw || '{}')[key]` (`AsyncStorage.get`,
// which `init` calls for `sessionId`) on both the restore and the login path, so
// one unreadable blob defeats every later login too. The fake reproduces that
Expand All @@ -28,27 +36,28 @@ const sdk = vi.hoisted(() => ({
}));
vi.mock('@web3auth/mpc-core-kit', async (importOriginal) => {
const actual = await importOriginal<typeof import('@web3auth/mpc-core-kit')>();
const readStore = (): unknown => {
const parsed = JSON.parse(window.localStorage.getItem(STORE_KEY) || '{}') as Record<
string,
unknown
>;
return parsed.sessionId;
};
return {
...actual,
Web3AuthMPCCoreKit: class {
readonly _storageKey = STORE_KEY;
private readonly storage: AsyncStore;
constructor(options: { storage: AsyncStore }) {
this.storage = options.storage;
}
get status(): string {
return sdk.status;
}
private async readStore(): Promise<void> {
const raw = (await this.storage.getItem(STORE_KEY)) || '{}';
void (JSON.parse(raw) as Record<string, unknown>).sessionId;
}
async init(): Promise<void> {
readStore();
await this.readStore();
if (sdk.initFailure) throw sdk.initFailure;
}
async loginWithOAuth(): Promise<void> {
readStore();
window.localStorage.setItem(STORE_KEY, LOGGED_IN_STORE);
await this.readStore();
await this.storage.setItem(STORE_KEY, LOGGED_IN_STORE);
sdk.status = actual.COREKIT_STATUS.LOGGED_IN;
}
commitChanges(): Promise<void> {
Expand All @@ -63,26 +72,31 @@ const ENV = {
VITE_WEB3AUTH_VERIFIER: 'verifier',
} satisfies Partial<ImportMetaEnv>;

/** The provider over a real Core Kit session, so the store it owns is the real one. */
function mount() {
return renderHook(() => useCoreKit(), {
wrapper: ({ children }: { children: ReactNode }) => (
<CoreKitProvider createSession={() => createCoreKitSession(ENV)}>{children}</CoreKitProvider>
),
});
}

describe('CoreKitProvider', () => {
let store: SealedStore;

/** The provider over a real Core Kit session, so the store it owns is the real one. */
function mount() {
return renderHook(() => useCoreKit(), {
wrapper: ({ children }: { children: ReactNode }) => (
<CoreKitProvider createSession={() => createCoreKitSession(ENV, store)}>
{children}
</CoreKitProvider>
),
});
}

beforeEach(() => {
sdk.status = COREKIT_STATUS.NOT_INITIALIZED;
sdk.initFailure = undefined;
window.localStorage.clear();
store = sealedTestStore();
});

it.each(UNREADABLE_STORES)(
'discards %s the restore could not read, and does not pass it off as a signed-out tab',
async (_shape, store) => {
window.localStorage.setItem(STORE_KEY, store);
async (_shape, seeded) => {
await store.setItem(STORE_KEY, seeded);
const { result } = mount();

await waitFor(() => expect(result.current.status).toBe('ready'));
Expand All @@ -95,8 +109,8 @@ describe('CoreKitProvider', () => {

it.each(UNREADABLE_STORES)(
'leaves a login after a restore that failed on %s able to establish a session',
async (_shape, store) => {
window.localStorage.setItem(STORE_KEY, store);
async (_shape, seeded) => {
await store.setItem(STORE_KEY, seeded);
const { result } = mount();
await waitFor(() => expect(result.current.status).toBe('ready'));

Expand All @@ -105,29 +119,29 @@ describe('CoreKitProvider', () => {
});

expect(result.current.session?.isLoggedIn()).toBe(true);
expect(window.localStorage.getItem(STORE_KEY)).toBe(LOGGED_IN_STORE);
await expect(store.getItem(STORE_KEY)).resolves.toBe(LOGGED_IN_STORE);
}
);

it('keeps a readable store when the restore failed for some other reason', async () => {
window.localStorage.setItem(STORE_KEY, SIGNED_OUT_STORE);
await store.setItem(STORE_KEY, SIGNED_OUT_STORE);
sdk.initFailure = OFFLINE;
const { result } = mount();

await waitFor(() => expect(result.current.status).toBe('ready'));

expect(result.current.error).toMatch(/could not be restored/);
expect(window.localStorage.getItem(STORE_KEY)).toBe(SIGNED_OUT_STORE);
await expect(store.getItem(STORE_KEY)).resolves.toBe(SIGNED_OUT_STORE);
});

it('keeps a store the restore read cleanly, session in it or not', async () => {
window.localStorage.setItem(STORE_KEY, SIGNED_OUT_STORE);
await store.setItem(STORE_KEY, SIGNED_OUT_STORE);
const { result } = mount();

await waitFor(() => expect(result.current.status).toBe('ready'));

expect(result.current.error).toBeNull();
expect(result.current.session?.isLoggedIn()).toBe(false);
expect(window.localStorage.getItem(STORE_KEY)).toBe(SIGNED_OUT_STORE);
await expect(store.getItem(STORE_KEY)).resolves.toBe(SIGNED_OUT_STORE);
});
});
78 changes: 62 additions & 16 deletions apps/web/src/auth/coreKit.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { COREKIT_STATUS } from '@web3auth/mpc-core-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryKeys, sealedTestStore } from '../test/storeFakes';
import type { SealedStore } from './sealedStore';
import { createCoreKitSession } from './coreKit';

const STORE_KEY = 'corekit_store';

/** What the SDK writes under its one key; nothing here is real key material. */
const SESSION = '{"sessionId":"not-a-real-session-id"}';

// The SDK reaches for the Web3Auth network on construction; the seam is what
// lets the persistence options it is handed be read back, and what drives the
// login and logout outcomes a device can actually land in.
Expand All @@ -13,6 +18,7 @@ const sdk = vi.hoisted(() => ({
statusAfterLogin: 'LOGGED_IN',
logoutError: undefined as Error | undefined,
logoutCalls: 0,
initFailure: undefined as Error | undefined,
}));
vi.mock('@web3auth/mpc-core-kit', async (importOriginal) => {
const actual = await importOriginal<typeof import('@web3auth/mpc-core-kit')>();
Expand All @@ -26,6 +32,9 @@ vi.mock('@web3auth/mpc-core-kit', async (importOriginal) => {
get status(): string {
return sdk.status;
}
init(): Promise<void> {
return sdk.initFailure ? Promise.reject(sdk.initFailure) : Promise.resolve();
}
async loginWithOAuth(): Promise<void> {
sdk.status = sdk.statusAfterLogin;
}
Expand All @@ -48,65 +57,102 @@ const ENV = {
const REFUSED = new Error('the session server is unreachable');

describe('the Core Kit store', () => {
let keys: MemoryKeys;
let store: SealedStore;

/** A session over a store this test can seed and read back. */
const session = () => createCoreKitSession(ENV, store);

beforeEach(() => {
sdk.options = undefined;
sdk.status = COREKIT_STATUS.LOGGED_IN;
sdk.statusAfterLogin = COREKIT_STATUS.LOGGED_IN;
sdk.logoutError = undefined;
sdk.logoutCalls = 0;
sdk.initFailure = undefined;
window.localStorage.clear();
keys = new MemoryKeys();
store = sealedTestStore(keys);
});

it('hands the SDK the sealed store, so nothing it writes lands in the clear', async () => {
session();
await store.setItem(STORE_KEY, SESSION);

expect(sdk.options?.storage).toBe(store);
expect(window.localStorage.getItem(STORE_KEY)).not.toContain('sessionId');
});

it('persists origin-wide, so a tab that did not log in can still be promoted to leader', () => {
createCoreKitSession(ENV);
it('is left standing when a restore failed only because the key store was unreachable', async () => {
// Seeded through a store of its own, so the session's has no key in hand
// and has to reach the one that is about to refuse.
await sealedTestStore(keys).setItem(STORE_KEY, SESSION);
const stored = window.localStorage.getItem(STORE_KEY);
keys.refusal = new Error('the wrapping-key database is shut');
sdk.initFailure = REFUSED;

expect(sdk.options?.storage).toBe(window.localStorage);
await expect(session().restore()).rejects.toThrow(REFUSED);

expect(window.localStorage.getItem(STORE_KEY)).toBe(stored);
expect(keys.held).not.toBeNull();
});

it('is cleared when a restore found something the SDK could not parse', async () => {
const created = session();
await store.setItem(STORE_KEY, '{"sessionId":"a-truncated-writ');
sdk.initFailure = REFUSED;

await expect(created.restore()).rejects.toThrow(REFUSED);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(window.localStorage.getItem(STORE_KEY)).toBeNull();
});

it('caps how long a persisted session stays restorable at eight hours', () => {
createCoreKitSession(ENV);
session();

expect(sdk.options?.sessionTime).toBe(28_800);
});

it('is cleared on logout, which the SDK leaves standing', async () => {
const session = createCoreKitSession(ENV);
window.localStorage.setItem(STORE_KEY, '{"sessionId":"a-session-id"}');
const created = session();
await store.setItem(STORE_KEY, SESSION);

await session.logout();
await created.logout();

expect(sdk.logoutCalls).toBe(1);
expect(window.localStorage.getItem(STORE_KEY)).toBeNull();
expect(keys.held).toBeNull();
});

it('is cleared when the SDK refuses to log out, and the refusal still surfaces', async () => {
const session = createCoreKitSession(ENV);
const created = session();
sdk.logoutError = REFUSED;
window.localStorage.setItem(STORE_KEY, '{"sessionId":"a-session-id"}');
await store.setItem(STORE_KEY, SESSION);

await expect(session.logout()).rejects.toThrow(REFUSED);
await expect(created.logout()).rejects.toThrow(REFUSED);

expect(window.localStorage.getItem(STORE_KEY)).toBeNull();
expect(keys.held).toBeNull();
});

it('is cleared on a sign-out the SDK has no session to end', async () => {
const session = createCoreKitSession(ENV);
const created = session();
sdk.status = COREKIT_STATUS.NOT_INITIALIZED;
window.localStorage.setItem(STORE_KEY, '{"sessionId":"a-session-id"}');
await store.setItem(STORE_KEY, SESSION);

await session.logout();
await created.logout();

expect(sdk.logoutCalls).toBe(0);
expect(window.localStorage.getItem(STORE_KEY)).toBeNull();
});

it('is cleared when a login stops short of a session and the rollback is refused', async () => {
const session = createCoreKitSession(ENV);
const created = session();
sdk.statusAfterLogin = COREKIT_STATUS.REQUIRED_SHARE;
sdk.logoutError = REFUSED;
window.localStorage.setItem(STORE_KEY, '{"sessionId":"a-session-id"}');
await store.setItem(STORE_KEY, SESSION);

await expect(session.login('google')).rejects.toThrow(/needs approval or a recovery phrase/);
await expect(created.login('google')).rejects.toThrow(/needs approval or a recovery phrase/);

expect(sdk.logoutCalls).toBe(1);
expect(window.localStorage.getItem(STORE_KEY)).toBeNull();
Expand Down
Loading