diff --git a/frontend/src/stores/credentials.js b/frontend/src/stores/credentials.js new file mode 100644 index 0000000..33f9274 --- /dev/null +++ b/frontend/src/stores/credentials.js @@ -0,0 +1,55 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '../composables/useApi.js' + +export const useCredentialsStore = defineStore('credentials', () => { + const api = useApi() + + const byPlatform = ref(new Map()) + const extensionKey = ref(null) + const loading = ref(false) + const error = ref(null) + + async function loadAll() { + loading.value = true + error.value = null + try { + const arr = await api.get('/api/credentials') + const m = new Map() + for (const c of arr) m.set(c.platform, c) + byPlatform.value = m + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + + async function upload(platform, credential_type, data) { + const rec = await api.post('/api/credentials', { + body: { platform, credential_type, data }, + }) + byPlatform.value.delete(platform) + return rec + } + + async function remove(platform) { + await api.delete(`/api/credentials/${platform}`) + byPlatform.value.delete(platform) + } + + async function loadKey() { + const body = await api.get('/api/settings/extension_api_key') + extensionKey.value = body.key + } + + async function rotateKey() { + const body = await api.post('/api/settings/extension_api_key/rotate') + extensionKey.value = body.key + } + + return { + byPlatform, extensionKey, loading, error, + loadAll, upload, remove, loadKey, rotateKey, + } +}) diff --git a/frontend/src/stores/platforms.js b/frontend/src/stores/platforms.js new file mode 100644 index 0000000..e996c4b --- /dev/null +++ b/frontend/src/stores/platforms.js @@ -0,0 +1,25 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { useApi } from '../composables/useApi.js' + +export const usePlatformsStore = defineStore('platforms', () => { + const api = useApi() + + const byKey = ref(new Map()) + const loaded = ref(false) + + const list = computed(() => Array.from(byKey.value.values())) + + async function loadAll() { + if (loaded.value) return + const body = await api.get('/api/platforms') + const m = new Map() + for (const [k, v] of Object.entries(body.platforms || {})) { + m.set(k, v) + } + byKey.value = m + loaded.value = true + } + + return { byKey, loaded, list, loadAll } +}) diff --git a/frontend/test/credentials.spec.js b/frontend/test/credentials.spec.js new file mode 100644 index 0000000..8437726 --- /dev/null +++ b/frontend/test/credentials.spec.js @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useCredentialsStore } from '../src/stores/credentials.js' + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)), + } + }) +} + +describe('credentials store', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('loadAll populates byPlatform map', async () => { + const s = useCredentialsStore() + stubFetch(() => ({ + status: 200, + body: [{ platform: 'patreon', credential_type: 'cookies', captured_at: '2026-05-20T00:00:00+00:00', expires_at: null, last_verified: null }], + })) + await s.loadAll() + expect(s.byPlatform.get('patreon').credential_type).toBe('cookies') + }) + + it('upload invalidates the cache', async () => { + const s = useCredentialsStore() + s.byPlatform.set('patreon', { platform: 'patreon' }) + stubFetch(() => ({ status: 201, body: { platform: 'patreon', credential_type: 'cookies' } })) + await s.upload('patreon', 'cookies', 'NETSCAPE_CONTENT') + expect(s.byPlatform.has('patreon')).toBe(false) + }) + + it('remove deletes and invalidates the cache', async () => { + const s = useCredentialsStore() + s.byPlatform.set('patreon', { platform: 'patreon' }) + stubFetch(() => ({ status: 204, body: null })) + await s.remove('patreon') + expect(s.byPlatform.has('patreon')).toBe(false) + }) + + it('loadKey + rotateKey', async () => { + const s = useCredentialsStore() + const seen = [] + stubFetch((url) => { + seen.push(url) + if (url.endsWith('/rotate')) return { status: 200, body: { key: 'NEW' } } + return { status: 200, body: { key: 'OLD' } } + }) + await s.loadKey() + expect(s.extensionKey).toBe('OLD') + await s.rotateKey() + expect(s.extensionKey).toBe('NEW') + }) +}) diff --git a/frontend/test/platforms.spec.js b/frontend/test/platforms.spec.js new file mode 100644 index 0000000..6667673 --- /dev/null +++ b/frontend/test/platforms.spec.js @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { usePlatformsStore } from '../src/stores/platforms.js' + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)), + } + }) +} + +describe('platforms store', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('loadAll fetches /api/platforms and populates list+byKey', async () => { + const s = usePlatformsStore() + stubFetch(() => ({ + status: 200, + body: { platforms: { + patreon: { key: 'patreon', name: 'Patreon', auth_type: 'cookies' }, + discord: { key: 'discord', name: 'Discord', auth_type: 'token' }, + }}, + })) + await s.loadAll() + expect(s.list.map(p => p.key).sort()).toEqual(['discord', 'patreon']) + expect(s.byKey.get('patreon').auth_type).toBe('cookies') + }) + + it('loadAll is cached on subsequent calls', async () => { + const s = usePlatformsStore() + let n = 0 + stubFetch(() => { n++; return { status: 200, body: { platforms: {} } } }) + await s.loadAll() + await s.loadAll() + expect(n).toBe(1) + }) +})