feat(cloud-sync): persist project and status metadata for synced accounts
This commit is contained in:
1 parent
f970ccb2ed
commit
385036d577
4 files changed
+524
-2
No files matched your search
@@ -85,6 +85,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
status_reason TEXT,
|
||||
is_active INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
@@ -96,6 +97,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
||||
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json');
|
||||
const hasProxyUrl = tableInfo.some((col) => col.name === 'proxy_url');
|
||||
const hasStatusReason = tableInfo.some((col) => col.name === 'status_reason');
|
||||
if (!hasIsActive) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||
}
|
||||
@@ -108,6 +110,9 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
if (!hasProxyUrl) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN proxy_url TEXT');
|
||||
}
|
||||
if (!hasStatusReason) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN status_reason TEXT');
|
||||
}
|
||||
|
||||
// Create index on email for faster lookups
|
||||
// Create index on email for faster lookups
|
||||
@@ -479,6 +484,7 @@ export class CloudAccountRepo {
|
||||
createdAt: account.created_at,
|
||||
lastUsed: account.last_used,
|
||||
status: account.status || 'active',
|
||||
statusReason: account.status_reason ?? null,
|
||||
isActive: account.is_active ? 1 : 0,
|
||||
proxyUrl: account.proxy_url ?? null,
|
||||
};
|
||||
@@ -595,6 +601,7 @@ export class CloudAccountRepo {
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
status_reason: normalizedRow.statusReason ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
});
|
||||
@@ -681,13 +688,14 @@ export class CloudAccountRepo {
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
token: JSON.parse(tokenValue),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
status_reason: normalizedRow.statusReason ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
};
|
||||
@@ -985,6 +993,26 @@ export class CloudAccountRepo {
|
||||
}
|
||||
}
|
||||
|
||||
static async setAccountStatus(
|
||||
id: string,
|
||||
status: CloudAccount['status'],
|
||||
reason?: string | null,
|
||||
): Promise<void> {
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
orm
|
||||
.update(accounts)
|
||||
.set({
|
||||
status,
|
||||
statusReason: reason?.trim() ? reason.trim() : null,
|
||||
})
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccountByEmail(email: string): Promise<CloudAccount | null> {
|
||||
const all = await this.getAccounts();
|
||||
return all.find((a) => a.email.toLowerCase() === email.toLowerCase()) || null;
|
||||
@@ -1030,10 +1058,35 @@ export class CloudAccountRepo {
|
||||
account.token.access_token,
|
||||
account.token.refresh_token,
|
||||
account.token.expiry_timestamp,
|
||||
account.token.is_gcp_tos ?? true,
|
||||
);
|
||||
const userStatusPayload = ProtobufUtils.createMinimalUserStatusPayload(account.email);
|
||||
const userStatusEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'userStatusSentinelKey',
|
||||
userStatusPayload,
|
||||
);
|
||||
const normalizedProjectId = account.token.project_id?.trim();
|
||||
|
||||
orm.transaction((tx) => {
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken);
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.userStatus', userStatusEntry);
|
||||
if (normalizedProjectId) {
|
||||
const projectPayload = ProtobufUtils.createStringValuePayload(normalizedProjectId);
|
||||
const projectEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'enterpriseGcpProjectId',
|
||||
projectPayload,
|
||||
);
|
||||
this.upsertItemValue(
|
||||
tx,
|
||||
'antigravityUnifiedStateSync.enterprisePreferences',
|
||||
projectEntry,
|
||||
);
|
||||
} else {
|
||||
tx
|
||||
.delete(itemTable)
|
||||
.where(eq(itemTable.key, 'antigravityUnifiedStateSync.enterprisePreferences'))
|
||||
.run();
|
||||
}
|
||||
this.writeAuthStatusAndCleanup(tx, account);
|
||||
});
|
||||
}
|
||||
@@ -1252,7 +1305,9 @@ export class CloudAccountRepo {
|
||||
private static readTokenInfoFromDb(db: DrizzleExecutor): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
projectId?: string;
|
||||
} {
|
||||
const enterpriseProjectId = this.readEnterpriseProjectIdFromDb(db);
|
||||
const unifiedValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
@@ -1296,12 +1351,16 @@ export class CloudAccountRepo {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return tokenInfo;
|
||||
return {
|
||||
...tokenInfo,
|
||||
projectId: enterpriseProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
private static readTokenInfoWithRetry(dbPath: string): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
projectId?: string;
|
||||
} {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
||||
@@ -1323,6 +1382,41 @@ export class CloudAccountRepo {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private static readEnterpriseProjectIdFromDb(db: DrizzleExecutor): string | undefined {
|
||||
const enterprisePreferencesValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.enterprisePreferences',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.enterprisePreferences',
|
||||
);
|
||||
if (!enterprisePreferencesValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sentinelKey, payload } = ProtobufUtils.decodeUnifiedStateEntry(
|
||||
enterprisePreferencesValue,
|
||||
);
|
||||
if (sentinelKey !== 'enterpriseGcpProjectId') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const projectBytes = ProtobufUtils.getField(payload, 3);
|
||||
if (!projectBytes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const projectId = ProtobufUtils.readString(projectBytes).trim();
|
||||
if (projectId === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return projectId;
|
||||
} catch (error) {
|
||||
logger.warn('SyncLocal: Failed to parse enterprise project preference', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||
// Try all possible database paths
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
@@ -1372,6 +1466,8 @@ export class CloudAccountRepo {
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
project_id: tokenInfo.projectId,
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
@@ -1383,8 +1479,28 @@ export class CloudAccountRepo {
|
||||
const accounts = await this.getAccounts();
|
||||
const existing = accounts.find((a) => a.email === account.email);
|
||||
if (existing) {
|
||||
const existingProjectId = existing.token.project_id?.trim();
|
||||
|
||||
account.id = existing.id; // Keep existing ID
|
||||
account.created_at = existing.created_at;
|
||||
account.name = account.name ?? existing.name;
|
||||
account.avatar_url = account.avatar_url ?? existing.avatar_url;
|
||||
account.proxy_url = existing.proxy_url;
|
||||
account.device_profile = existing.device_profile;
|
||||
account.device_history = existing.device_history;
|
||||
account.status = 'active';
|
||||
account.status_reason = undefined;
|
||||
account.token = {
|
||||
...existing.token,
|
||||
access_token: tokenInfo.accessToken,
|
||||
refresh_token: tokenInfo.refreshToken || existing.token.refresh_token,
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
project_id: existingProjectId || tokenInfo.projectId,
|
||||
is_gcp_tos: existing.token.is_gcp_tos ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
await this.addAccount(account);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const accounts = sqliteTable('accounts', {
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,10 @@ export interface CloudTokenData {
|
||||
token_type: string;
|
||||
email?: string;
|
||||
project_id?: string;
|
||||
oauth_client_key?: string;
|
||||
session_id?: string;
|
||||
upstream_proxy_url?: string;
|
||||
is_gcp_tos?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudQuotaModelInfo {
|
||||
@@ -53,6 +55,7 @@ export interface CloudAccount {
|
||||
created_at: number;
|
||||
last_used: number; // Unix timestamp
|
||||
status?: 'active' | 'rate_limited' | 'expired';
|
||||
status_reason?: string;
|
||||
is_active?: boolean;
|
||||
proxy_url?: string;
|
||||
}
|
||||
@@ -66,8 +69,10 @@ export const CloudTokenDataSchema = z.object({
|
||||
token_type: z.string(),
|
||||
email: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
oauth_client_key: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
upstream_proxy_url: z.string().optional(),
|
||||
is_gcp_tos: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CloudQuotaModelInfoSchema = z.object({
|
||||
@@ -105,6 +110,7 @@ export const CloudAccountSchema = z.object({
|
||||
created_at: z.number(),
|
||||
last_used: z.number(),
|
||||
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
||||
status_reason: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
proxy_url: z.string().optional(),
|
||||
});
|
||||
@@ -123,6 +129,8 @@ export const CloudAccountExportSchema = z.object({
|
||||
device_profile: z.any().optional(),
|
||||
device_history: z.any().optional(),
|
||||
proxy_url: z.string().optional().nullable(),
|
||||
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
||||
status_reason: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user