Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
describe('useAIPermissionsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with empty permissions when no localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.allEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('loads valid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
expect(store.enabled.size).toBe(2)
|
||||
})
|
||||
|
||||
it('filters invalid categories from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'invalid-category', 'system']))
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(2)
|
||||
expect(store.isEnabled('apps')).toBe(true)
|
||||
expect(store.isEnabled('system')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles corrupt localStorage gracefully', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-valid-json{')
|
||||
setActivePinia(createPinia())
|
||||
const store = useAIPermissionsStore()
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('toggle adds a category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggle removes an enabled category', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('bitcoin')
|
||||
store.toggle('bitcoin')
|
||||
expect(store.isEnabled('bitcoin')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggle persists to localStorage', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
|
||||
expect(stored).toContain('apps')
|
||||
})
|
||||
|
||||
it('enableAll enables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
expect(store.allEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(AI_PERMISSION_CATEGORIES.length)
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(store.isEnabled(cat.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('disableAll disables all categories', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.enableAll()
|
||||
store.disableAll()
|
||||
expect(store.noneEnabled).toBe(true)
|
||||
expect(store.enabled.size).toBe(0)
|
||||
})
|
||||
|
||||
it('enabledCategories returns array of enabled IDs', () => {
|
||||
const store = useAIPermissionsStore()
|
||||
store.toggle('apps')
|
||||
store.toggle('network')
|
||||
expect(store.enabledCategories).toContain('apps')
|
||||
expect(store.enabledCategories).toContain('network')
|
||||
expect(store.enabledCategories.length).toBe(2)
|
||||
})
|
||||
|
||||
it('AI_PERMISSION_CATEGORIES has 10 categories', () => {
|
||||
expect(AI_PERMISSION_CATEGORIES.length).toBe(10)
|
||||
})
|
||||
|
||||
it('all categories have required fields', () => {
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
expect(cat.id).toBeTruthy()
|
||||
expect(cat.label).toBeTruthy()
|
||||
expect(cat.description).toBeTruthy()
|
||||
expect(cat.icon).toBeTruthy()
|
||||
expect(cat.group).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
describe('node-side persistence (grants are a property of the node, not a browser)', () => {
|
||||
it('MIGRATES local grants up when the node has none — never silently revokes them', async () => {
|
||||
// Everyone who granted permissions before this change has them only in
|
||||
// localStorage. An empty node must not wipe that on first hydrate.
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['media', 'files']))
|
||||
const store = useAIPermissionsStore()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: [] } as never)
|
||||
|
||||
await store.hydrate()
|
||||
|
||||
expect(store.isEnabled('media')).toBe(true)
|
||||
expect(store.isEnabled('files')).toBe(true)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(
|
||||
([a]) => (a as { method?: string }).method === 'ai.permissions.set',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('adopts the node grants on a browser that has none — the new-device case', async () => {
|
||||
const store = useAIPermissionsStore()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: ['wallet'] } as never)
|
||||
|
||||
await store.hydrate()
|
||||
|
||||
expect(store.isEnabled('wallet')).toBe(true)
|
||||
// and it is cached locally so the next paint is instant
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')).toContain('wallet')
|
||||
})
|
||||
|
||||
it('lets the node REVOKE a grant this browser still remembers', async () => {
|
||||
// The node is authoritative. A revocation made elsewhere must win, or
|
||||
// revoking would be impossible from any second device.
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['media', 'wallet']))
|
||||
const store = useAIPermissionsStore()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: ['media'] } as never)
|
||||
|
||||
await store.hydrate()
|
||||
|
||||
expect(store.isEnabled('media')).toBe(true)
|
||||
expect(store.isEnabled('wallet')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps local grants when the node is unreachable rather than blanking them', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(['media']))
|
||||
const store = useAIPermissionsStore()
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
await store.hydrate()
|
||||
|
||||
expect(store.isEnabled('media')).toBe(true)
|
||||
expect(store.hydrated).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores categories the node reports that this build does not know', async () => {
|
||||
const store = useAIPermissionsStore()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: ['media', 'not-a-category'] } as never)
|
||||
|
||||
await store.hydrate()
|
||||
|
||||
expect(store.isEnabled('media')).toBe(true)
|
||||
expect(store.enabledCategories).not.toContain('not-a-category')
|
||||
})
|
||||
|
||||
it('pushes every toggle to the node', async () => {
|
||||
const store = useAIPermissionsStore()
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ granted: [] } as never)
|
||||
|
||||
store.toggle('media')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(
|
||||
([a]) => (a as { method?: string }).method === 'ai.permissions.set',
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user