发布当前测试版本至临时灰度环境 #11
+49
-4
@@ -14,6 +14,7 @@ const transport = axios.create({
|
||||
interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||
_retriedAfterRefresh?: boolean
|
||||
_sessionIdentity?: string | null
|
||||
_logoutGeneration?: number
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||
@@ -52,6 +53,8 @@ function mapError(error: unknown) {
|
||||
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
|
||||
}
|
||||
|
||||
let refreshExchange: { identity: string; refreshToken: string; promise: Promise<TokenPair> } | null = null
|
||||
let logoutGeneration = 0
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
let refreshIdentity: string | null = null
|
||||
let redirectingToLogin = false
|
||||
@@ -95,8 +98,10 @@ export async function refreshAuthenticatedSession() {
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken })
|
||||
const tokens = unwrap(response.data)
|
||||
const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) }
|
||||
refreshExchange = exchange
|
||||
let tokens: TokenPair
|
||||
try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null }
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
requireCurrentIdentity(observedIdentity, tokens.accessToken)
|
||||
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
|
||||
@@ -134,29 +139,42 @@ transport.interceptors.request.use((config) => {
|
||||
const request = config as RetryableRequest
|
||||
if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token)
|
||||
if (token && !isPublicAuthRequest(config.url)) {
|
||||
if (!request._retriedAfterRefresh) request._sessionIdentity = accessTokenIdentity(token)
|
||||
if (!request._retriedAfterRefresh) {
|
||||
request._sessionIdentity = accessTokenIdentity(token)
|
||||
request._logoutGeneration = logoutGeneration
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
transport.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
const request = response.config as RetryableRequest
|
||||
if (request._sessionIdentity) {
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const request = error.config as RetryableRequest | undefined
|
||||
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
|
||||
throw mapError(error)
|
||||
}
|
||||
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
request._retriedAfterRefresh = true
|
||||
const refreshTokenBeforeRetry = getRefreshToken()
|
||||
try {
|
||||
const accessToken = await refreshAuthenticatedSession()
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
request.headers.Authorization = `Bearer ${accessToken}`
|
||||
return await transport.request(request)
|
||||
} catch (refreshError) {
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
const mappedError = mapError(refreshError)
|
||||
if (mappedError.status === 401 || mappedError.code === 'A0401') {
|
||||
@@ -170,6 +188,33 @@ transport.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
// Captured revocation never persists credentials or restores local login state.
|
||||
export async function revokeCapturedSession(accessToken: string, refreshToken: string) {
|
||||
logoutGeneration++
|
||||
const identity = accessTokenIdentity(accessToken)
|
||||
if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED')
|
||||
const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null
|
||||
const revoke = (pair: Pick<TokenPair, 'accessToken' | 'refreshToken'>) => apiRequest<void>({
|
||||
method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken },
|
||||
headers: { Authorization: `Bearer ${pair.accessToken}` },
|
||||
})
|
||||
const validate = (pair: TokenPair) => {
|
||||
if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED')
|
||||
return pair
|
||||
}
|
||||
// An already sent refresh can rotate the pair. Consume its actual result
|
||||
// without issuing a competing refresh or restoring it to local storage.
|
||||
if (pending) {
|
||||
await revoke(validate(await pending))
|
||||
return
|
||||
}
|
||||
try { await revoke({ accessToken, refreshToken }) } catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 401) throw error
|
||||
const pair = await apiRequest<TokenPair>({ method: 'POST', url: '/auth/refresh', data: { refreshToken } })
|
||||
await revoke(validate(pair))
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const response = await transport.request<ApiEnvelope<T>>(config)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
accessTokenIdentity,
|
||||
ACCESS_TOKEN_KEY,
|
||||
REFRESH_TOKEN_KEY,
|
||||
REMEMBERED_LOGIN_ID_KEY,
|
||||
@@ -68,3 +69,22 @@ test('normal token rotation never removes either shared token between writes', (
|
||||
assert.ok(!removed.includes(ACCESS_TOKEN_KEY))
|
||||
assert.ok(!removed.includes(REFRESH_TOKEN_KEY))
|
||||
})
|
||||
|
||||
function rawIdentity(overrides = '') {
|
||||
return `test.${Buffer.from(`{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"${overrides}}`).toString('base64url')}.test`
|
||||
}
|
||||
test('identity preserves low digits of numeric 19 digit user customer and membership',()=>{
|
||||
const original=rawIdentity();const json=Buffer.from(original.split('.')[1],'base64url').toString();
|
||||
for(const field of ['user_id','tenant_id','membership_id']){
|
||||
const changed=json.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`)
|
||||
assert.notEqual(accessTokenIdentity(original),accessTokenIdentity(`test.${Buffer.from(changed).toString('base64url')}.test`))
|
||||
}
|
||||
})
|
||||
test('identity string and numeric integers are equivalent without changing other strings',()=>{
|
||||
const a=rawIdentity();const raw=Buffer.from(a.split('.')[1],'base64url').toString();
|
||||
const b=`test.${Buffer.from(raw.replaceAll(':2107003446494334978',':"2107003446494334978"')).toString('base64url')}.test`
|
||||
assert.equal(accessTokenIdentity(a),accessTokenIdentity(b))
|
||||
const decorated=rawIdentity(',"username":'+JSON.stringify('number 2107003446494334979 and "tenant_id":2107003446494334979')+',"note":"1234567890123456789"')
|
||||
assert.equal(accessTokenIdentity(a),accessTokenIdentity(decorated))
|
||||
assert.ok(accessTokenIdentity(a).includes('2107003446494334978'))
|
||||
})
|
||||
|
||||
+10
-1
@@ -27,7 +27,16 @@ export function accessTokenIdentity(token: string): string | null {
|
||||
if (!encoded) return null
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0))
|
||||
const value = JSON.parse(new TextDecoder().decode(bytes))
|
||||
const json = new TextDecoder().decode(bytes)
|
||||
// Match whole JSON strings first so digits inside arbitrary string values
|
||||
// are never rewritten. Preserve integer identity claims before JSON.parse
|
||||
// can round them; number/string representations then share one identity.
|
||||
const exactIdentityJson = json.replace(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (part, offset: number) => {
|
||||
if (!/^-?\d+$/.test(part)) return part
|
||||
const key = json.slice(0, offset).match(/"(user_id|tenant_id|membership_id|operator_tenant_id)"\s*:\s*$/)
|
||||
return key ? JSON.stringify(part) : part
|
||||
})
|
||||
const value = JSON.parse(exactIdentityJson)
|
||||
if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null
|
||||
return JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type].map(item => item == null ? null : String(item)))
|
||||
} catch {
|
||||
|
||||
@@ -100,22 +100,6 @@ test('late refresh response cannot overwrite a session changed in another tab',
|
||||
|
||||
function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
|
||||
|
||||
test('logout after local clear sends only the original refresh token and never refreshes or clears a later login', async () => {
|
||||
const sent = deferred(), release = deferred(), calls = []
|
||||
const http = harness(async config => {
|
||||
calls.push(config); sent.resolve(); await release.promise; throw unauthorized(config)
|
||||
})
|
||||
http.setSession('', '')
|
||||
const api = compile('../modules/auth/api/auth.ts', { '@/api/http': http })
|
||||
const pending = api.logoutMerchant('old-refresh')
|
||||
const rejected = assert.rejects(pending, error => error.code === 'A0401')
|
||||
await sent.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].url, '/auth/logout')
|
||||
assert.equal(calls[0].headers.Authorization, undefined)
|
||||
assert.deepEqual(JSON.parse(calls[0].data), { refreshToken: 'old-refresh' })
|
||||
assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' })
|
||||
})
|
||||
function unauthorized(config) {
|
||||
return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
|
||||
}
|
||||
@@ -173,3 +157,66 @@ test('refresh lock wait reuses same-identity rotation but rejects changed-identi
|
||||
assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' })
|
||||
}
|
||||
})
|
||||
|
||||
function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) }
|
||||
function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } }
|
||||
test('logout after immediate local clear revokes captured Bearer and Refresh', async () => {
|
||||
const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)})
|
||||
http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh')
|
||||
assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`)
|
||||
assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''})
|
||||
})
|
||||
test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => {
|
||||
const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)})
|
||||
http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh')
|
||||
assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''})
|
||||
})
|
||||
test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => {
|
||||
for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)})
|
||||
http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})}
|
||||
})
|
||||
test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{
|
||||
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)})
|
||||
const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise
|
||||
const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected
|
||||
assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''})
|
||||
})
|
||||
test('late successful business response after logout rejects even after same-identity new login',async()=>{
|
||||
const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')})
|
||||
const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise
|
||||
await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh')
|
||||
})
|
||||
|
||||
test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{
|
||||
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)})
|
||||
const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise
|
||||
const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected
|
||||
assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'})
|
||||
})
|
||||
test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{
|
||||
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)})
|
||||
const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving
|
||||
assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'})
|
||||
})
|
||||
test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{
|
||||
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)})
|
||||
const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected
|
||||
assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh')
|
||||
})
|
||||
|
||||
test('same identity new pair logout does not adopt another session inflight refresh',async()=>{
|
||||
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)})
|
||||
const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh')
|
||||
const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving
|
||||
assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''})
|
||||
})
|
||||
|
||||
test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{
|
||||
const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}'
|
||||
const original=`test.${Buffer.from(raw).toString('base64url')}.test`
|
||||
for(const field of ['user_id','tenant_id','membership_id']){
|
||||
const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[]
|
||||
const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)})
|
||||
http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
import { apiRequest, revokeCapturedSession } from '@/api/http'
|
||||
import type { TokenPair } from '@/api/session'
|
||||
|
||||
export interface RegisterMerchantRequest {
|
||||
@@ -35,8 +35,8 @@ export function registerMerchant(payload: RegisterMerchantRequest) {
|
||||
return apiRequest<RegisterMerchantResult>({ method: 'POST', url: '/auth/register', data: payload })
|
||||
}
|
||||
|
||||
export function logoutMerchant(refreshToken: string) {
|
||||
return apiRequest<void>({ method: 'POST', url: '/auth/logout', data: { refreshToken } })
|
||||
export function logoutMerchant(accessToken: string, refreshToken: string) {
|
||||
return revokeCapturedSession(accessToken, refreshToken)
|
||||
}
|
||||
|
||||
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
|
||||
|
||||
@@ -61,6 +61,7 @@ export const useAuthStore = defineStore('client-auth', () => {
|
||||
const permissionCodes = ref<string[]>([])
|
||||
const sessionRestored = ref(false)
|
||||
|
||||
let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise<void> } | undefined
|
||||
let generation = 0
|
||||
let contextRequest: { generation: number; promise: Promise<void> } | undefined
|
||||
let restoreTimer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -199,10 +200,20 @@ export const useAuthStore = defineStore('client-auth', () => {
|
||||
await restoreSession()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
function logout() {
|
||||
const accessToken = getAccessToken()
|
||||
const refreshToken = getRefreshToken()
|
||||
if (logoutRequest && ((!accessToken && !refreshToken)
|
||||
|| (logoutRequest.accessToken === accessToken && logoutRequest.refreshToken === refreshToken))) return logoutRequest.promise
|
||||
const pending = accessToken || refreshToken
|
||||
? logoutMerchant(accessToken, refreshToken)
|
||||
: Promise.resolve()
|
||||
clearSession()
|
||||
if (refreshToken) await logoutMerchant(refreshToken)
|
||||
const request = { accessToken, refreshToken, promise: pending.finally(() => {
|
||||
if (logoutRequest === request) logoutRequest = undefined
|
||||
}) }
|
||||
logoutRequest = request
|
||||
return request.promise
|
||||
}
|
||||
|
||||
function hasPermission(permission?: string) {
|
||||
|
||||
@@ -35,7 +35,7 @@ function harness(t, expired = false) {
|
||||
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
|
||||
const module = compile('./auth.ts', {
|
||||
vue, pinia, '@/api/session': session,
|
||||
'@/modules/auth/api/auth': { logoutMerchant: async refresh => behavior.logout?.(refresh) },
|
||||
'@/modules/auth/api/auth': { logoutMerchant: async (access, refresh) => behavior.logout?.(refresh, access) },
|
||||
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
|
||||
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
|
||||
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
|
||||
@@ -152,3 +152,17 @@ test('store logout clears immediately and late revocation failure preserves a la
|
||||
assert.equal(h.auth.isAuthenticated, true)
|
||||
assert.equal(h.session.getRefreshToken(), 'refresh-b')
|
||||
})
|
||||
|
||||
test('duplicate logout joins captured pair revocation and does not send again',async t=>{
|
||||
const h=harness(t);const remote=deferred();let count=0,sent
|
||||
h.behavior.logout=(refresh,access)=>{count++;sent={refresh,access};return remote.promise}
|
||||
const a=h.auth.logout(),b=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(sent.access,token());assert.equal(sent.refresh,'refresh-a');assert.equal(count,1);remote.resolve();await Promise.all([a,b])
|
||||
})
|
||||
|
||||
test('B logout during A pending revokes B; A finally cannot release B deduplication',async t=>{
|
||||
const h=harness(t);const a=deferred(),b=deferred(),calls=[]
|
||||
h.behavior.logout=(refresh,access)=>{calls.push({refresh,access});return calls.length===1?a.promise:b.promise}
|
||||
const leavingA=h.auth.logout();h.rotate(token(2,2,2));await flush();assert.equal(h.auth.isAuthenticated,true)
|
||||
const leavingB=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(h.session.getAccessToken(),'');assert.equal(calls.length,2);assert.equal(calls[1].access,token(2,2,2))
|
||||
a.resolve();await leavingA;const duplicateB=h.auth.logout();assert.equal(calls.length,2);let duplicateFinished=false;void duplicateB.then(()=>{duplicateFinished=true});await flush();assert.equal(duplicateFinished,false);b.resolve();await Promise.all([leavingB,duplicateB])
|
||||
})
|
||||
|
||||
@@ -102,7 +102,8 @@ test('opens complete service and privacy documents from registration', () => {
|
||||
test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
assert.match(authApiSource, /\/auth\/login/)
|
||||
assert.match(authApiSource, /\/auth\/register/)
|
||||
assert.match(authApiSource, /\/auth\/logout/)
|
||||
assert.match(authApiSource, /revokeCapturedSession\(accessToken, refreshToken\)/)
|
||||
assert.match(httpSource, /\/auth\/logout/)
|
||||
assert.match(httpSource, /refreshPromise/)
|
||||
assert.match(httpSource, /\/auth\/refresh/)
|
||||
assert.match(httpSource, /persistTokenPair\(tokens\)/)
|
||||
|
||||
Reference in New Issue
Block a user