Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import path from 'node:path';
|
|
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
const sessionFile = path.join('/test-user-data', 'auth-sessions.json');
|
|
const temporarySessionFile = `${sessionFile}.tmp`;
|
|
|
|
const runtime = vi.hoisted(() => ({
|
|
encryptionAvailable: true,
|
|
backend: 'unknown',
|
|
failWrite: false,
|
|
files: new Map<string, string>(),
|
|
fetch: vi.fn(),
|
|
fromPartition: vi.fn(),
|
|
rename: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readFile: vi.fn(async (file: string) => {
|
|
const value = runtime.files.get(file);
|
|
if (value === undefined) {
|
|
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
|
}
|
|
return value;
|
|
}),
|
|
writeFile: vi.fn(async (file: string, value: string) => {
|
|
if (runtime.failWrite) throw new Error('disk unavailable');
|
|
runtime.files.set(file, value);
|
|
}),
|
|
rename: vi.fn(async (source: string, target: string) => {
|
|
runtime.rename(source, target);
|
|
const value = runtime.files.get(source);
|
|
if (value !== undefined) runtime.files.set(target, value);
|
|
runtime.files.delete(source);
|
|
}),
|
|
rm: vi.fn(async (file: string) => {
|
|
runtime.files.delete(file);
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getPath: () => '/test-user-data' },
|
|
session: {
|
|
fromPartition: (...args: unknown[]) => {
|
|
runtime.fromPartition(...args);
|
|
return { fetch: runtime.fetch };
|
|
},
|
|
},
|
|
safeStorage: {
|
|
isEncryptionAvailable: () => runtime.encryptionAvailable,
|
|
getSelectedStorageBackend: () => runtime.backend,
|
|
encryptString: (value: string) => Buffer.from(value),
|
|
decryptString: (value: Buffer) => value.toString(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/main/logger', () => ({
|
|
logger: { error: vi.fn() },
|
|
}));
|
|
|
|
import {
|
|
clearAuthSession,
|
|
executeAuthSessionRequest,
|
|
getValidAccessToken,
|
|
initializeAuthSessions,
|
|
isManagedAuthEndpoint,
|
|
normalizeEndpoint,
|
|
revokeAuthSession,
|
|
setAuthSession,
|
|
} from '../../src/main/auth/auth-session';
|
|
|
|
beforeEach(() => {
|
|
runtime.encryptionAvailable = true;
|
|
runtime.backend = 'unknown';
|
|
runtime.failWrite = false;
|
|
runtime.fetch.mockReset();
|
|
runtime.fromPartition.mockClear();
|
|
runtime.rename.mockClear();
|
|
});
|
|
|
|
test.each([
|
|
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
|
|
['https://affine.pro:443', 'https://affine.pro'],
|
|
['http://localhost:80/path', 'http://localhost'],
|
|
['http://localhost:8080/path', 'http://localhost:8080'],
|
|
])('normalizes auth endpoint %s', (endpoint, expected) => {
|
|
expect(normalizeEndpoint(endpoint)).toBe(expected);
|
|
});
|
|
|
|
test('loads and atomically persists one encrypted token-pair record', async () => {
|
|
const endpoint = 'https://persistent.example';
|
|
const pair = tokenResponse('access-persistent', 'p', 900);
|
|
runtime.files.set(
|
|
sessionFile,
|
|
JSON.stringify({
|
|
[endpoint]: Buffer.from(
|
|
JSON.stringify({
|
|
version: 1,
|
|
tokenType: pair.tokenType,
|
|
accessToken: pair.accessToken,
|
|
accessExpiresAt: new Date(
|
|
Date.now() + pair.expiresIn * 1000
|
|
).toISOString(),
|
|
refreshToken: pair.refreshToken,
|
|
refreshExpiresAt: pair.refreshExpiresAt,
|
|
session: pair.session,
|
|
})
|
|
).toString('base64'),
|
|
})
|
|
);
|
|
|
|
expect(isManagedAuthEndpoint(endpoint)).toBe(false);
|
|
await initializeAuthSessions();
|
|
expect(isManagedAuthEndpoint(endpoint)).toBe(true);
|
|
expect(await getValidAccessToken(endpoint)).toBe('access-persistent');
|
|
|
|
await expect(setAuthSession(endpoint, pair)).resolves.toEqual({
|
|
persistent: true,
|
|
});
|
|
expect(await getValidAccessToken(endpoint)).toBe('access-persistent');
|
|
expect(runtime.rename).toHaveBeenCalledWith(
|
|
temporarySessionFile,
|
|
sessionFile
|
|
);
|
|
const file = runtime.files.get(sessionFile) ?? '';
|
|
expect(file).not.toContain(pair.refreshToken);
|
|
});
|
|
|
|
test('keeps a session in main-process memory when safeStorage is unavailable', async () => {
|
|
const endpoint = 'https://session-only.example';
|
|
await setAuthSession(endpoint, tokenResponse('old-persistent', 'o', 900));
|
|
runtime.encryptionAvailable = false;
|
|
|
|
await expect(
|
|
setAuthSession(endpoint, tokenResponse('session-access', 's', 900))
|
|
).resolves.toEqual({ persistent: false });
|
|
expect(await getValidAccessToken(endpoint)).toBe('session-access');
|
|
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
|
|
runtime.encryptionAvailable = true;
|
|
});
|
|
|
|
test('rejects Linux basic_text persistence and removes an older disk session', async () => {
|
|
const platform = vi
|
|
.spyOn(process, 'platform', 'get')
|
|
.mockReturnValue('linux');
|
|
const endpoint = 'https://basic-text.example';
|
|
await setAuthSession(endpoint, tokenResponse('old', 'o', 900));
|
|
runtime.backend = 'basic_text';
|
|
|
|
await expect(
|
|
setAuthSession(endpoint, tokenResponse('memory', 'm', 900))
|
|
).resolves.toEqual({ persistent: false });
|
|
expect(await getValidAccessToken(endpoint)).toBe('memory');
|
|
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
|
|
platform.mockRestore();
|
|
});
|
|
|
|
test('shares one refresh across concurrent main-process callers', async () => {
|
|
const endpoint = 'https://refresh.example';
|
|
await setAuthSession(endpoint, tokenResponse('expiring', 'a', 1));
|
|
runtime.fetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(tokenResponse('fresh', 'b', 900)))
|
|
);
|
|
|
|
const tokens = await Promise.all(
|
|
Array.from({ length: 50 }, () => getValidAccessToken(endpoint, 120_000))
|
|
);
|
|
|
|
expect(tokens.every(token => token === 'fresh')).toBe(true);
|
|
expect(runtime.fetch).toHaveBeenCalledTimes(1);
|
|
expect(runtime.fromPartition).toHaveBeenCalledWith('affine-auth-transport', {
|
|
cache: false,
|
|
});
|
|
});
|
|
|
|
test('preserves credentials for unknown refresh errors', async () => {
|
|
const endpoint = 'https://unknown-error.example';
|
|
await setAuthSession(endpoint, tokenResponse('still-valid', 'u', 900));
|
|
runtime.fetch.mockImplementation(
|
|
async () =>
|
|
new Response(JSON.stringify({ code: 'UNKNOWN_SELF_HOSTED_ERROR' }), {
|
|
status: 401,
|
|
})
|
|
);
|
|
|
|
await expect(getValidAccessToken(endpoint, 1_000_000)).rejects.toMatchObject({
|
|
code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
|
|
transient: true,
|
|
});
|
|
await expect(getValidAccessToken(endpoint, 0)).resolves.toBe('still-valid');
|
|
});
|
|
|
|
test('clears credentials only for an allowlisted permanent refresh error', async () => {
|
|
const endpoint = 'https://permanent-error.example';
|
|
await setAuthSession(endpoint, tokenResponse('expired', 'e', 1));
|
|
runtime.fetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ code: 'AUTH_SESSION_REVOKED' }), {
|
|
status: 401,
|
|
})
|
|
);
|
|
|
|
await expect(getValidAccessToken(endpoint, 120_000)).resolves.toBeNull();
|
|
await expect(getValidAccessToken(endpoint, 0)).resolves.toBeNull();
|
|
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
|
|
});
|
|
|
|
test('cleans a decrypted record that does not match the token-pair schema', async () => {
|
|
const endpoint = 'https://corrupt.example';
|
|
runtime.files.set(
|
|
sessionFile,
|
|
JSON.stringify({
|
|
[endpoint]: Buffer.from(JSON.stringify({ token: 'legacy' })).toString(
|
|
'base64'
|
|
),
|
|
})
|
|
);
|
|
|
|
await expect(getValidAccessToken(endpoint)).resolves.toBeNull();
|
|
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
|
|
});
|
|
|
|
test('recovers an unreadable session file on the next atomic write', async () => {
|
|
runtime.files.set(sessionFile, '{not-json');
|
|
const endpoint = 'https://file-corruption.example';
|
|
|
|
await expect(getValidAccessToken(endpoint)).resolves.toBeNull();
|
|
await setAuthSession(endpoint, tokenResponse('recovered', 'c', 900));
|
|
|
|
expect(JSON.parse(runtime.files.get(sessionFile)!)).toHaveProperty(endpoint);
|
|
await expect(getValidAccessToken(endpoint)).resolves.toBe('recovered');
|
|
});
|
|
|
|
test('does not publish a rotated pair until an atomic save succeeds', async () => {
|
|
const endpoint = 'https://save-failure.example';
|
|
await setAuthSession(endpoint, tokenResponse('old', 'a', 1));
|
|
runtime.fetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(tokenResponse('rotated', 'b', 900)))
|
|
);
|
|
runtime.failWrite = true;
|
|
|
|
await expect(getValidAccessToken(endpoint, 120_000)).rejects.toMatchObject({
|
|
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
|
|
});
|
|
runtime.failWrite = false;
|
|
await expect(getValidAccessToken(endpoint, 120_000)).resolves.toBe('rotated');
|
|
expect(runtime.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('main replaces renderer bearer and strips it after local clear', async () => {
|
|
const endpoint = 'https://owner.example';
|
|
await setAuthSession(endpoint, tokenResponse('main-token', 'm', 900));
|
|
const execute = vi.fn(async () => new Response('{}'));
|
|
|
|
await executeAuthSessionRequest(
|
|
new Request(`${endpoint}/graphql`, {
|
|
headers: { Authorization: 'Bearer renderer-stale' },
|
|
}),
|
|
`${endpoint}/graphql`,
|
|
execute
|
|
);
|
|
expect(execute.mock.calls[0]?.[0].headers.get('Authorization')).toBe(
|
|
'Bearer main-token'
|
|
);
|
|
|
|
await clearAuthSession(endpoint, 'test-clear');
|
|
await executeAuthSessionRequest(
|
|
new Request(`${endpoint}/graphql`, {
|
|
headers: { Authorization: 'Bearer renderer-stale' },
|
|
}),
|
|
`${endpoint}/graphql`,
|
|
execute
|
|
);
|
|
expect(execute.mock.calls[1]?.[0].headers.get('Authorization')).toBeNull();
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
code: 'ACCESS_TOKEN_EXPIRED',
|
|
expectedRequests: 2,
|
|
method: 'POST',
|
|
},
|
|
{ code: 'FORBIDDEN', expectedRequests: 1, method: 'PUT' },
|
|
])('replays a protocol request once for $code only', async item => {
|
|
const endpoint = `https://${item.code.toLowerCase()}.example`;
|
|
await setAuthSession(endpoint, tokenResponse('initial', 'i', 900));
|
|
runtime.fetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(tokenResponse('refreshed', 'f', 900)))
|
|
);
|
|
const execute = vi
|
|
.fn<(request: Request) => Promise<Response>>()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ code: item.code }), { status: 401 })
|
|
)
|
|
.mockResolvedValueOnce(new Response('{}'));
|
|
|
|
await executeAuthSessionRequest(
|
|
new Request(`${endpoint}/api/test`, {
|
|
method: item.method,
|
|
body: JSON.stringify({ value: item.code }),
|
|
duplex: 'half',
|
|
}),
|
|
`${endpoint}/api/test`,
|
|
execute
|
|
);
|
|
|
|
expect(execute).toHaveBeenCalledTimes(item.expectedRequests);
|
|
expect(runtime.fetch).toHaveBeenCalledTimes(
|
|
item.code === 'ACCESS_TOKEN_EXPIRED' ? 1 : 0
|
|
);
|
|
for (const [request] of execute.mock.calls) {
|
|
await expect(request.text()).resolves.toBe(
|
|
JSON.stringify({ value: item.code })
|
|
);
|
|
}
|
|
});
|
|
|
|
test('clears locally before revoking without exposing refresh token over IPC', async () => {
|
|
const endpoint = 'https://revoke.example';
|
|
const pair = tokenResponse('revoke-access', 'r', 900);
|
|
await setAuthSession(endpoint, pair);
|
|
runtime.fetch.mockResolvedValueOnce(new Response('{}'));
|
|
|
|
await revokeAuthSession(endpoint);
|
|
|
|
expect(await getValidAccessToken(endpoint)).toBeNull();
|
|
const request = runtime.fetch.mock.calls.at(-1)?.[1] as RequestInit;
|
|
expect(request.body).toBe(
|
|
JSON.stringify({ refreshToken: pair.refreshToken })
|
|
);
|
|
});
|
|
|
|
function tokenResponse(accessToken: string, seed: string, expiresIn: number) {
|
|
return {
|
|
tokenType: 'Bearer' as const,
|
|
accessToken,
|
|
expiresIn,
|
|
refreshToken: `aff_rt_v1.${seed.repeat(24)}.${seed.repeat(43)}`,
|
|
refreshExpiresAt: '2030-01-01T00:00:00.000Z',
|
|
session: {
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
absoluteExpiresAt: '2031-01-01T00:00:00.000Z',
|
|
},
|
|
};
|
|
}
|