diff --git a/docker-compose.test.yml b/docker-compose.test.yml index c76791f1..6a5933bb 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -11,12 +11,7 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9001}:80" - extra_hosts: - - "host.docker.internal:host-gateway" environment: - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}" - # Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。 - # 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或 - # 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。 - API_UPSTREAM: "http://host.docker.internal:9000" - OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300" + CLIENT_WEB_URL: "${CLIENT_WEB_URL:-}" + API_UPSTREAM: "http://test-api-cn.buggoglobal.com" + OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 3ec922ae..69457a86 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -24,7 +24,7 @@ server { client_max_body_size 12m; proxy_pass ${API_UPSTREAM}; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -36,7 +36,7 @@ server { rewrite ^/overseas-api/(.*)$ /api/$1 break; proxy_pass ${OVERSEAS_API_UPSTREAM}; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index 93ddb1dc..eed3a8cc 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -1,5 +1,5 @@ import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ post: vi.fn(), @@ -89,6 +89,8 @@ describe('admin auth session', () => { mocks.refreshHandler = null }) + afterEach(() => vi.unstubAllGlobals()) + it('accepts a domestic-only token response when the test environment has no overseas services', async () => { mocks.post.mockResolvedValue(domesticOnlyLoginResponse()) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) @@ -203,6 +205,32 @@ describe('admin auth session', () => { .toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]') }) + it('reuses a session rotated by another tab without refreshing it again', async () => { + const previousToken = token({ token_version: 1 }) + const rotatedToken = token({ token_version: 2 }) + localStorage.setItem('cc-admin-token', previousToken) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]') + vi.stubGlobal('navigator', { + locks: { + request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise) => { + localStorage.setItem('cc-admin-token', rotatedToken) + localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated') + localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]') + return callback() + }), + }, + }) + const auth = useAuthStore() + + await expect(auth.refreshSession()).resolves.toBeUndefined() + + expect(mocks.post).not.toHaveBeenCalled() + expect(auth.token).toBe(rotatedToken) + expect(auth.refreshToken).toBe('refresh-rotated') + expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list']) + }) + it('does not let an in-flight refresh restore a session after logout', async () => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 3a893a4f..03aa5f7b 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -203,14 +203,9 @@ export const useAuthStore = defineStore('admin-auth', () => { observedRefreshToken, currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '', reuseCurrentSession: async () => { - const storedAccessToken = localStorage.getItem(TOKEN_KEY) || '' - const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '' - if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期') - const response = await http.post, ApiEnvelope>( - '/auth/refresh', - { refreshToken: storedRefreshToken }, - ) - await applySession(unwrapApiResponse(response), expectedGeneration) + applyStoredSession() + ensureCurrentGeneration(expectedGeneration) + if (!token.value || !refreshToken.value) throw new Error('登录状态已过期') }, refresh: async (currentRefreshToken) => { const response = await http.post, ApiEnvelope>( diff --git a/src/core/auth/crossTabRefresh.test.ts b/src/core/auth/crossTabRefresh.test.ts index 2fc9ea5c..5aeee799 100644 --- a/src/core/auth/crossTabRefresh.test.ts +++ b/src/core/auth/crossTabRefresh.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { coordinateCrossTabRefresh } from './crossTabRefresh' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh' afterEach(() => vi.unstubAllGlobals()) @@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => { const reuseCurrentSession = vi.fn(async () => 'access-new') vi.stubGlobal('navigator', { locks: { - request: vi.fn(async (_name: string, callback: () => Promise) => { + request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise) => { currentRefreshToken = 'refresh-new' return callback() }), @@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => { it('performs the refresh when the token has not changed', async () => { const refresh = vi.fn(async () => 'access-new') vi.stubGlobal('navigator', { - locks: { request: vi.fn(async (_name: string, callback: () => Promise) => callback()) }, + locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise) => callback()) }, }) await expect(coordinateCrossTabRefresh({ @@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => { expect(refresh).toHaveBeenCalledWith('refresh-old') }) + + it('aborts a refresh lock that cannot be acquired', async () => { + vi.stubGlobal('navigator', { + locks: { + request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true }) + })), + }, + }) + + await expect(coordinateCrossTabRefresh({ + lockName: 'admin-refresh', + lockTimeoutMs: 5, + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: vi.fn(), + refresh: vi.fn(), + })).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError) + }) }) diff --git a/src/core/auth/crossTabRefresh.ts b/src/core/auth/crossTabRefresh.ts index 6f429ec1..11252f2a 100644 --- a/src/core/auth/crossTabRefresh.ts +++ b/src/core/auth/crossTabRefresh.ts @@ -1,11 +1,21 @@ export interface CrossTabRefreshOptions { lockName: string + lockTimeoutMs?: number observedRefreshToken: string currentRefreshToken: () => string reuseCurrentSession: () => Promise refresh: (refreshToken: string) => Promise } +export class CrossTabRefreshTimeoutError extends Error { + constructor() { + super('登录状态刷新等待超时,请重新登录') + this.name = 'CrossTabRefreshTimeoutError' + } +} + +const DEFAULT_LOCK_TIMEOUT_MS = 20_000 + export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { const run = async () => { const currentRefreshToken = options.currentRefreshToken() @@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptio throw error } } - return navigator.locks.request(options.lockName, run) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) + try { + return await navigator.locks.request(options.lockName, { signal: controller.signal }, run) + } catch (error) { + if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError() + throw error + } finally { + clearTimeout(timeout) + } } diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 1c389c18..ce5339d3 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -36,8 +36,10 @@ describe('admin nginx deployment config', () => { }) it('declares an explicit overseas gateway upstream in container deployments', () => { - expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"') - expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"') + expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"') + expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"') + expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"') + expect(config).toContain('proxy_set_header Host $proxy_host;') }) it('runs each deployment on its target node without controller-local SSH files', () => {