fix(plugins): close R2 workspace gaps

This commit is contained in:
2026-09-03 15:07:29 +08:00
parent c5020ae22c
commit 0bfabc0df2
10 changed files with 405 additions and 15 deletions

View File

@@ -1,7 +1,8 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { Login } from '@/pages/Login';
import { dispatchPluginWorkspaceCommand } from '@/pages/Plugins/plugin-workspace-controller';
import { useAuthStore } from '@/stores/auth';
import { useProviderStore } from '@/stores/providers';
@@ -36,12 +37,63 @@ function resetAuthStore() {
});
}
function renderLogin() {
function LocationProbe() {
const location = useLocation();
return <output data-testid="login-destination">{location.pathname}{location.search}</output>;
}
function PluginSignInAction() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="login-destination">{location.pathname}{location.search}</output>
<button
type="button"
onClick={() => void dispatchPluginWorkspaceCommand({ kind: 'sign_in' }, {
marketplace: {
acquire: async () => undefined,
remove: async () => undefined,
install: async () => undefined,
installBeta: async () => undefined,
update: async () => undefined,
uninstall: async () => undefined,
},
device: {
setEnabled: async () => undefined,
uninstall: async () => undefined,
},
project: { setEnabled: async () => undefined },
loginReturnPath: `${location.pathname}${location.search}`,
navigate,
openSettings: () => undefined,
})}
>
登录后获取插件
</button>
</>
);
}
function renderLogin(initialEntries: React.ComponentProps<typeof MemoryRouter>['initialEntries'] = ['/login']) {
return render(
<MemoryRouter initialEntries={['/login']}>
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
<Route path="/plugins" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
}
function renderPluginLoginJourney(canonicalPath: string) {
return render(
<MemoryRouter initialEntries={[canonicalPath]}>
<Routes>
<Route path="/plugins" element={<PluginSignInAction />} />
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
@@ -192,6 +244,39 @@ describe('Login page', () => {
expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token');
});
it('returns a signed-out plugin action to the exact canonical workspace query after login', async () => {
const canonicalPath = '/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});
renderPluginLoginJourney(canonicalPath);
fireEvent.click(screen.getByRole('button', { name: '登录后获取插件' }));
fireEvent.change(await screen.findByLabelText('用户名'), { target: { value: 'zhangsan' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByTestId('login-destination')).toHaveTextContent(canonicalPath);
});
it.each([
'https://attacker.example/plugins',
'//attacker.example/plugins',
])('rejects unsafe login return %s and keeps the module chooser default', async (unsafeReturn) => {
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});
renderLogin([{ pathname: '/login', state: { from: unsafeReturn } }]);
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('Module Selection')).toBeVisible();
});
it('validates a Chinese mobile number, uses one-time-code autocomplete, and submits only phone and SMS code', async () => {
loginWithMobile.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'mobile-access-token' });