From a517b0efcf773a2be246fac694878feacaf825e6 Mon Sep 17 00:00:00 2001 From: muhammed-ibrahim Date: Wed, 24 Jun 2026 16:30:49 +0300 Subject: [PATCH 1/7] refactor(core): share reactive credential state across hook instances --- packages/core/src/runtime/auth.spec.ts | 20 ++++++- packages/core/src/runtime/auth.ts | 82 +++++++++++++------------- 2 files changed, 60 insertions(+), 42 deletions(-) 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..d481a9c 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, type ComputedRef, type Ref } from 'vue' export type AuthScheme = 'bearer' | 'apikey' | 'basic' | 'oauth2' @@ -23,56 +23,56 @@ 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>() - 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) - function loadFromStorage(): void { - if (typeof sessionStorage === 'undefined') return - const raw = sessionStorage.getItem(storageKey()) - if (!raw) { - store.value = undefined - return - } - try { - const parsed = JSON.parse(raw) as AuthCredential - store.value = isValidCredential(parsed) ? parsed : undefined - } catch { - sessionStorage.removeItem(storageKey()) - store.value = undefined + if (typeof sessionStorage !== 'undefined') { + const key = `${STORAGE_PREFIX}${name}` + const raw = sessionStorage.getItem(key) + if (raw) { + try { + const parsed = JSON.parse(raw) as AuthCredential + if (isValidCredential(parsed)) { + store.value = parsed + } + } catch { + sessionStorage.removeItem(key) + } + } + + watch(store, (value) => { + try { + if (value === undefined) { + sessionStorage.removeItem(key) + } else { + sessionStorage.setItem(key, JSON.stringify(value)) + } + } catch { + // sessionStorage quota exceeded — credential won't persist but UI still works + } + }) } } + return store +} - onMounted(() => { - loadFromStorage() - }) - - watch(nameRef, () => { - loadFromStorage() - }) - - 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 - } - }) +/** 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)) return { - credential: computed(() => store.value), + credential: computed(() => currentStore.value.value), set(credential) { - store.value = { ...credential } + currentStore.value.value = { ...credential } }, clear() { - store.value = undefined + currentStore.value.value = undefined }, } } From 03a6629b483121f57c08d177f1cfaab69e883ace Mon Sep 17 00:00:00 2001 From: muhammed-ibrahim Date: Wed, 24 Jun 2026 16:30:52 +0300 Subject: [PATCH 2/7] fix(core): persist credential immediately on input in AuthControls --- .../core/src/components/AuthControls.spec.ts | 21 +++++++++++++++++-- packages/core/src/components/AuthControls.vue | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/AuthControls.spec.ts b/packages/core/src/components/AuthControls.spec.ts index 2a39b44..2823b74 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' } }) diff --git a/packages/core/src/components/AuthControls.vue b/packages/core/src/components/AuthControls.vue index 411fca3..411005c 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" /> From 260042ec93690b8ba9508cfc45ac288af5b34a52 Mon Sep 17 00:00:00 2001 From: muhammed-ibrahim Date: Wed, 24 Jun 2026 16:30:59 +0300 Subject: [PATCH 3/7] test(core): add EndpointTryPanel authorization injection specs --- .../src/components/EndpointTryPanel.spec.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 packages/core/src/components/EndpointTryPanel.spec.ts 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') + }) +}) From 98ce7c791f78b15ddf71bd06363557d9345a1bea Mon Sep 17 00:00:00 2001 From: muhammed-ibrahim Date: Sun, 5 Jul 2026 12:01:02 +0300 Subject: [PATCH 4/7] fix(core): resolve auth hydration mismatch by reading storage on mount --- packages/core/src/runtime/auth.ts | 90 +++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/core/src/runtime/auth.ts b/packages/core/src/runtime/auth.ts index d481a9c..ccfdf41 100644 --- a/packages/core/src/runtime/auth.ts +++ b/packages/core/src/runtime/auth.ts @@ -1,4 +1,4 @@ -import { computed, isRef, 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' @@ -24,41 +24,51 @@ export interface AuthState { const STORAGE_PREFIX = 'vod:auth:' export const authStoresCache = new Map>() +const hydratedStores = new Set() + +function storageKey(name: string) { + return `${STORAGE_PREFIX}${name}` +} export function getAuthStore(name: string): Ref { let store = authStoresCache.get(name) if (!store) { store = ref(undefined) authStoresCache.set(name, store) + } + return store +} - if (typeof sessionStorage !== 'undefined') { - const key = `${STORAGE_PREFIX}${name}` - const raw = sessionStorage.getItem(key) - if (raw) { - try { - const parsed = JSON.parse(raw) as AuthCredential - if (isValidCredential(parsed)) { - store.value = parsed - } - } catch { - sessionStorage.removeItem(key) - } - } +function hydrateAuthStore(name: string) { + if (typeof sessionStorage === 'undefined') return + if (hydratedStores.has(name)) return + hydratedStores.add(name) - watch(store, (value) => { - try { - if (value === undefined) { - sessionStorage.removeItem(key) - } else { - sessionStorage.setItem(key, JSON.stringify(value)) - } - } catch { - // sessionStorage quota exceeded — credential won't persist but UI still works - } - }) + const store = getAuthStore(name) + const key = storageKey(name) + const raw = sessionStorage.getItem(key) + if (raw) { + try { + const parsed = JSON.parse(raw) as AuthCredential + if (isValidCredential(parsed)) { + store.value = parsed + } + } catch { + sessionStorage.removeItem(key) } } - return store + + watch(store, (value) => { + try { + if (value === undefined) { + sessionStorage.removeItem(key) + } else { + sessionStorage.setItem(key, JSON.stringify(value)) + } + } catch { + // sessionStorage quota exceeded — credential won't persist but UI still works + } + }) } /** Per-spec credential cache. SSR-safe (reads in onMounted). Accepts a reactive ref or plain string. */ @@ -66,13 +76,39 @@ export function useAuthState(specName: string | Ref | ComputedRef getAuthStore(nameRef.value)) + onMounted(() => { + watch( + nameRef, + (name) => { + hydrateAuthStore(name) + }, + { immediate: true } + ) + }) + return { credential: computed(() => currentStore.value.value), set(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() { currentStore.value.value = undefined + if (typeof sessionStorage !== 'undefined') { + try { + const key = storageKey(nameRef.value) + sessionStorage.removeItem(key) + } catch { + // Ignore storage errors + } + } }, } } @@ -80,7 +116,7 @@ export function useAuthState(specName: string | Ref | ComputedRef Date: Sun, 5 Jul 2026 12:01:21 +0300 Subject: [PATCH 5/7] fix(core): decouple auth persistence from component lifecycle --- packages/core/src/runtime/auth.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/core/src/runtime/auth.ts b/packages/core/src/runtime/auth.ts index ccfdf41..0ba8145 100644 --- a/packages/core/src/runtime/auth.ts +++ b/packages/core/src/runtime/auth.ts @@ -58,17 +58,6 @@ function hydrateAuthStore(name: string) { } } - watch(store, (value) => { - try { - if (value === undefined) { - sessionStorage.removeItem(key) - } else { - sessionStorage.setItem(key, JSON.stringify(value)) - } - } catch { - // sessionStorage quota exceeded — credential won't persist but UI still works - } - }) } /** Per-spec credential cache. SSR-safe (reads in onMounted). Accepts a reactive ref or plain string. */ From 0a5a86172befcb4532f0cff8061d590695511d69 Mon Sep 17 00:00:00 2001 From: muhammed-ibrahim Date: Mon, 6 Jul 2026 14:07:17 +0300 Subject: [PATCH 6/7] test(core): isolate AuthControls clear test from hydratedStores cache --- packages/core/src/components/AuthControls.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/components/AuthControls.spec.ts b/packages/core/src/components/AuthControls.spec.ts index 2823b74..c028a7e 100644 --- a/packages/core/src/components/AuthControls.spec.ts +++ b/packages/core/src/components/AuthControls.spec.ts @@ -43,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('') }) From be4847007c7f441ac77d3781bec2dda5ba6e62cd Mon Sep 17 00:00:00 2001 From: muhammed-ibrahim Date: Mon, 6 Jul 2026 14:31:41 +0300 Subject: [PATCH 7/7] style: fix formatting in packages\core\src\runtime\auth.ts --- packages/core/src/runtime/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/runtime/auth.ts b/packages/core/src/runtime/auth.ts index 0ba8145..f8071a0 100644 --- a/packages/core/src/runtime/auth.ts +++ b/packages/core/src/runtime/auth.ts @@ -57,7 +57,6 @@ function hydrateAuthStore(name: string) { sessionStorage.removeItem(key) } } - } /** Per-spec credential cache. SSR-safe (reads in onMounted). Accepts a reactive ref or plain string. */