feat(cloud-sync): persist project and status metadata for synced accounts

This commit is contained in:
Draculabo committed 2026-04-11 16:44:30 +08:00
1 parent f970ccb2ed
commit 385036d577
4 files changed
+524 -2

No files matched your search

+397
View File
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ProtobufUtils } from '../../utils/protobuf';
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
import type { UserInfo } from '../../services/GoogleAPIService';
import { toSyncLocalAccountORPCError } from '../../ipc/cloud/router';
let mockData: Record<string, string>;
let busyOnFirstGet = false;
@@ -168,6 +169,34 @@ describe('CloudAccountRepo.syncFromIDE', () => {
expect(account?.email).toBe('[email protected]');
});
it('reads enterprise project preference from IDE unified state when syncing new account', async () => {
const accessToken = 'access-enterprise';
const refreshToken = 'refresh-enterprise';
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(accessToken, refreshToken, 1700000000);
const projectPayload = ProtobufUtils.createStringValuePayload('enterprise-project-1');
const enterprisePreferenceB64 = ProtobufUtils.createUnifiedStateEntry(
'enterpriseGcpProjectId',
projectPayload,
);
mockData['antigravityUnifiedStateSync.oauthToken'] = unifiedB64;
mockData['antigravityUnifiedStateSync.enterprisePreferences'] = enterprisePreferenceB64;
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
createMockUserInfo('[email protected]', 'Enterprise User'),
);
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]);
vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
const account = await CloudAccountRepo.syncFromIDE();
expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken);
expect(account?.email).toBe('[email protected]');
expect(account?.token.project_id).toBe('enterprise-project-1');
});
it('should fall back to old oauth token when unified is missing', async () => {
const accessToken = 'access-old';
const refreshToken = 'refresh-old';
@@ -191,6 +220,191 @@ describe('CloudAccountRepo.syncFromIDE', () => {
expect(account?.email).toBe('[email protected]');
});
it('preserves existing token metadata and proxy settings when syncing existing account', async () => {
const accessToken = 'access-updated';
const refreshToken = 'refresh-updated';
const oldB64 = Buffer.from(
ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000),
).toString('base64');
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
createMockUserInfo('[email protected]', 'Existing User'),
);
const existingAccount = {
id: 'existing-id',
provider: 'google' as const,
email: '[email protected]',
name: 'Existing User',
avatar_url: 'https://example.com/avatar.png',
token: {
access_token: 'old-access',
refresh_token: 'old-refresh',
expires_in: 3600,
expiry_timestamp: 1699999999,
token_type: 'Bearer',
email: '[email protected]',
project_id: 'project-keep',
oauth_client_key: 'custom-client',
session_id: 'session-keep',
upstream_proxy_url: 'http://127.0.0.1:8080',
},
quota: undefined,
device_profile: undefined,
device_history: undefined,
created_at: 1690000000,
last_used: 1690000100,
status: 'active' as const,
is_active: false,
proxy_url: 'http://127.0.0.1:7890',
};
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([existingAccount]);
const addAccountSpy = vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
const account = await CloudAccountRepo.syncFromIDE();
expect(account?.id).toBe('existing-id');
expect(account?.token.project_id).toBe('project-keep');
expect(account?.token.oauth_client_key).toBe('custom-client');
expect(account?.token.session_id).toBe('session-keep');
expect(account?.token.upstream_proxy_url).toBe('http://127.0.0.1:8080');
expect(account?.proxy_url).toBe('http://127.0.0.1:7890');
expect(addAccountSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: 'existing-id',
token: expect.objectContaining({
access_token: accessToken,
refresh_token: refreshToken,
project_id: 'project-keep',
oauth_client_key: 'custom-client',
}),
proxy_url: 'http://127.0.0.1:7890',
}),
);
});
it('should recover project_id from IDE enterprise preferences when existing project_id is blank', async () => {
const accessToken = 'access-blank-project';
const refreshToken = 'refresh-blank-project';
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(
accessToken,
refreshToken,
1700000000,
true,
);
const enterprisePreferenceB64 = ProtobufUtils.createUnifiedStateEntry(
'enterpriseGcpProjectId',
ProtobufUtils.createStringValuePayload('enterprise-project-recovered'),
);
mockData['antigravityUnifiedStateSync.oauthToken'] = unifiedB64;
mockData['antigravityUnifiedStateSync.enterprisePreferences'] = enterprisePreferenceB64;
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
createMockUserInfo('[email protected]', 'Existing User'),
);
const existingAccount = {
id: 'existing-id',
provider: 'google' as const,
email: '[email protected]',
name: 'Existing User',
avatar_url: 'https://example.com/avatar.png',
token: {
access_token: 'old-access',
refresh_token: 'old-refresh',
expires_in: 3600,
expiry_timestamp: 1699999999,
token_type: 'Bearer',
email: '[email protected]',
project_id: ' ',
oauth_client_key: 'custom-client',
},
quota: undefined,
device_profile: undefined,
device_history: undefined,
created_at: 1690000000,
last_used: 1690000100,
status: 'active' as const,
is_active: false,
proxy_url: 'http://127.0.0.1:7890',
};
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([existingAccount]);
const addAccountSpy = vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
const account = await CloudAccountRepo.syncFromIDE();
expect(account?.token.project_id).toBe('enterprise-project-recovered');
expect(addAccountSpy).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
project_id: 'enterprise-project-recovered',
}),
}),
);
});
it('should reset stale blocked status after successful IDE resync', async () => {
const accessToken = 'access-status-reset';
const refreshToken = 'refresh-status-reset';
const oldB64 = Buffer.from(
ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000),
).toString('base64');
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
createMockUserInfo('[email protected]', 'Existing User'),
);
const existingAccount = {
id: 'existing-id',
provider: 'google' as const,
email: '[email protected]',
name: 'Existing User',
avatar_url: 'https://example.com/avatar.png',
token: {
access_token: 'old-access',
refresh_token: 'old-refresh',
expires_in: 3600,
expiry_timestamp: 1699999999,
token_type: 'Bearer',
email: '[email protected]',
},
quota: undefined,
device_profile: undefined,
device_history: undefined,
created_at: 1690000000,
last_used: 1690000100,
status: 'rate_limited' as const,
status_reason: 'RESOURCE_EXHAUSTED',
is_active: false,
proxy_url: 'http://127.0.0.1:7890',
};
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([existingAccount]);
const addAccountSpy = vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
const account = await CloudAccountRepo.syncFromIDE();
expect(account?.status).toBe('active');
expect(account?.status_reason).toBeUndefined();
expect(addAccountSpy).toHaveBeenCalledWith(
expect.objectContaining({
status: 'active',
status_reason: undefined,
}),
);
});
it('should retry when sqlite is busy', async () => {
busyOnFirstGet = true;
const accessToken = 'access-retry';
@@ -268,6 +482,20 @@ describe('CloudAccountRepo.syncFromIDE', () => {
});
});
describe('syncLocalAccount ORPC error mapping', () => {
it('preserves re-login guidance as an actionable unauthorized error', () => {
const error = toSyncLocalAccountORPCError(
new Error(
'Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE.',
),
);
expect(error.code).toBe('UNAUTHORIZED');
expect(error.status).toBe(401);
expect(error.message).toContain('Please re-login in Antigravity IDE');
});
});
describe('cloud switch fail-fast path', () => {
afterEach(() => {
vi.resetModules();
@@ -386,3 +614,172 @@ describe('cloud switch fail-fast path', () => {
expect(recordSwitchSuccessMock).not.toHaveBeenCalled();
});
});
describe('cloud oauth client key backfill', () => {
afterEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it('backfills missing oauth_client_key with active non-enterprise client', async () => {
const updateTokenMock = vi.fn(async () => undefined);
const setSettingMock = vi.fn();
const accounts = [
{
id: 'acc-1',
provider: 'google' as const,
email: '[email protected]',
token: {
access_token: 'access',
refresh_token: 'refresh',
expires_in: 3600,
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
token_type: 'Bearer',
email: '[email protected]',
},
created_at: Math.floor(Date.now() / 1000),
last_used: Math.floor(Date.now() / 1000),
},
];
vi.doMock('../../ipc/database/cloudHandler', () => ({
CloudAccountRepo: {
getAccounts: vi.fn(async () => accounts),
updateToken: updateTokenMock,
getSetting: vi.fn((key: string, defaultValue: unknown) => {
if (key === 'oauth_client_key_backfill_v1_done') {
return false;
}
if (key === 'active_oauth_client_key') {
return 'custom_a';
}
return defaultValue;
}),
setSetting: setSettingMock,
},
}));
const setActiveOAuthClientKeyMock = vi.fn();
const getActiveOAuthClientKeyMock = vi.fn(() => 'custom_a');
vi.doMock('../../services/GoogleAPIService', () => ({
GoogleAPIService: {
setActiveOAuthClientKey: setActiveOAuthClientKeyMock,
getActiveOAuthClientKey: getActiveOAuthClientKeyMock,
},
}));
vi.doMock('../../utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.doMock('../../ipc/tray/handler', () => ({ updateTrayMenu: vi.fn() }));
vi.doMock('../../ipc/device/handler', () => ({
ensureGlobalOriginalFromCurrentStorage: vi.fn(),
generateDeviceProfile: vi.fn(),
getStorageDirectoryPath: vi.fn(() => ''),
isIdentityProfileApplyEnabled: vi.fn(() => false),
loadGlobalOriginalProfile: vi.fn(),
readCurrentDeviceProfile: vi.fn(),
saveGlobalOriginalProfile: vi.fn(),
}));
vi.doMock('../../utils/paths', () => ({ getAntigravityDbPaths: () => [] }));
vi.doMock('../../ipc/switchGuard', () => ({
runWithSwitchGuard: async (_owner: string, fn: () => Promise<void>) => fn(),
}));
vi.doMock('../../ipc/switchFlow', () => ({ executeSwitchFlow: vi.fn() }));
vi.doMock('electron', () => ({ shell: { openExternal: vi.fn() } }));
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
await listCloudAccounts();
expect(setActiveOAuthClientKeyMock).toHaveBeenCalledWith('custom_a');
expect(updateTokenMock).toHaveBeenCalledWith(
'acc-1',
expect.objectContaining({
oauth_client_key: 'custom_a',
}),
);
expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true);
});
it('skips enterprise backfill for legacy account without project_id', async () => {
const updateTokenMock = vi.fn(async () => undefined);
const setSettingMock = vi.fn();
const accounts = [
{
id: 'acc-legacy',
provider: 'google' as const,
email: '[email protected]',
token: {
access_token: 'access',
refresh_token: 'refresh',
expires_in: 3600,
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
token_type: 'Bearer',
email: '[email protected]',
},
created_at: Math.floor(Date.now() / 1000),
last_used: Math.floor(Date.now() / 1000),
},
];
vi.doMock('../../ipc/database/cloudHandler', () => ({
CloudAccountRepo: {
getAccounts: vi.fn(async () => accounts),
updateToken: updateTokenMock,
getSetting: vi.fn((key: string, defaultValue: unknown) => {
if (key === 'oauth_client_key_backfill_v1_done') {
return false;
}
if (key === 'active_oauth_client_key') {
return 'antigravity_enterprise';
}
return defaultValue;
}),
setSetting: setSettingMock,
},
}));
vi.doMock('../../services/GoogleAPIService', () => ({
GoogleAPIService: {
setActiveOAuthClientKey: vi.fn(),
getActiveOAuthClientKey: vi.fn(() => 'antigravity_enterprise'),
},
}));
vi.doMock('../../utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.doMock('../../ipc/tray/handler', () => ({ updateTrayMenu: vi.fn() }));
vi.doMock('../../ipc/device/handler', () => ({
ensureGlobalOriginalFromCurrentStorage: vi.fn(),
generateDeviceProfile: vi.fn(),
getStorageDirectoryPath: vi.fn(() => ''),
isIdentityProfileApplyEnabled: vi.fn(() => false),
loadGlobalOriginalProfile: vi.fn(),
readCurrentDeviceProfile: vi.fn(),
saveGlobalOriginalProfile: vi.fn(),
}));
vi.doMock('../../utils/paths', () => ({ getAntigravityDbPaths: () => [] }));
vi.doMock('../../ipc/switchGuard', () => ({
runWithSwitchGuard: async (_owner: string, fn: () => Promise<void>) => fn(),
}));
vi.doMock('../../ipc/switchFlow', () => ({ executeSwitchFlow: vi.fn() }));
vi.doMock('electron', () => ({ shell: { openExternal: vi.fn() } }));
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
await listCloudAccounts();
expect(updateTokenMock).not.toHaveBeenCalled();
expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true);
});
});