feat: enforce per-user module access in Makelore

This commit is contained in:
2026-08-17 08:51:15 +08:00
parent f7171a471a
commit d16922f18c
12 changed files with 457 additions and 35 deletions

View File

@@ -1,4 +1,4 @@
import { render, waitFor } from '@testing-library/react';
import { 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';
@@ -23,6 +23,10 @@ vi.mock('@/pages/Chat', () => ({
Chat: () => <div>Programming workspace</div>,
}));
vi.mock('@/pages/ModuleSelection', () => ({
ModuleSelection: () => <div>Module chooser</div>,
}));
describe('App programming provider initialization gate', () => {
const initProviders = vi.fn();
@@ -45,6 +49,12 @@ describe('App programming provider initialization gate', () => {
deptId: null,
authorities: [],
},
moduleAccess: {
programming: true,
design: true,
learning: true,
robot: true,
},
init: vi.fn(),
});
useUserSyncStore.setState({ bootstrap: vi.fn().mockResolvedValue(undefined) });
@@ -73,4 +83,28 @@ describe('App programming provider initialization gate', () => {
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
});
it.each([
['/opencode-chat', 'programming'],
['/image-canvas', 'design'],
['/learning', 'learning'],
['/ai-hardware', 'robot'],
] as const)('redirects disabled %s routes before mounting their module', async (pathname, accessKey) => {
useAuthStore.setState({
moduleAccess: {
programming: true,
design: true,
learning: true,
robot: true,
[accessKey]: false,
},
});
await renderAt(pathname);
expect(await screen.findByText('Module chooser')).toBeInTheDocument();
if (accessKey === 'programming') {
expect(initProviders).not.toHaveBeenCalled();
}
});
});

View File

@@ -63,6 +63,88 @@ describe('auth host api routes', () => {
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
});
it('projects the current user module access without exposing the upstream profile', async () => {
storeWorksSquareSession({
accessToken: 'main-access-token',
refreshToken: 'main-refresh-token',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
id: 42,
username: 'student',
one_api_token_key: 'must-not-reach-renderer',
module_access: {
programming: false,
design: true,
learning: false,
robot: true,
},
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/me'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
moduleAccess: {
programming: false,
design: true,
learning: false,
robot: true,
},
});
expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer');
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/auth/me',
{
method: 'GET',
headers: { Authorization: 'Bearer main-access-token' },
},
);
});
it('keeps module access enabled when an older profile omits the policy', async () => {
storeWorksSquareSession({
accessToken: 'main-access-token',
refreshToken: 'main-refresh-token',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ username: 'student' }), { status: 200 }),
));
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(200);
expect(response.json()).toEqual({
success: true,
moduleAccess: {
programming: true,
design: true,
learning: true,
robot: true,
},
});
});
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

@@ -21,6 +21,12 @@ function resetAuthStore() {
canRefresh: false,
legacyRefreshToken: null,
user: null,
moduleAccess: {
programming: true,
design: true,
learning: true,
robot: true,
},
});
}
@@ -60,6 +66,15 @@ describe('auth store', () => {
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockResolvedValueOnce({
success: true,
moduleAccess: {
programming: true,
design: false,
learning: true,
robot: false,
},
});
await useAuthStore.getState().loginWithBrowser();
@@ -81,11 +96,60 @@ describe('auth store', () => {
deptId: 9,
authorities: ['ROLE_USER'],
});
expect(state.moduleAccess).toEqual({
programming: true,
design: false,
learning: true,
robot: false,
});
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
'must-not-return-to-renderer-storage',
);
});
it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
session: {
accessToken: 'persisted-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockResolvedValueOnce({
success: true,
moduleAccess: { design: false },
});
useAuthStore.setState({
authBase: 'https://biz.nianxx.cn/auth/',
accessToken: 'persisted-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
user: {
username: 'zhangsan',
userId: '1',
tenantId: null,
deptId: null,
authorities: [],
},
});
await useAuthStore.getState().init();
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
expect(useAuthStore.getState().moduleAccess).toEqual({
programming: true,
design: false,
learning: true,
robot: true,
});
});
it('surfaces browser authorization failures and does not keep a partial session', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: false,

View File

@@ -113,6 +113,42 @@ describe('AI module navigation', () => {
expect(await screen.findByTestId('location-path')).toHaveTextContent('/ai-hardware');
});
it('greys out a disabled module and does not navigate when it is clicked', async () => {
useAuthStore.setState({
moduleAccess: {
programming: true,
design: true,
learning: false,
robot: true,
},
user: {
username: 'zhangsan@example.com',
userId: 'user-1',
tenantId: null,
deptId: null,
authorities: [],
},
});
render(
<MemoryRouter initialEntries={['/module-select']}>
<Routes>
<Route path="/module-select" element={<ModuleSelection />} />
<Route path="*" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
const learningOption = screen.getByTestId('ai-module-option-learning');
expect(learningOption).toBeDisabled();
expect(learningOption).toHaveClass('module-option-card-disabled');
expect(learningOption).toHaveAttribute('aria-disabled', 'true');
fireEvent.click(learningOption);
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
expect(screen.queryByTestId('location-path')).not.toBeInTheDocument();
});
it('routes AI programming from the chooser to the project conversation entry', async () => {
render(
<MemoryRouter initialEntries={['/module-select']}>