修复全量审查问题;接入vitest;精简IdP图标逻辑
CI / test (push) Successful in 46s

This commit is contained in:
2026-07-22 16:51:45 +08:00
parent efcb84eaa8
commit 119f60516c
39 changed files with 1164 additions and 797 deletions
+22 -25
View File
@@ -129,34 +129,31 @@ export function unbindIdentity(id: number): Promise<SessionRefresh> {
// ---- OAuth provider 配置(设置页) ----
const mockOauth: OAuthSettings = {
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
oidcDisplayName: '',
oidcDisabled: false,
githubClientId: '',
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
}
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
oidcDisplayName: '',
oidcDisabled: false,
githubClientId: '',
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
})
if (mockOn) return mocked({ ...mockOauth })
return request('/settings/oauth')
}
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: body.oidcIssuer,
oidcClientId: body.oidcClientId,
oidcSecretSet: !!body.oidcClientSecret,
oidcDisplayName: body.oidcDisplayName,
oidcDisabled: body.oidcDisabled,
githubClientId: body.githubClientId,
githubSecretSet: !!body.githubClientSecret,
githubDisplayName: body.githubDisplayName,
githubDisabled: body.githubDisabled,
})
return request('/settings/oauth', { method: 'PUT', body })
if (mockOn) {
const { oidcClientSecret, githubClientSecret, ...rest } = body
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
return mocked({ ...mockOauth })
}
return request('/settings/oauth', { method: 'PATCH', body })
}
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { request, ApiError } from './request'
import { useAuthStore } from '@/stores/auth'
/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */
function setPageURL(url: string) {
;(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url)
}
/** 构造最小 401 响应;rotate 在响应返回前换发新会话,模拟 OAuth 绑定回跳 */
function stub401(rotateTo?: string) {
vi.stubGlobal('fetch', async () => {
if (rotateTo !== undefined) useAuthStore().setSession(rotateTo, '')
return {
ok: false,
status: 401,
url: '/api/v1/x',
json: async () => ({ error: '未认证' }),
} as unknown as Response
})
}
let assignSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('401 令牌快照', () => {
it('当前 token 收到 401:登出并带 redirect 跳登录', async () => {
setPageURL('http://localhost/instances?tab=list')
useAuthStore().setSession('tokA', '')
stub401()
await expect(request('/x')).rejects.toMatchObject({ status: 401, message: '未认证' })
expect(useAuthStore().token).toBe('')
expect(assignSpy).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent('/instances?tab=list')}`,
)
})
it('响应到达前会话已换新:旧 token 的迟到 401 不得清掉新会话', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
stub401('tokB')
await expect(request('/x')).rejects.toBeInstanceOf(ApiError)
expect(useAuthStore().token).toBe('tokB')
expect(assignSpy).not.toHaveBeenCalled()
})
it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
setPageURL('http://localhost/login')
stub401()
await expect(request('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({
message: '未认证',
})
expect(assignSpy).not.toHaveBeenCalled()
})
})
+19 -4
View File
@@ -32,7 +32,7 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path
}
async function parseError(resp: Response): Promise<never> {
async function parseError(resp: Response, sentToken: string): Promise<never> {
let message = `请求失败(${resp.status})`
let ociCode: string | undefined
try {
@@ -54,7 +54,7 @@ async function parseError(resp: Response): Promise<never> {
} catch {
/* 非 JSON 响应体,保留默认消息 */
}
if (resp.status === 401) useAuthStore().logout()
if (resp.status === 401) handleUnauthorized(sentToken)
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
@@ -62,17 +62,31 @@ async function parseError(resp: Response): Promise<never> {
throw new ApiError(resp.status, message, ociCode)
}
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
function handleUnauthorized(sentToken: string) {
const auth = useAuthStore()
if (sentToken !== auth.token) return
auth.logout()
if (location.pathname !== '/login') {
const redirect = encodeURIComponent(location.pathname + location.search)
location.assign(`/login?redirect=${redirect}`)
}
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp)
if (!resp.ok) await parseError(resp, sentToken)
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
@@ -91,12 +105,13 @@ export async function rawFetch(
const auth = useAuthStore()
const headers: Record<string, string> = { ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body,
})
if (!resp.ok) await parseError(resp)
if (!resp.ok) await parseError(resp, sentToken)
return resp
}
+4 -4
View File
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
return request('/settings/security')
}
/** 保存安全设置,返回最新值;越界或非法后端返回 400,保存后立即生效 */
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
if (mockOn) {
Object.assign(mockSecuritySetting, body)
Object.assign(mockSecuritySetting, patch)
return mocked({ ...mockSecuritySetting }, 400)
}
return request('/settings/security', { method: 'PUT', body })
return request('/settings/security', { method: 'PATCH', body: patch })
}
export interface SystemLogParams {
+2 -5
View File
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
return request(`/tasks/${id}/logs`, { query: { limit } })
}
export function runTask(id: number): Promise<TaskLog> {
if (mockOn) {
const logs = mockTaskLogs[id]
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
}
export function runTask(id: number): Promise<{ triggered: boolean }> {
if (mockOn) return mocked({ triggered: true }, 300)
return request(`/tasks/${id}/run`, { method: 'POST' })
}
+30 -2
View File
@@ -10,7 +10,7 @@ import {
mockUserDetails,
mockUsers,
} from './mock'
import { mockOn, mocked, request } from './request'
import { mockOn, mocked, rawFetch, request } from './request'
import type {
AuditEventsResult,
IamUser,
@@ -18,6 +18,7 @@ import type {
IamUserDetail,
IdentityProvider,
IdentitySetting,
IdpIconUpload,
LimitItem,
LimitService,
NotificationRecipients,
@@ -298,7 +299,7 @@ export interface CreateIdpBody {
name: string
metadata: string
description?: string
/** logo:data URI 或外链 URL */
/** logo:http(s) 外链 URL(可先经 IdP 图标上传接口取得) */
iconUrl?: string
nameIdFormat?: string
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
@@ -325,6 +326,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
}
/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */
export async function uploadIdpIcon(
id: number,
file: File,
domainId?: string,
): Promise<IdpIconUpload> {
if (mockOn)
return mocked({ url: 'https://mock.local/images/idp-icon.png', fileName: 'images/idp-icon.png' }, 600)
const fd = new FormData()
fd.append('file', file, file.name)
const resp = await rawFetch(`/oci-configs/${id}/idp-icons`, {
method: 'POST',
query: { domainId },
body: fd,
})
return (await resp.json()) as IdpIconUpload
}
/** 删除尚未被 IdP 采用的身份域公共图片 */
export function deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/idp-icons`, {
method: 'DELETE',
query: { domainId, fileName },
})
}
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
if (mockOn) return mocked(mockIdps)
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })