实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。 实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
This commit is contained in:
@@ -15,9 +15,11 @@ function resetAuthStore() {
|
||||
authBase: '',
|
||||
clientId: 'app',
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenType: null,
|
||||
expiresAt: null,
|
||||
lastActiveAt: null,
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
});
|
||||
}
|
||||
@@ -41,7 +43,7 @@ describe('auth store', () => {
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
refresh_token: 'must-not-return-to-renderer-storage',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 60,
|
||||
username: 'zhangsan',
|
||||
@@ -51,8 +53,14 @@ describe('auth store', () => {
|
||||
authorities: ['ROLE_USER'],
|
||||
client_id: 'app',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
session: {
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().loginWithBrowser();
|
||||
|
||||
@@ -63,7 +71,8 @@ describe('auth store', () => {
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated()).toBe(true);
|
||||
expect(state.accessToken).toBe('access-token');
|
||||
expect(state.refreshToken).toBe('refresh-token');
|
||||
expect(state.canRefresh).toBe(true);
|
||||
expect(state.legacyRefreshToken).toBeNull();
|
||||
expect(state.expiresAt).toBe(Date.now() + 60_000);
|
||||
expect(state.user).toEqual({
|
||||
username: 'zhangsan',
|
||||
@@ -72,6 +81,9 @@ describe('auth store', () => {
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
});
|
||||
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
|
||||
'must-not-return-to-renderer-storage',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces browser authorization failures and does not keep a partial session', async () => {
|
||||
@@ -91,7 +103,7 @@ describe('auth store', () => {
|
||||
expect(state.isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears a persisted session when the configured SSO gateway changes', () => {
|
||||
it('clears a persisted session when the configured SSO gateway changes', async () => {
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
@@ -99,9 +111,10 @@ describe('auth store', () => {
|
||||
authBase: 'https://onefeel.brother7.cn/ingress/auth',
|
||||
clientId: 'app',
|
||||
accessToken: 'old-environment-token',
|
||||
refreshToken: 'old-environment-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'old-environment-refresh-token',
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
@@ -111,17 +124,29 @@ describe('auth store', () => {
|
||||
},
|
||||
});
|
||||
|
||||
useAuthStore.getState().init();
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.authBase).toBe('https://biz.nianxx.cn/auth/');
|
||||
expect(state.accessToken).toBeNull();
|
||||
expect(state.refreshToken).toBeNull();
|
||||
expect(state.canRefresh).toBe(false);
|
||||
expect(state.legacyRefreshToken).toBeNull();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('syncs a valid persisted session to Main during init', () => {
|
||||
it('migrates a legacy persisted refresh token to Main during init', async () => {
|
||||
const lastActiveAt = Date.now() - 60_000;
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
@@ -129,9 +154,11 @@ describe('auth store', () => {
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'persisted-access-token',
|
||||
refreshToken: 'persisted-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'persisted-refresh-token',
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
@@ -141,7 +168,7 @@ describe('auth store', () => {
|
||||
},
|
||||
});
|
||||
|
||||
useAuthStore.getState().init();
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', {
|
||||
method: 'POST',
|
||||
@@ -150,12 +177,18 @@ describe('auth store', () => {
|
||||
refreshToken: 'persisted-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt,
|
||||
}),
|
||||
});
|
||||
expect(useAuthStore.getState().legacyRefreshToken).toBeNull();
|
||||
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
||||
state?: Record<string, unknown>;
|
||||
};
|
||||
expect(persisted.state?.legacyRefreshToken).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for Main session sync before marking persisted auth initialized', async () => {
|
||||
let resolveSync!: (value: { success: boolean }) => void;
|
||||
let resolveSync!: (value: unknown) => void;
|
||||
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveSync = resolve;
|
||||
}));
|
||||
@@ -166,9 +199,10 @@ describe('auth store', () => {
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'persisted-access-token',
|
||||
refreshToken: 'persisted-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
@@ -182,13 +216,22 @@ describe('auth store', () => {
|
||||
|
||||
expect(useAuthStore.getState().initialized).toBe(false);
|
||||
|
||||
resolveSync({ success: true });
|
||||
resolveSync({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
await initialization;
|
||||
|
||||
expect(useAuthStore.getState().initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('clears persisted renderer auth when Main session sync fails', async () => {
|
||||
it('retains persisted renderer auth when Main session sync is temporarily unavailable', async () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(new Error('Host API unavailable'));
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
@@ -197,9 +240,10 @@ describe('auth store', () => {
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'persisted-access-token',
|
||||
refreshToken: 'persisted-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'persisted-refresh-token',
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
@@ -213,25 +257,196 @@ describe('auth store', () => {
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
accessToken: 'persisted-access-token',
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'persisted-refresh-token',
|
||||
user: { username: 'zhangsan' },
|
||||
error: null,
|
||||
});
|
||||
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
||||
state?: Record<string, unknown>;
|
||||
};
|
||||
expect(persisted.state?.legacyRefreshToken).toBe('persisted-refresh-token');
|
||||
});
|
||||
|
||||
it('does not let a delayed init revive a session after logout', async () => {
|
||||
let resolveSync!: (value: unknown) => void;
|
||||
hostApiFetchMock
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveSync = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'persisted-refresh-token',
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: 7,
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
|
||||
const initialization = useAuthStore.getState().init();
|
||||
await useAuthStore.getState().logout();
|
||||
resolveSync({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'late-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
await initialization;
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
error: '登录状态恢复失败,请重新登录。',
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs a refreshed session to Main', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 120,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
it('does not let delayed browser login revive a Main-terminal session', async () => {
|
||||
let resolveLogin!: (value: unknown) => void;
|
||||
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}));
|
||||
|
||||
const login = useAuthStore.getState().loginWithBrowser();
|
||||
useAuthStore.getState().applyMainSession(null);
|
||||
resolveLogin({
|
||||
success: true,
|
||||
token: { access_token: 'late-access-token', username: 'zhangsan' },
|
||||
session: {
|
||||
accessToken: 'late-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
await login;
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
accessToken: null,
|
||||
canRefresh: false,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a delayed refresh revive an invalidated session', async () => {
|
||||
let resolveRefresh!: (value: unknown) => void;
|
||||
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'old-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: 7,
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = useAuthStore.getState().refreshSession();
|
||||
useAuthStore.getState().invalidateSession('登录已失效');
|
||||
resolveRefresh({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'late-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(refresh).resolves.toBeNull();
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
accessToken: null,
|
||||
canRefresh: false,
|
||||
user: null,
|
||||
error: '登录已失效',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let delayed activity revive an invalidated session', async () => {
|
||||
let resolveActivity!: (value: unknown) => void;
|
||||
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveActivity = resolve;
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now() - 60_000,
|
||||
canRefresh: true,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: 7,
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
|
||||
const activity = useAuthStore.getState().markActivity();
|
||||
useAuthStore.getState().invalidateSession('登录已失效');
|
||||
resolveActivity({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'late-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
await activity;
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
accessToken: null,
|
||||
canRefresh: false,
|
||||
user: null,
|
||||
error: '登录已失效',
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes the Main-managed session and preserves the user profile', async () => {
|
||||
const lastActiveAt = Date.now() - 60_000;
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'new-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 120_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
@@ -239,9 +454,11 @@ describe('auth store', () => {
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'old-access-token',
|
||||
refreshToken: 'old-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 1_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
@@ -253,19 +470,217 @@ describe('auth store', () => {
|
||||
|
||||
await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/refresh', {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken: 'old-refresh-token' }),
|
||||
body: JSON.stringify({ forceRefresh: true }),
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/session/sync', {
|
||||
expect(useAuthStore.getState().user?.username).toBe('zhangsan');
|
||||
});
|
||||
|
||||
it('restores an expired access token when Main reports it can refresh', async () => {
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'expired-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'renewed-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'expired-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: 7,
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/session/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
accessToken: 'expired-access-token',
|
||||
refreshToken: null,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 120_000,
|
||||
expiresAt: Date.now() - 1,
|
||||
lastActiveAt,
|
||||
}),
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/session/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ forceRefresh: false }),
|
||||
});
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
accessToken: 'renewed-access-token',
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
lastActiveAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a session when Main rejects it after seven inactive days', async () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now() - 7 * 24 * 60 * 60 * 1000,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: 7,
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', expect.any(Object));
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
accessToken: null,
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
lastActiveAt: null,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists refresh capability without persisting a new refresh token', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'new-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'old-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
});
|
||||
|
||||
await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token');
|
||||
|
||||
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
||||
state?: Record<string, unknown>;
|
||||
};
|
||||
expect(persisted.state).not.toHaveProperty('refreshToken');
|
||||
expect(persisted.state).toMatchObject({ canRefresh: true, legacyRefreshToken: null });
|
||||
});
|
||||
|
||||
it('does not advance activity while maintaining the session in the background', async () => {
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'background-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'old-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 1_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
});
|
||||
|
||||
await useAuthStore.getState().maintainSession();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ forceRefresh: false }),
|
||||
});
|
||||
expect(useAuthStore.getState().lastActiveAt).toBe(lastActiveAt);
|
||||
});
|
||||
|
||||
it('persists Main-owned refresh capability received outside a Renderer refresh', () => {
|
||||
const lastActiveAt = Date.now() - 60_000;
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'old-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: 7,
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
|
||||
useAuthStore.getState().applyMainSession({
|
||||
accessToken: 'main-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
||||
state?: Record<string, unknown>;
|
||||
};
|
||||
expect(useAuthStore.getState().user?.username).toBe('zhangsan');
|
||||
expect(useAuthStore.getState().lastActiveAt).toBe(lastActiveAt);
|
||||
expect(persisted.state).not.toHaveProperty('refreshToken');
|
||||
expect(persisted.state).toMatchObject({ canRefresh: true, legacyRefreshToken: null });
|
||||
});
|
||||
|
||||
it('clears stale renderer auth when refresh fails', async () => {
|
||||
@@ -277,9 +692,10 @@ describe('auth store', () => {
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
clientId: 'app',
|
||||
accessToken: 'expired-access-token',
|
||||
refreshToken: 'invalid-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
@@ -293,7 +709,8 @@ describe('auth store', () => {
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
@@ -304,9 +721,10 @@ describe('auth store', () => {
|
||||
authBase: 'https://gateway.example.com/auth',
|
||||
clientId: 'app',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
|
||||
Reference in New Issue
Block a user