diff --git a/packages/core/src/components/AuthControls.spec.ts b/packages/core/src/components/AuthControls.spec.ts index 2a39b44..c028a7e 100644 --- a/packages/core/src/components/AuthControls.spec.ts +++ b/packages/core/src/components/AuthControls.spec.ts @@ -2,10 +2,27 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import AuthControls from './AuthControls.vue' +import { authStoresCache } from '../runtime/auth' describe('AuthControls', () => { - beforeEach(() => sessionStorage.clear()) - afterEach(() => sessionStorage.clear()) + beforeEach(() => { + sessionStorage.clear() + authStoresCache.clear() + }) + afterEach(() => { + sessionStorage.clear() + authStoresCache.clear() + }) + + it('persists the entered credential to sessionStorage immediately on input', async () => { + const wrapper = mount(AuthControls, { props: { specName: 'public', scheme: 'bearer' } }) + const input = wrapper.find('input') + await input.setValue('SECRET_TOKEN') + expect(JSON.parse(sessionStorage.getItem('vod:auth:public')!)).toMatchObject({ + scheme: 'bearer', + value: 'SECRET_TOKEN', + }) + }) it('persists the entered credential to sessionStorage on blur', async () => { const wrapper = mount(AuthControls, { props: { specName: 'public', scheme: 'bearer' } }) @@ -26,11 +43,11 @@ describe('AuthControls', () => { }) it('clears stored credentials when the button is clicked', async () => { - sessionStorage.setItem('vod:auth:public', JSON.stringify({ scheme: 'bearer', value: 'X' })) - const wrapper = mount(AuthControls, { props: { specName: 'public', scheme: 'bearer' } }) + sessionStorage.setItem('vod:auth:clear-test', JSON.stringify({ scheme: 'bearer', value: 'X' })) + const wrapper = mount(AuthControls, { props: { specName: 'clear-test', scheme: 'bearer' } }) await nextTick() await wrapper.find('button.vod-auth__clear').trigger('click') - expect(sessionStorage.getItem('vod:auth:public')).toBeNull() + expect(sessionStorage.getItem('vod:auth:clear-test')).toBeNull() expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') }) diff --git a/packages/core/src/components/AuthControls.vue b/packages/core/src/components/AuthControls.vue index 5f86f07..02dcfae 100644 --- a/packages/core/src/components/AuthControls.vue +++ b/packages/core/src/components/AuthControls.vue @@ -39,6 +39,7 @@ class="vod-auth__input" autocomplete="off" spellcheck="false" + @input="commit" @blur="commit" @keydown.enter.prevent="commit" /> diff --git a/packages/core/src/components/EndpointTryPanel.spec.ts b/packages/core/src/components/EndpointTryPanel.spec.ts new file mode 100644 index 0000000..3347ed5 --- /dev/null +++ b/packages/core/src/components/EndpointTryPanel.spec.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import EndpointTryPanel from './EndpointTryPanel.vue' +import { useAuthState, authStoresCache } from '../runtime/auth' +import type { ParsedOperation } from '../parser/types' +import EndpointPlayground from './EndpointPlayground.vue' + +const dummyOp: ParsedOperation = { + id: 'test.op', + operationId: 'testOp', + kind: 'path', + method: 'get', + path: '/test', + summary: 'Test', + tags: [], + parameters: [], + responses: [], + requestSchemaRefs: {}, + responseSchemaRefs: {}, + defaultServer: '', + security: [], + deprecated: false, +} + +describe('EndpointTryPanel - Authorization Injection', () => { + beforeEach(() => { + sessionStorage.clear() + authStoresCache.clear() + }) + + afterEach(() => { + sessionStorage.clear() + authStoresCache.clear() + }) + + it('injects bearer token correctly', async () => { + const authState = useAuthState('test-spec') + authState.set({ scheme: 'bearer', value: 'my-bearer-token' }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'bearer', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + expect(playground.exists()).toBe(true) + + const envelope = { url: 'http://api.example.com/test', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.init.headers).toEqual({ + Authorization: 'Bearer my-bearer-token', + }) + }) + + it('injects oauth2 token correctly', async () => { + const authState = useAuthState('test-spec') + authState.set({ scheme: 'oauth2', value: 'oauth-token' }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'oauth2', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + const envelope = { url: 'http://api.example.com/test', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.init.headers).toEqual({ + Authorization: 'Bearer oauth-token', + }) + }) + + it('injects basic auth correctly', async () => { + const authState = useAuthState('test-spec') + authState.set({ scheme: 'basic', value: 'user:pass' }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'basic', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + const envelope = { url: 'http://api.example.com/test', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.init.headers).toEqual({ + Authorization: `Basic ${btoa('user:pass')}`, + }) + }) + + it('injects apikey as header correctly', async () => { + const authState = useAuthState('test-spec') + authState.set({ + scheme: 'apikey', + value: 'my-api-key', + headerName: 'X-API-Key', + apiKeyIn: 'header', + }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'apikey', + headerName: 'X-API-Key', + apiKeyIn: 'header', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + const envelope = { url: 'http://api.example.com/test', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.init.headers).toEqual({ + 'X-API-Key': 'my-api-key', + }) + }) + + it('injects apikey as query parameter correctly', async () => { + const authState = useAuthState('test-spec') + authState.set({ + scheme: 'apikey', + value: 'my-api-key', + headerName: 'api_key', + apiKeyIn: 'query', + }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'apikey', + headerName: 'api_key', + apiKeyIn: 'query', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + const envelope = { url: 'http://api.example.com/test', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.url).toBe('http://api.example.com/test?api_key=my-api-key') + }) + + it('injects apikey as query parameter with existing params correctly', async () => { + const authState = useAuthState('test-spec') + authState.set({ + scheme: 'apikey', + value: 'my-api-key', + headerName: 'api_key', + apiKeyIn: 'query', + }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'apikey', + headerName: 'api_key', + apiKeyIn: 'query', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + const envelope = { url: 'http://api.example.com/test?foo=bar', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.url).toBe('http://api.example.com/test?foo=bar&api_key=my-api-key') + }) + + it('does not inject apikey for cookie type in browser requests', async () => { + const authState = useAuthState('test-spec') + authState.set({ + scheme: 'apikey', + value: 'my-cookie-val', + headerName: 'cookie_name', + apiKeyIn: 'cookie', + }) + await nextTick() + + const wrapper = mount(EndpointTryPanel, { + props: { + op: dummyOp, + specName: 'test-spec', + specVersionLabel: 'v1', + serverList: ['http://api.example.com'], + scheme: 'apikey', + headerName: 'cookie_name', + apiKeyIn: 'cookie', + bodyInputs: false, + inline: false, + showSnippets: false, + showAuth: true, + showTry: true, + }, + }) + + const playground = wrapper.findComponent(EndpointPlayground) + const envelope = { url: 'http://api.example.com/test', init: { headers: {} } } + playground.vm.$emit('before-send', envelope) + + expect(envelope.init.headers).toEqual({}) + expect(envelope.url).toBe('http://api.example.com/test') + }) +}) diff --git a/packages/core/src/runtime/auth.spec.ts b/packages/core/src/runtime/auth.spec.ts index 1360fc2..17d1540 100644 --- a/packages/core/src/runtime/auth.spec.ts +++ b/packages/core/src/runtime/auth.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { defineComponent, h, nextTick } from 'vue' import { mount } from '@vue/test-utils' -import { readStoredCredential, useAuthState, type AuthState } from './auth' +import { readStoredCredential, useAuthState, authStoresCache, type AuthState } from './auth' function withAuth(specName: string, callback: (state: AuthState) => void) { const Harness = defineComponent({ @@ -17,10 +17,12 @@ function withAuth(specName: string, callback: (state: AuthState) => void) { describe('useAuthState', () => { beforeEach(() => { sessionStorage.clear() + authStoresCache.clear() }) afterEach(() => { sessionStorage.clear() + authStoresCache.clear() }) it('starts with no credential when storage is empty', async () => { @@ -117,4 +119,20 @@ describe('useAuthState', () => { await nextTick() expect(secondState?.credential.value?.value).toBe('KEEP_ME') }) + + it('shares reactive credential state across multiple active hook instances', async () => { + let state1: AuthState | undefined + let state2: AuthState | undefined + withAuth('shared-spec', (s) => { + state1 = s + }) + withAuth('shared-spec', (s) => { + state2 = s + }) + + state1?.set({ scheme: 'bearer', value: 'UPDATED_TOKEN' }) + await nextTick() + + expect(state2?.credential.value?.value).toBe('UPDATED_TOKEN') + }) }) diff --git a/packages/core/src/runtime/auth.ts b/packages/core/src/runtime/auth.ts index 73ec73d..f8071a0 100644 --- a/packages/core/src/runtime/auth.ts +++ b/packages/core/src/runtime/auth.ts @@ -1,4 +1,4 @@ -import { computed, isRef, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue' +import { computed, isRef, ref, watch, onMounted, type ComputedRef, type Ref } from 'vue' export type AuthScheme = 'bearer' | 'apikey' | 'basic' | 'oauth2' @@ -23,56 +23,80 @@ export interface AuthState { const STORAGE_PREFIX = 'vod:auth:' -/** Per-spec credential cache. SSR-safe (reads in onMounted). Accepts a reactive ref or plain string. */ -export function useAuthState(specName: string | Ref | ComputedRef): AuthState { - const nameRef = isRef(specName) ? specName : ref(specName) - const store = ref(undefined) +export const authStoresCache = new Map>() +const hydratedStores = new Set() + +function storageKey(name: string) { + return `${STORAGE_PREFIX}${name}` +} - function storageKey(): string { - return `${STORAGE_PREFIX}${nameRef.value}` +export function getAuthStore(name: string): Ref { + let store = authStoresCache.get(name) + if (!store) { + store = ref(undefined) + authStoresCache.set(name, store) } + return store +} - function loadFromStorage(): void { - if (typeof sessionStorage === 'undefined') return - const raw = sessionStorage.getItem(storageKey()) - if (!raw) { - store.value = undefined - return - } +function hydrateAuthStore(name: string) { + if (typeof sessionStorage === 'undefined') return + if (hydratedStores.has(name)) return + hydratedStores.add(name) + + const store = getAuthStore(name) + const key = storageKey(name) + const raw = sessionStorage.getItem(key) + if (raw) { try { const parsed = JSON.parse(raw) as AuthCredential - store.value = isValidCredential(parsed) ? parsed : undefined + if (isValidCredential(parsed)) { + store.value = parsed + } } catch { - sessionStorage.removeItem(storageKey()) - store.value = undefined + sessionStorage.removeItem(key) } } +} - onMounted(() => { - loadFromStorage() - }) - - watch(nameRef, () => { - loadFromStorage() - }) +/** Per-spec credential cache. SSR-safe (reads in onMounted). Accepts a reactive ref or plain string. */ +export function useAuthState(specName: string | Ref | ComputedRef): AuthState { + const nameRef = isRef(specName) ? specName : ref(specName) + const currentStore = computed(() => getAuthStore(nameRef.value)) - watch(store, (value) => { - if (typeof sessionStorage === 'undefined') return - try { - if (value === undefined) sessionStorage.removeItem(storageKey()) - else sessionStorage.setItem(storageKey(), JSON.stringify(value)) - } catch { - // sessionStorage quota exceeded — credential won't persist but UI still works - } + onMounted(() => { + watch( + nameRef, + (name) => { + hydrateAuthStore(name) + }, + { immediate: true } + ) }) return { - credential: computed(() => store.value), + credential: computed(() => currentStore.value.value), set(credential) { - store.value = { ...credential } + currentStore.value.value = { ...credential } + if (typeof sessionStorage !== 'undefined') { + try { + const key = storageKey(nameRef.value) + sessionStorage.setItem(key, JSON.stringify(credential)) + } catch { + // sessionStorage quota exceeded — credential won't persist but UI still works + } + } }, clear() { - store.value = undefined + currentStore.value.value = undefined + if (typeof sessionStorage !== 'undefined') { + try { + const key = storageKey(nameRef.value) + sessionStorage.removeItem(key) + } catch { + // Ignore storage errors + } + } }, } } @@ -80,7 +104,7 @@ export function useAuthState(specName: string | Ref | ComputedRef