-
Notifications
You must be signed in to change notification settings - Fork 88
cloud/C4: useLicenseStore (zustand) #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type { LicensePayload } from '@/services/luminaCloud'; | ||
|
|
||
| const verifyLicense = vi.hoisted(() => vi.fn()); | ||
| const saveLicense = vi.hoisted(() => vi.fn()); | ||
| const loadLicense = vi.hoisted(() => vi.fn()); | ||
| const removeLicense = vi.hoisted(() => vi.fn()); | ||
|
|
||
| vi.mock('@/services/luminaCloud', async () => { | ||
| const actual = await vi.importActual<typeof import('@/services/luminaCloud')>( | ||
| '@/services/luminaCloud' | ||
| ); | ||
| return { | ||
| ...actual, | ||
| verifyLicense, | ||
| saveLicense, | ||
| loadLicense, | ||
| removeLicense, | ||
| }; | ||
| }); | ||
|
|
||
| import { useLicenseStore } from './useLicenseStore'; | ||
|
|
||
| const VALID_PAYLOAD: LicensePayload = { | ||
| v: 1, | ||
| lid: 'lic_01HXTEST', | ||
| email: 'fixture@example.com', | ||
| sku: 'lumina-lifetime-founders', | ||
| features: ['cloud_ai', 'sync'], | ||
| issued_at: '2026-04-28T12:00:00Z', | ||
| expires_at: null, | ||
| order_id: 'creem_ord_test', | ||
| device_limit: 5, | ||
| }; | ||
|
|
||
| describe('useLicenseStore', () => { | ||
| beforeEach(() => { | ||
| useLicenseStore.setState({ license: null, payload: null, status: 'idle' }); | ||
| verifyLicense.mockReset(); | ||
| saveLicense.mockReset(); | ||
| loadLicense.mockReset(); | ||
| removeLicense.mockReset(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('setLicense', () => { | ||
| it('idle → loading → valid for a verified token', async () => { | ||
| verifyLicense.mockReturnValue(VALID_PAYLOAD); | ||
| saveLicense.mockResolvedValue(undefined); | ||
|
|
||
| const transitions: string[] = []; | ||
| const unsubscribe = useLicenseStore.subscribe((state, prev) => { | ||
| if (state.status !== prev.status) transitions.push(state.status); | ||
| }); | ||
|
|
||
| await useLicenseStore.getState().setLicense('valid-token'); | ||
| unsubscribe(); | ||
|
|
||
| expect(transitions).toEqual(['loading', 'valid']); | ||
| const after = useLicenseStore.getState(); | ||
| expect(after.status).toBe('valid'); | ||
| expect(after.license).toBe('valid-token'); | ||
| expect(after.payload).toEqual(VALID_PAYLOAD); | ||
| expect(saveLicense).toHaveBeenCalledWith('valid-token'); | ||
| }); | ||
|
|
||
| it('idle → loading → invalid for a token that fails verification', async () => { | ||
| verifyLicense.mockReturnValue(null); | ||
|
|
||
| const transitions: string[] = []; | ||
| const unsubscribe = useLicenseStore.subscribe((state, prev) => { | ||
| if (state.status !== prev.status) transitions.push(state.status); | ||
| }); | ||
|
|
||
| await useLicenseStore.getState().setLicense('garbage'); | ||
| unsubscribe(); | ||
|
|
||
| expect(transitions).toEqual(['loading', 'invalid']); | ||
| const after = useLicenseStore.getState(); | ||
| expect(after.status).toBe('invalid'); | ||
| expect(after.license).toBeNull(); | ||
| expect(after.payload).toBeNull(); | ||
| expect(saveLicense).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('keeps in-memory state valid even when keychain save throws', async () => { | ||
| verifyLicense.mockReturnValue(VALID_PAYLOAD); | ||
| saveLicense.mockRejectedValue(new Error('keychain unavailable')); | ||
| const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
| await useLicenseStore.getState().setLicense('valid-token'); | ||
|
|
||
| const after = useLicenseStore.getState(); | ||
| expect(after.status).toBe('valid'); | ||
| expect(after.license).toBe('valid-token'); | ||
| expect(consoleErr).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('clearLicense', () => { | ||
| it('valid → idle and removes from keychain', async () => { | ||
| useLicenseStore.setState({ | ||
| license: 'valid-token', | ||
| payload: VALID_PAYLOAD, | ||
| status: 'valid', | ||
| }); | ||
| removeLicense.mockResolvedValue(undefined); | ||
|
|
||
| await useLicenseStore.getState().clearLicense(); | ||
|
|
||
| const after = useLicenseStore.getState(); | ||
| expect(after.status).toBe('idle'); | ||
| expect(after.license).toBeNull(); | ||
| expect(after.payload).toBeNull(); | ||
| expect(removeLicense).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('still clears in-memory state when keychain remove throws', async () => { | ||
| useLicenseStore.setState({ | ||
| license: 'valid-token', | ||
| payload: VALID_PAYLOAD, | ||
| status: 'valid', | ||
| }); | ||
| removeLicense.mockRejectedValue(new Error('keychain unavailable')); | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
| await useLicenseStore.getState().clearLicense(); | ||
|
|
||
| expect(useLicenseStore.getState().status).toBe('idle'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('refreshFromKeychain', () => { | ||
| it('idle → loading → valid when keychain holds a verifying token', async () => { | ||
| loadLicense.mockResolvedValue('stored-token'); | ||
| verifyLicense.mockReturnValue(VALID_PAYLOAD); | ||
|
|
||
| const transitions: string[] = []; | ||
| const unsubscribe = useLicenseStore.subscribe((state, prev) => { | ||
| if (state.status !== prev.status) transitions.push(state.status); | ||
| }); | ||
|
|
||
| await useLicenseStore.getState().refreshFromKeychain(); | ||
| unsubscribe(); | ||
|
|
||
| expect(transitions).toEqual(['loading', 'valid']); | ||
| expect(useLicenseStore.getState().license).toBe('stored-token'); | ||
| }); | ||
|
|
||
| it('idle → loading → idle when keychain is empty', async () => { | ||
| loadLicense.mockResolvedValue(null); | ||
|
|
||
| const transitions: string[] = []; | ||
| const unsubscribe = useLicenseStore.subscribe((state, prev) => { | ||
| if (state.status !== prev.status) transitions.push(state.status); | ||
| }); | ||
|
|
||
| await useLicenseStore.getState().refreshFromKeychain(); | ||
| unsubscribe(); | ||
|
|
||
| expect(transitions).toEqual(['loading', 'idle']); | ||
| expect(useLicenseStore.getState().license).toBeNull(); | ||
| expect(verifyLicense).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('idle → loading → invalid when stored token no longer verifies', async () => { | ||
| loadLicense.mockResolvedValue('stale-token'); | ||
| verifyLicense.mockReturnValue(null); | ||
|
|
||
| const transitions: string[] = []; | ||
| const unsubscribe = useLicenseStore.subscribe((state, prev) => { | ||
| if (state.status !== prev.status) transitions.push(state.status); | ||
| }); | ||
|
|
||
| await useLicenseStore.getState().refreshFromKeychain(); | ||
| unsubscribe(); | ||
|
|
||
| expect(transitions).toEqual(['loading', 'invalid']); | ||
| expect(useLicenseStore.getState().license).toBeNull(); | ||
| }); | ||
|
|
||
| it('treats a keychain failure like an empty keychain (idle)', async () => { | ||
| loadLicense.mockRejectedValue(new Error('keychain unavailable')); | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
| await useLicenseStore.getState().refreshFromKeychain(); | ||
|
|
||
| expect(useLicenseStore.getState().status).toBe('idle'); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { create } from 'zustand'; | ||
|
|
||
| import { | ||
| loadLicense, | ||
| removeLicense, | ||
| saveLicense, | ||
| verifyLicense, | ||
| } from '@/services/luminaCloud'; | ||
| import type { LicensePayload, LicenseStatus } from '@/services/luminaCloud'; | ||
|
|
||
| interface LicenseStoreState { | ||
| license: string | null; | ||
| payload: LicensePayload | null; | ||
| status: LicenseStatus; | ||
| /** | ||
| * Verify a freshly-pasted license, persist it to the OS keychain on success, | ||
| * and update in-memory state. Persistence failure does not flip status away | ||
| * from `valid` — the in-memory token is still useable for the rest of the | ||
| * session, just not across restarts. | ||
| */ | ||
| setLicense: (token: string) => Promise<void>; | ||
| /** | ||
| * Wipe the in-memory license + payload, then remove from the keychain. | ||
| * `idle` (not `invalid`) — the user explicitly cleared. | ||
| */ | ||
| clearLicense: () => Promise<void>; | ||
| /** | ||
| * Read the keychain on app start. If the stored token still verifies, lift | ||
| * it into memory; otherwise discard. Call once at boot. | ||
| */ | ||
| refreshFromKeychain: () => Promise<void>; | ||
| } | ||
|
|
||
| export const useLicenseStore = create<LicenseStoreState>((set) => ({ | ||
| license: null, | ||
| payload: null, | ||
| status: 'idle', | ||
|
|
||
| async setLicense(token) { | ||
| set({ status: 'loading' }); | ||
| const payload = verifyLicense(token); | ||
| if (!payload) { | ||
| set({ license: null, payload: null, status: 'invalid' }); | ||
| return; | ||
| } | ||
| try { | ||
| await saveLicense(token); | ||
| } catch (err) { | ||
| console.error('[license] saveLicense failed; in-memory only', err); | ||
| } | ||
| set({ license: token, payload, status: 'valid' }); | ||
| }, | ||
|
|
||
| async clearLicense() { | ||
| try { | ||
| await removeLicense(); | ||
| } catch (err) { | ||
| console.error('[license] removeLicense failed', err); | ||
| } | ||
| set({ license: null, payload: null, status: 'idle' }); | ||
|
Comment on lines
+56
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The clear path waits for Useful? React with 👍 / 👎. |
||
| }, | ||
|
|
||
| async refreshFromKeychain() { | ||
| set({ status: 'loading' }); | ||
| let token: string | null = null; | ||
| try { | ||
| token = await loadLicense(); | ||
| } catch (err) { | ||
| console.error('[license] loadLicense failed', err); | ||
| } | ||
| if (!token) { | ||
| set({ license: null, payload: null, status: 'idle' }); | ||
| return; | ||
| } | ||
| const payload = verifyLicense(token); | ||
| if (!payload) { | ||
| set({ license: null, payload: null, status: 'invalid' }); | ||
| return; | ||
| } | ||
| set({ license: token, payload, status: 'valid' }); | ||
| }, | ||
| })); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
setLicensewritesstatus: 'valid'after anawaitwithout checking whether a newer action already changed the store, so overlapping calls can revert newer state. For example, ifsetLicense('old')is waiting onsaveLicenseand the user then runsclearLicense()(or a secondsetLicense), the first call can finish later and restore the old token as valid. This creates incorrect and potentially unsafe state; track request/version IDs (or compare current state) before the finalset.Useful? React with 👍 / 👎.