fix: close module access lifecycle gaps

This commit is contained in:
2026-08-17 09:15:37 +08:00
parent d16922f18c
commit ebe55ba17a
8 changed files with 289 additions and 19 deletions

View File

@@ -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();
});
});

View File

@@ -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({

View File

@@ -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