fix: close module access lifecycle gaps
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter, Outlet } from 'react-router-dom';
|
||||
import App from '@/App';
|
||||
@@ -27,6 +27,10 @@ vi.mock('@/pages/ModuleSelection', () => ({
|
||||
ModuleSelection: () => <div>Module chooser</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Settings', () => ({
|
||||
Settings: () => <div>Global settings</div>,
|
||||
}));
|
||||
|
||||
describe('App programming provider initialization gate', () => {
|
||||
const initProviders = vi.fn();
|
||||
|
||||
@@ -84,6 +88,37 @@ describe('App programming provider initialization gate', () => {
|
||||
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('waits for the startup module policy before initializing programming providers', async () => {
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
await renderAt('/opencode-chat');
|
||||
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Module chooser')).toBeInTheDocument();
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/opencode-chat', 'programming'],
|
||||
['/image-canvas', 'design'],
|
||||
@@ -107,4 +142,20 @@ describe('App programming provider initialization gate', () => {
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps global settings available when Programming is disabled', async () => {
|
||||
useAuthStore.setState({
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
await renderAt('/settings');
|
||||
|
||||
expect(await screen.findByText('Global settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Module chooser')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,34 @@ describe('auth host api routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the Main session when the current-user lookup is unauthorized', async () => {
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'expired-access-token',
|
||||
refreshToken: 'expired-refresh-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ detail: 'upstream secret' }), { status: 401 }),
|
||||
));
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/me'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
error: '登录已过期,请重新授权。',
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('upstream secret');
|
||||
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it('exchanges username and AES-encrypted password through the app SSO token endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
|
||||
@@ -150,6 +150,100 @@ describe('auth store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clears restored auth when the current-user policy lookup is unauthorized', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: '登录已过期,请重新授权。',
|
||||
accessToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a new login when its current-user policy lookup is unauthorized', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
username: 'zhangsan',
|
||||
user_id: '1',
|
||||
},
|
||||
session: {
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
|
||||
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
|
||||
'登录已过期,请重新授权。',
|
||||
);
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
accessToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces browser authorization failures and does not keep a partial session', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
@@ -541,6 +635,52 @@ describe('auth store', () => {
|
||||
expect(useAuthStore.getState().user?.username).toBe('zhangsan');
|
||||
});
|
||||
|
||||
it('clears auth when a refreshed session cannot read the current user', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'new-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 120_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'old-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 1_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(useAuthStore.getState().refreshSession()).resolves.toBeNull();
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
error: '登录已过期,请重新授权。',
|
||||
accessToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('restores an expired access token when Main reports it can refresh', async () => {
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
hostApiFetchMock
|
||||
|
||||
Reference in New Issue
Block a user