mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
fix(providers): mark Antigravity connects with no Cloud Code projectId as degraded (#11284) (#11358)
Merged via consolidated batch validation. Production evidence (VPS docker instance): Antigravity OAuth connects ending without a Cloud Code projectId were persisted as silently active while every model call failed; now persisted as degraded. Own tests pass.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
- **fix(providers):** Antigravity OAuth marks connects with no Cloud Code projectId as degraded instead of a false "Connected"; BYOP detection at connect time, auto-disable of confirmed-missing accounts, and selection-side rotation ([#11284](https://github.com/diegosouzapw/OmniRoute/issues/11284))
|
||||||
@@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
|||||||
const projectId = (psd as Record<string, unknown>).projectId;
|
const projectId = (psd as Record<string, unknown>).projectId;
|
||||||
return typeof projectId === "string" && projectId.trim().length > 0;
|
return typeof projectId === "string" && projectId.trim().length > 0;
|
||||||
};
|
};
|
||||||
const withStoredProject = connections.filter(hasStoredProject);
|
// #11284: rows whose missing Cloud Code project was CONFIRMED at request
|
||||||
return withStoredProject.length > 0 ? withStoredProject : connections;
|
// time (errorCode="missing_project_id") are dead weight — drop them when a
|
||||||
|
// healthier sibling exists. When every row is confirmed missing, keep the
|
||||||
|
// pool so the typed 422 (not an empty-selection 404) explains what to fix.
|
||||||
|
const hasHealthySibling = (connection: T): boolean =>
|
||||||
|
connections.some(
|
||||||
|
(other) => other !== connection && other.errorCode !== "missing_project_id"
|
||||||
|
);
|
||||||
|
const candidates = connections.filter(
|
||||||
|
(connection) =>
|
||||||
|
connection.errorCode !== "missing_project_id" ||
|
||||||
|
!hasHealthySibling(connection) ||
|
||||||
|
!hasStoredProject(connection)
|
||||||
|
);
|
||||||
|
const withStoredProject = candidates.filter(hasStoredProject);
|
||||||
|
if (withStoredProject.length > 0) return withStoredProject;
|
||||||
|
return candidates.length > 0 ? candidates : connections;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function persistDiscoveredAntigravityProjectId(
|
export async function persistDiscoveredAntigravityProjectId(
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export function persistDiscoveredAntigravityProjectId(
|
|||||||
errorCode: null,
|
errorCode: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
lastErrorType: null,
|
lastErrorType: null,
|
||||||
|
// #11284: a discovered project proves the account is usable again —
|
||||||
|
// re-enable it (markAntigravityMissingCloudCodeProject may have disabled
|
||||||
|
// it after a confirmed-missing 422).
|
||||||
|
isActive: true,
|
||||||
|
testStatus: "active",
|
||||||
providerSpecificData,
|
providerSpecificData,
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -77,7 +82,14 @@ export function markAntigravityMissingCloudCodeProject(
|
|||||||
): void {
|
): void {
|
||||||
if (!connectionId) return;
|
if (!connectionId) return;
|
||||||
|
|
||||||
|
// #11284: a CONFIRMED missing Cloud Code project is not transient — disable
|
||||||
|
// the row so selection rotates to healthy siblings instead of re-dispatching
|
||||||
|
// into the same 422 every request. "unavailable" is deliberately NOT a
|
||||||
|
// terminal status: persistDiscoveredAntigravityProjectId() re-enables the
|
||||||
|
// account the moment a project shows up at request time.
|
||||||
void updateProviderConnection(connectionId, {
|
void updateProviderConnection(connectionId, {
|
||||||
|
isActive: false,
|
||||||
|
testStatus: "unavailable",
|
||||||
errorCode: "missing_project_id",
|
errorCode: "missing_project_id",
|
||||||
lastError:
|
lastError:
|
||||||
"Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.",
|
"Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
|
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
|
||||||
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
|
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
|
||||||
|
import { antigravityDegradedProjectState } from "@/lib/oauth/antigravityProjectGate";
|
||||||
import { syncToCloud } from "@/lib/cloudSync";
|
import { syncToCloud } from "@/lib/cloudSync";
|
||||||
import { startLocalServer } from "@/lib/oauth/utils/server";
|
import { startLocalServer } from "@/lib/oauth/utils/server";
|
||||||
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||||
@@ -520,6 +521,12 @@ export async function POST(
|
|||||||
exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState)
|
exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #11284: when Cloud Code projectId discovery failed at connect time,
|
||||||
|
// SAVE the connection but mark it degraded (maintainer direction on
|
||||||
|
// #11284) — the refresh token stays stored and request-time bootstrap
|
||||||
|
// self-heals the row once Google assigns a project.
|
||||||
|
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
|
||||||
|
|
||||||
// Normalize: if name is missing, use email or displayName as fallback so accounts
|
// Normalize: if name is missing, use email or displayName as fallback so accounts
|
||||||
// always show a real label (e.g. user@gmail.com) instead of "Account #abc123"
|
// always show a real label (e.g. user@gmail.com) instead of "Account #abc123"
|
||||||
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
||||||
@@ -542,14 +549,15 @@ export async function POST(
|
|||||||
connection = await updateProviderConnection(matchId, {
|
connection = await updateProviderConnection(matchId, {
|
||||||
...tokenData,
|
...tokenData,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
testStatus: "active",
|
testStatus: degradedProject?.testStatus ?? "active",
|
||||||
|
...(degradedProject ?? {}),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
connection = await createProviderConnection(
|
connection = await createProviderConnection(
|
||||||
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
|
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +566,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
...(degradedProject ? { warning: degradedProject.warning } : {}),
|
||||||
connection: {
|
connection: {
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
provider: connection.provider,
|
provider: connection.provider,
|
||||||
@@ -739,6 +748,10 @@ export async function POST(
|
|||||||
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
|
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #11284: when Cloud Code projectId discovery failed at connect time,
|
||||||
|
// SAVE the connection but mark it degraded (maintainer direction).
|
||||||
|
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
|
||||||
|
|
||||||
// Normalize: if name is missing, use email as fallback display label
|
// Normalize: if name is missing, use email as fallback display label
|
||||||
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
||||||
tokenData.name = tokenData.email || tokenData.displayName;
|
tokenData.name = tokenData.email || tokenData.displayName;
|
||||||
@@ -765,14 +778,15 @@ export async function POST(
|
|||||||
connection = await updateProviderConnection(matchId, {
|
connection = await updateProviderConnection(matchId, {
|
||||||
...tokenData,
|
...tokenData,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
testStatus: "active",
|
testStatus: degradedProject?.testStatus ?? "active",
|
||||||
|
...(degradedProject ?? {}),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
connection = await createProviderConnection(
|
connection = await createProviderConnection(
|
||||||
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
|
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,6 +794,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
...(degradedProject ? { warning: degradedProject.warning } : {}),
|
||||||
connection: {
|
connection: {
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
provider: connection.provider,
|
provider: connection.provider,
|
||||||
|
|||||||
61
src/lib/oauth/antigravityProjectGate.ts
Normal file
61
src/lib/oauth/antigravityProjectGate.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* #11284 — Antigravity OAuth connect-time DEGRADE marking for accounts without
|
||||||
|
* a Cloud Code projectId. Shared helper used by the OAuth route's `exchange`,
|
||||||
|
* `poll-callback`, and the shared persistOAuthConnection path.
|
||||||
|
*
|
||||||
|
* Maintainer direction on #11284: do NOT reject the connect — SAVE the
|
||||||
|
* connection but mark it degraded, so the refresh token stays stored and the
|
||||||
|
* request-time bootstrap can self-heal it (persistDiscoveredAntigravityProjectId
|
||||||
|
* flips the row back to active). Confirmed-BYOP accounts get disabled by
|
||||||
|
* markAntigravityMissingCloudCodeProject() on the first dispatch instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AntigravityDegradedProjectState = {
|
||||||
|
/** Persist with this status instead of "active". */
|
||||||
|
testStatus: "degraded";
|
||||||
|
errorCode: string;
|
||||||
|
lastErrorType: string;
|
||||||
|
lastError: string;
|
||||||
|
/** Non-fatal warning surfaced in the connect response for the dashboard. */
|
||||||
|
warning: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Providers whose Cloud Code projectId is expected at connect time. */
|
||||||
|
const PROJECT_EXPECTED_PROVIDERS = new Set(["antigravity", "agy"]);
|
||||||
|
|
||||||
|
const BYOP_WARNING =
|
||||||
|
"Connected, but Google did not assign a Cloud Code project to this account (BYOP). " +
|
||||||
|
"Create a GCP Project at console.cloud.google.com and complete Gemini Code Assist onboarding; " +
|
||||||
|
"the account is marked degraded until then and cannot serve requests.";
|
||||||
|
|
||||||
|
const DISCOVERY_FAILED_WARNING =
|
||||||
|
"Connected, but the Google Cloud Code projectId could not be discovered during login " +
|
||||||
|
"(loadCodeAssist/onboardUser failed). The account is marked degraded; discovery retries " +
|
||||||
|
"automatically on the first request.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #11284: when projectId discovery failed at connect time, return the degrade
|
||||||
|
* fields to persist (testStatus:"degraded" + typed error markers) instead of
|
||||||
|
* silently saving a false "active". Returns null for healthy payloads.
|
||||||
|
*/
|
||||||
|
export function antigravityDegradedProjectState(
|
||||||
|
provider: string,
|
||||||
|
tokenData: Record<string, unknown> | null | undefined
|
||||||
|
): AntigravityDegradedProjectState | null {
|
||||||
|
if (!PROJECT_EXPECTED_PROVIDERS.has(provider)) return null;
|
||||||
|
const outcome = tokenData?.projectDiscoveryOutcome;
|
||||||
|
if (!outcome) return null;
|
||||||
|
console.warn(
|
||||||
|
`[oauth] ${provider}: marking connection degraded — no Cloud Code projectId (${String(outcome)}) (#11284)`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
testStatus: "degraded",
|
||||||
|
errorCode: "missing_project_id",
|
||||||
|
lastErrorType: "oauth_missing_project_id",
|
||||||
|
lastError:
|
||||||
|
outcome === "requires_manual_project"
|
||||||
|
? BYOP_WARNING
|
||||||
|
: DISCOVERY_FAILED_WARNING,
|
||||||
|
warning: outcome === "requires_manual_project" ? BYOP_WARNING : DISCOVERY_FAILED_WARNING,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -96,7 +96,13 @@ export function findExistingOAuthConnectionMatch(
|
|||||||
export function buildOAuthConnectionCreatePayload(
|
export function buildOAuthConnectionCreatePayload(
|
||||||
provider: string,
|
provider: string,
|
||||||
tokenData: Record<string, any>,
|
tokenData: Record<string, any>,
|
||||||
expiresAt: string | null
|
expiresAt: string | null,
|
||||||
|
degradedProject?: {
|
||||||
|
testStatus: "degraded";
|
||||||
|
errorCode: string;
|
||||||
|
lastErrorType: string;
|
||||||
|
lastError: string;
|
||||||
|
} | null
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
@@ -104,7 +110,17 @@ export function buildOAuthConnectionCreatePayload(
|
|||||||
...tokenData,
|
...tokenData,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
tokenExpiresAt: expiresAt,
|
tokenExpiresAt: expiresAt,
|
||||||
testStatus: "active" as const,
|
// #11284: degraded when Cloud Code projectId discovery failed at connect
|
||||||
|
// time — the row is saved (refresh token stored, request-time bootstrap
|
||||||
|
// can self-heal) but visibly NOT active.
|
||||||
|
testStatus: degradedProject?.testStatus ?? ("active" as const),
|
||||||
|
...(degradedProject
|
||||||
|
? {
|
||||||
|
errorCode: degradedProject.errorCode,
|
||||||
|
lastErrorType: degradedProject.lastErrorType,
|
||||||
|
lastError: degradedProject.lastError,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,20 @@ type AntigravityTokenPayload = {
|
|||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Why no Cloud Code projectId was discovered at connect time (#11284).
|
||||||
|
* - "requires_manual_project": Google answered onboardUser with 200 but no
|
||||||
|
* cloudaicompanionProject in the body — the account must bring its own GCP
|
||||||
|
* project (BYOP, #8491). Retrying can never succeed.
|
||||||
|
* - "discovery_failed": loadCodeAssist/onboardUser errored, timed out, or
|
||||||
|
* still returned empty after a successful onboarding round-trip.
|
||||||
|
*/
|
||||||
|
type AntigravityProjectDiscoveryOutcome = "requires_manual_project" | "discovery_failed";
|
||||||
type AntigravityPostExchange = {
|
type AntigravityPostExchange = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
tierId: string;
|
tierId: string;
|
||||||
userInfo: { email?: string };
|
userInfo: { email?: string };
|
||||||
|
projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) {
|
async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) {
|
||||||
@@ -150,6 +160,8 @@ async function postExchangeAntigravity(
|
|||||||
|
|
||||||
let projectId = "";
|
let projectId = "";
|
||||||
let tierId = "legacy-tier";
|
let tierId = "legacy-tier";
|
||||||
|
// #11284: classify WHY discovery fails instead of silently swallowing it.
|
||||||
|
let loadFailed = false;
|
||||||
try {
|
try {
|
||||||
const response = await fetchFirstOk(
|
const response = await fetchFirstOk(
|
||||||
config.loadCodeAssistEndpoints,
|
config.loadCodeAssistEndpoints,
|
||||||
@@ -160,6 +172,7 @@ async function postExchangeAntigravity(
|
|||||||
projectId = extractProjectId(data);
|
projectId = extractProjectId(data);
|
||||||
tierId = extractCodeAssistOnboardTierId(data);
|
tierId = extractCodeAssistOnboardTierId(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
loadFailed = true;
|
||||||
console.log("Failed to load code assist:", error);
|
console.log("Failed to load code assist:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,21 +181,57 @@ async function postExchangeAntigravity(
|
|||||||
} else if (config.onboardUserEndpoints.length > 0) {
|
} else if (config.onboardUserEndpoints.length > 0) {
|
||||||
// Accounts without an existing Cloud Code project need one bounded inline
|
// Accounts without an existing Cloud Code project need one bounded inline
|
||||||
// onboarding attempt before loadCodeAssist can discover their project.
|
// onboarding attempt before loadCodeAssist can discover their project.
|
||||||
|
let onboardedWithoutProject = false;
|
||||||
try {
|
try {
|
||||||
await fetchFirstOk(
|
const response = await fetchFirstOk(
|
||||||
config.onboardUserEndpoints,
|
config.onboardUserEndpoints,
|
||||||
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
|
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
|
||||||
POSTEXCHANGE_TIMEOUT_MS
|
POSTEXCHANGE_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
const retryResponse = await fetchFirstOk(
|
// Google BYOP (#8491): a 200 WITHOUT cloudaicompanionProject in the
|
||||||
config.loadCodeAssistEndpoints,
|
// onboardUser body means no project was created and none ever will be —
|
||||||
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
|
// standard-tier/personal accounts must bring their own GCP project.
|
||||||
POSTEXCHANGE_TIMEOUT_MS
|
// A body that DOES carry one (string or {id}) is a real onboarding
|
||||||
);
|
// success; the retry loadCodeAssist below picks the id up (it can lag).
|
||||||
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>);
|
const bodyText = await response.text().catch(() => "");
|
||||||
} catch {
|
if (bodyText && !bodyText.includes("cloudaicompanionProject")) {
|
||||||
// Lazy request-time bootstrap retries if onboarding or discovery is unavailable.
|
console.log(
|
||||||
|
"[oauth] antigravity onboardUser succeeded without creating a project — Google BYOP (user-defined GCP project) required"
|
||||||
|
);
|
||||||
|
onboardedWithoutProject = true;
|
||||||
|
}
|
||||||
|
if (!onboardedWithoutProject) {
|
||||||
|
const retryResponse = await fetchFirstOk(
|
||||||
|
config.loadCodeAssistEndpoints,
|
||||||
|
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
|
||||||
|
POSTEXCHANGE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>);
|
||||||
|
// Prefer the id straight from the onboarding response when discovery
|
||||||
|
// lags behind server-side project creation.
|
||||||
|
if (!projectId) {
|
||||||
|
projectId = extractProjectId(
|
||||||
|
(await new Response(bodyText).json().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[oauth] antigravity inline onboarding/discovery failed:", error);
|
||||||
}
|
}
|
||||||
|
if (!projectId) {
|
||||||
|
return {
|
||||||
|
userInfo,
|
||||||
|
projectId,
|
||||||
|
tierId,
|
||||||
|
projectDiscoveryOutcome: onboardedWithoutProject
|
||||||
|
? "requires_manual_project"
|
||||||
|
: "discovery_failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (loadFailed) {
|
||||||
|
// No onboarding path configured and discovery hard-failed — do not report
|
||||||
|
// this account as healthy-with-no-project (#11284).
|
||||||
|
return { userInfo, projectId, tierId, projectDiscoveryOutcome: "discovery_failed" };
|
||||||
}
|
}
|
||||||
return { userInfo, projectId, tierId };
|
return { userInfo, projectId, tierId };
|
||||||
}
|
}
|
||||||
@@ -199,6 +248,9 @@ function mapAntigravityTokens(
|
|||||||
scope: tokens.scope,
|
scope: tokens.scope,
|
||||||
email: extra?.userInfo?.email,
|
email: extra?.userInfo?.email,
|
||||||
projectId: extra?.projectId,
|
projectId: extra?.projectId,
|
||||||
|
// #11284: let the OAuth route reject connects that ended without a Cloud
|
||||||
|
// Code project instead of persisting a dead "active" row.
|
||||||
|
projectDiscoveryOutcome: extra?.projectDiscoveryOutcome,
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
clientProfile,
|
clientProfile,
|
||||||
projectId: extra?.projectId,
|
projectId: extra?.projectId,
|
||||||
|
|||||||
64
tests/unit/antigravity-empty-project-selection.test.ts
Normal file
64
tests/unit/antigravity-empty-project-selection.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* #11284 — Selection-side safety net for Antigravity accounts with no stored
|
||||||
|
* Cloud Code projectId.
|
||||||
|
*
|
||||||
|
* Production evidence (VPS docker `omniroute`, 2026-08-24): a pool can hold
|
||||||
|
* healthy accounts WITH projectIds alongside accounts whose projectId is
|
||||||
|
* empty and which were never confirmed missing (no errorCode) — those
|
||||||
|
* empty-but-unconfirmed rows still win round-robin slots, burn the request on
|
||||||
|
* loadCodeAssist discovery + 422, and drag the whole combo circuit down.
|
||||||
|
*
|
||||||
|
* Contract pinned here (`antigravityProjectPersist.ts`, quota-strategy copy):
|
||||||
|
* - connections with an EMPTY stored projectId are skipped whenever at
|
||||||
|
* least one sibling carries one;
|
||||||
|
* - when NO connection has a stored project the pool passes through
|
||||||
|
* unchanged (fresh installs keep their lazy-discovery path — #2334);
|
||||||
|
* - confirmed-missing rows (errorCode="missing_project_id") stay excluded
|
||||||
|
* even when they carry a stale stored id (regression guard for the
|
||||||
|
* persistence-module twin `antigravityProjectPersistence.ts`).
|
||||||
|
*
|
||||||
|
* Run: node --import tsx/esm --test tests/unit/antigravity-empty-project-selection.test.ts
|
||||||
|
*/
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { preferAntigravityConnectionsWithStoredProject } from "../../open-sse/services/antigravityProjectPersist.ts";
|
||||||
|
|
||||||
|
const withProject = { id: "a", projectId: "proj-1" };
|
||||||
|
const withoutProject = { id: "d", projectId: null, providerSpecificData: {} };
|
||||||
|
const confirmedMissingWithStaleId = {
|
||||||
|
id: "f",
|
||||||
|
errorCode: "missing_project_id",
|
||||||
|
projectId: "stale-proj",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("#11284: skips empty-projectId siblings when a healthier account exists", () => {
|
||||||
|
const pool = [withoutProject, withProject];
|
||||||
|
assert.deepEqual(
|
||||||
|
preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id),
|
||||||
|
["a"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#11284: skips confirmed-missing rows even with a stale stored id", () => {
|
||||||
|
const pool = [confirmedMissingWithStaleId, withProject];
|
||||||
|
assert.deepEqual(
|
||||||
|
preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id),
|
||||||
|
["a"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#11284: keeps the full pool when ONLY confirmed-missing rows exist (never empty)", () => {
|
||||||
|
const pool = [confirmedMissingWithStaleId];
|
||||||
|
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#11284: never empties the pool when every row lacks a projectId", () => {
|
||||||
|
const pool = [withoutProject, { id: "e", providerSpecificData: {} }];
|
||||||
|
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#11284: single connection passes through untouched (lazy discovery still applies)", () => {
|
||||||
|
const pool = [withoutProject];
|
||||||
|
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
|
||||||
|
});
|
||||||
90
tests/unit/antigravity-missing-project-autodisable.test.ts
Normal file
90
tests/unit/antigravity-missing-project-autodisable.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* #11284 — Auto-disable Antigravity connections whose Cloud Code project is
|
||||||
|
* confirmed missing, so credential selection rotates to healthy siblings
|
||||||
|
* instead of re-dispatching into a guaranteed 422 on every request.
|
||||||
|
*
|
||||||
|
* Production evidence (VPS docker `omniroute`, 2026-08-24): five rows carried
|
||||||
|
* project_id="" with NO missing-project marker — nothing excluded them from
|
||||||
|
* selection, so each dispatch paid the discovery round-trip and failed.
|
||||||
|
*
|
||||||
|
* Contract: `markAntigravityMissingCloudCodeProject()` must persist the
|
||||||
|
* typed marker (errorCode/lastErrorType) AND `isActive: false` +
|
||||||
|
* `testStatus: "unavailable"` (recoverable — NOT a terminal status), while
|
||||||
|
* `persistDiscoveredAntigravityProjectId()` re-enables the row when a project
|
||||||
|
* is later discovered at request time.
|
||||||
|
*
|
||||||
|
* Run: node --import tsx/esm --test tests/unit/antigravity-missing-project-autodisable.test.ts
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ag-11284-"));
|
||||||
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||||
|
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "ag-11284-test-secret";
|
||||||
|
|
||||||
|
const core = await import("../../src/lib/db/core.ts");
|
||||||
|
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||||
|
const {
|
||||||
|
markAntigravityMissingCloudCodeProject,
|
||||||
|
persistDiscoveredAntigravityProjectId,
|
||||||
|
} = await import("../../open-sse/services/antigravityProjectPersistence.ts");
|
||||||
|
|
||||||
|
async function resetStorage() {
|
||||||
|
core.resetDbInstance();
|
||||||
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await resetStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createConnection() {
|
||||||
|
return providersDb.createProviderConnection({
|
||||||
|
provider: "antigravity",
|
||||||
|
authType: "oauth",
|
||||||
|
name: "autodisable-test",
|
||||||
|
email: `autodisable-${Date.now()}@example.test`,
|
||||||
|
accessToken: "token",
|
||||||
|
refreshToken: "refresh",
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
providerSpecificData: { tier: "g1-pro-tier" },
|
||||||
|
isActive: true,
|
||||||
|
testStatus: "active",
|
||||||
|
}) as Promise<{ id: string; providerSpecificData: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("confirmed-missing project disables the connection for selection", async () => {
|
||||||
|
const connection = await createConnection();
|
||||||
|
|
||||||
|
markAntigravityMissingCloudCodeProject(connection.id);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||||
|
assert.equal(updated?.isActive, false, "selection must skip disabled accounts");
|
||||||
|
assert.equal(updated?.testStatus, "unavailable");
|
||||||
|
assert.equal(updated?.errorCode, "missing_project_id");
|
||||||
|
assert.equal(updated?.lastErrorType, "oauth_missing_project_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovery of a projectId later re-enables the connection", async () => {
|
||||||
|
const connection = await createConnection();
|
||||||
|
|
||||||
|
markAntigravityMissingCloudCodeProject(connection.id);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
persistDiscoveredAntigravityProjectId(
|
||||||
|
connection.id,
|
||||||
|
"recovered-project-99",
|
||||||
|
connection.providerSpecificData as Record<string, unknown>
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const healed = await providersDb.getProviderConnectionById(connection.id);
|
||||||
|
assert.equal(healed?.projectId, "recovered-project-99");
|
||||||
|
assert.equal(healed?.isActive, true, "healthy accounts return to rotation");
|
||||||
|
assert.equal(healed?.testStatus, "active");
|
||||||
|
assert.ok(!healed?.errorCode);
|
||||||
|
});
|
||||||
@@ -78,7 +78,11 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown
|
|||||||
assert.equal(payload.error?.code, "missing_project_id");
|
assert.equal(payload.error?.code, "missing_project_id");
|
||||||
assert.equal(payload.error?.type, "oauth_missing_project_id");
|
assert.equal(payload.error?.type, "oauth_missing_project_id");
|
||||||
assert.equal(bootstrapCalls, 1);
|
assert.equal(bootstrapCalls, 1);
|
||||||
assert.equal(persisted?.testStatus, "active");
|
// #11284: a CONFIRMED missing project disables the account (recoverable,
|
||||||
|
// not terminal) so selection rotates to healthy siblings — and
|
||||||
|
// persistDiscoveredAntigravityProjectId re-enables it on recovery.
|
||||||
|
assert.equal(persisted?.isActive, false);
|
||||||
|
assert.equal(persisted?.testStatus, "unavailable");
|
||||||
assert.equal(persisted?.rateLimitedUntil, undefined);
|
assert.equal(persisted?.rateLimitedUntil, undefined);
|
||||||
assert.equal(persisted?.errorCode, "missing_project_id");
|
assert.equal(persisted?.errorCode, "missing_project_id");
|
||||||
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");
|
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");
|
||||||
|
|||||||
197
tests/unit/antigravity-oauth-empty-project-rejection.test.ts
Normal file
197
tests/unit/antigravity-oauth-empty-project-rejection.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* #11284 — Antigravity OAuth must never persist a connection without a Cloud
|
||||||
|
* Code projectId, and the connect-time post-exchange must detect Google's
|
||||||
|
* BYOP ("bring your own project") behavior instead of silently swallowing it.
|
||||||
|
*
|
||||||
|
* Production evidence (VPS docker `omniroute`, 2026-08-24): five antigravity
|
||||||
|
* connections were persisted with project_id="" and
|
||||||
|
* providerSpecificData.projectId="" while tier/subscriptionTier were fully
|
||||||
|
* populated (g1-pro-tier / "Google AI Pro") — proof the token exchange and
|
||||||
|
* loadCodeAssist round-trips SUCCEEDED but Google returned no
|
||||||
|
* cloudaicompanionProject (BYOP accounts, #8491). The old postExchange
|
||||||
|
* swallowed that outcome and the route marked the rows testStatus="active",
|
||||||
|
* so the dashboard showed "Connected" while every model call failed.
|
||||||
|
*
|
||||||
|
* Contract pinned here:
|
||||||
|
* 1. postExchange reports WHY no project was found:
|
||||||
|
* - "requires_manual_project" → onboardUser answered 200 without a
|
||||||
|
* cloudaicompanionProject in the body (Google BYOP).
|
||||||
|
* - "discovery_failed" → loadCodeAssist/onboardUser errored or timed out.
|
||||||
|
* - absent/undefined → projectId discovered normally.
|
||||||
|
* 2. mapTokens surfaces that outcome as tokenData.projectDiscoveryOutcome so
|
||||||
|
* the OAuth route can mark the connection degraded (saved, not active)
|
||||||
|
* instead of silently persisting a false "Connected" row.
|
||||||
|
*
|
||||||
|
* Run: node --import tsx/esm --test tests/unit/antigravity-oauth-empty-project-rejection.test.ts
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
function jsonRes(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postExchange reports requires_manual_project when onboardUser answers 200 without a project (Google BYOP)", async () => {
|
||||||
|
// Fresh account: loadCodeAssist has no project; onboardUser "succeeds" (200)
|
||||||
|
// but its body carries NO cloudaicompanionProject — Google now expects the
|
||||||
|
// user to bring their own GCP project (#8491). The retry loadCodeAssist
|
||||||
|
// still finds nothing. Outcome must be surfaced, not swallowed.
|
||||||
|
let onboardCalls = 0;
|
||||||
|
globalThis.fetch = (async (url: unknown) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("userinfo")) return jsonRes({ email: "byop@example.com" });
|
||||||
|
if (u.includes("loadCodeAssist")) {
|
||||||
|
return jsonRes({
|
||||||
|
allowedTiers: [{ id: "g1-pro-tier", isDefault: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.includes("onboardUser")) {
|
||||||
|
onboardCalls++;
|
||||||
|
// BYOP shape: 200 OK, body without cloudaicompanionProject.
|
||||||
|
return jsonRes({ done: true });
|
||||||
|
}
|
||||||
|
return jsonRes({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||||
|
|
||||||
|
assert.ok(onboardCalls >= 1, "onboarding attempt must run");
|
||||||
|
assert.equal(result.projectId, "", "no project exists for BYOP accounts");
|
||||||
|
assert.equal(
|
||||||
|
result.projectDiscoveryOutcome,
|
||||||
|
"requires_manual_project",
|
||||||
|
"BYOP outcome must be reported so the route marks the connection degraded"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postExchange reports discovery_failed when loadCodeAssist errors (was silently swallowed)", async () => {
|
||||||
|
// Upstream hard-fails: previously this collapsed to console.log + empty
|
||||||
|
// projectId with zero signal. Now it must be classified discovery_failed.
|
||||||
|
globalThis.fetch = (async (url: unknown) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("userinfo")) return jsonRes({ email: "err@example.com" });
|
||||||
|
if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500);
|
||||||
|
if (u.includes("onboardUser")) return jsonRes({ error: "boom" }, 500);
|
||||||
|
return jsonRes({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||||
|
|
||||||
|
assert.equal(result.projectId, "");
|
||||||
|
assert.equal(
|
||||||
|
result.projectDiscoveryOutcome,
|
||||||
|
"discovery_failed",
|
||||||
|
"upstream failures must be classified instead of silently dropped"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postExchange omits projectDiscoveryOutcome when a project is discovered (happy path unchanged)", async () => {
|
||||||
|
globalThis.fetch = (async (url: unknown) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("userinfo")) return jsonRes({ email: "ok@example.com" });
|
||||||
|
if (u.includes("loadCodeAssist")) {
|
||||||
|
return jsonRes({
|
||||||
|
cloudaicompanionProject: "happy-path-project",
|
||||||
|
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.includes("onboardUser")) return jsonRes({ done: true });
|
||||||
|
return jsonRes({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||||
|
|
||||||
|
assert.equal(result.projectId, "happy-path-project");
|
||||||
|
assert.equal(
|
||||||
|
result.projectDiscoveryOutcome,
|
||||||
|
undefined,
|
||||||
|
"successful discovery must not carry an outcome flag"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postExchange reports discovery_failed when onboarding succeeds but retry still finds nothing (propagation/transient)", async () => {
|
||||||
|
// onboardUser returns 200 WITHOUT cloudaicompanionProject in the body but
|
||||||
|
// the retry loadCodeAssist eventually surfaces it — recovery wins, no
|
||||||
|
// outcome flag. (The pure-lag case is covered by the onboard-body fallback.)
|
||||||
|
let lcaCalls = 0;
|
||||||
|
globalThis.fetch = (async (url: unknown) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("userinfo")) return jsonRes({ email: "lag@example.com" });
|
||||||
|
if (u.includes("loadCodeAssist")) {
|
||||||
|
lcaCalls++;
|
||||||
|
return jsonRes({
|
||||||
|
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.includes("onboardUser")) {
|
||||||
|
// Real onboarding success shape: project id present in body.
|
||||||
|
return jsonRes({ done: true, cloudaicompanionProject: { id: "late-project" } });
|
||||||
|
}
|
||||||
|
return jsonRes({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||||
|
|
||||||
|
assert.equal(result.projectId, "late-project");
|
||||||
|
assert.equal(
|
||||||
|
result.projectDiscoveryOutcome,
|
||||||
|
undefined,
|
||||||
|
"recovered projectId means healthy connection"
|
||||||
|
);
|
||||||
|
void lcaCalls;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postExchange still fails when onboarding carries a project but every discovery path stays empty", async () => {
|
||||||
|
// Degenerate upstream: onboardUser body has a project but retry loadCodeAssist
|
||||||
|
// errors — must NOT persist as silently-empty; classify discovery_failed.
|
||||||
|
globalThis.fetch = (async (url: unknown) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("userinfo")) return jsonRes({ email: "lag2@example.com" });
|
||||||
|
if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500);
|
||||||
|
if (u.includes("onboardUser")) {
|
||||||
|
return new Response(null, { status: 500 });
|
||||||
|
}
|
||||||
|
return jsonRes({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||||
|
|
||||||
|
assert.equal(result.projectId, "");
|
||||||
|
assert.equal(result.projectDiscoveryOutcome, "discovery_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mapTokens surfaces projectDiscoveryOutcome for the OAuth route degrade gate", async () => {
|
||||||
|
// The route can only act on what mapTokens hands it — the outcome must
|
||||||
|
// survive into tokenData.
|
||||||
|
globalThis.fetch = (async (url: unknown) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("userinfo")) return jsonRes({ email: "map@example.com" });
|
||||||
|
if (u.includes("loadCodeAssist")) {
|
||||||
|
return jsonRes({ allowedTiers: [{ id: "legacy-tier", isDefault: true }] });
|
||||||
|
}
|
||||||
|
if (u.includes("onboardUser")) return jsonRes({ done: true });
|
||||||
|
return jsonRes({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const tokens = { access_token: "tok" } as never;
|
||||||
|
const extra = await antigravity.postExchange(tokens);
|
||||||
|
const mapped = antigravity.mapTokens(tokens, extra);
|
||||||
|
|
||||||
|
assert.equal(mapped.projectId, "");
|
||||||
|
assert.equal(
|
||||||
|
mapped.projectDiscoveryOutcome,
|
||||||
|
"requires_manual_project",
|
||||||
|
"degrade gate needs the outcome on the mapped payload"
|
||||||
|
);
|
||||||
|
});
|
||||||
50
tests/unit/oauth-route-antigravity-project-gate.test.ts
Normal file
50
tests/unit/oauth-route-antigravity-project-gate.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* #11284 — Antigravity OAuth connect-time DEGRADE marking (maintainer
|
||||||
|
* direction): when Cloud Code projectId discovery failed, the connection is
|
||||||
|
* still saved but with testStatus:"degraded" + typed error markers, so the
|
||||||
|
* dashboard never shows a false "Connected" while request-time bootstrap can
|
||||||
|
* self-heal the row.
|
||||||
|
*
|
||||||
|
* Run: node --import tsx/esm --test tests/unit/oauth-route-antigravity-project-gate.test.ts
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const routeSource = fs.readFileSync(
|
||||||
|
path.join(here, "../../src/app/api/oauth/[provider]/[action]/route.ts"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const persistenceSource = fs.readFileSync(
|
||||||
|
path.join(here, "../../src/lib/oauth/connectionPersistence.ts"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
test("degrade gate is wired into both exchange and poll-callback branches", () => {
|
||||||
|
const callSites =
|
||||||
|
routeSource.match(/antigravityDegradedProjectState\(provider, tokenData\)/g) || [];
|
||||||
|
assert.equal(callSites.length, 2, "gate must run in exchange AND poll-callback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connects are SAVED with degraded status, not rejected", () => {
|
||||||
|
// No 422 rejection in the antigravity project path: the upsert proceeds and
|
||||||
|
// the degraded fields flow into both the update and create payloads.
|
||||||
|
assert.match(routeSource, /testStatus: degradedProject\?\.testStatus \?\? "active"/);
|
||||||
|
assert.match(persistenceSource, /degradedProject\?\.testStatus \?\? \("active" as const\)/);
|
||||||
|
assert.match(routeSource, /warning: degradedProject\.warning/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gate only applies to antigravity and agy, marks typed error fields", () => {
|
||||||
|
const gateSource = fs.readFileSync(
|
||||||
|
path.join(here, "../../src/lib/oauth/antigravityProjectGate.ts"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
assert.match(gateSource, /"antigravity"/);
|
||||||
|
assert.match(gateSource, /"agy"/);
|
||||||
|
assert.match(gateSource, /testStatus: "degraded"/);
|
||||||
|
assert.match(gateSource, /errorCode: "missing_project_id"/);
|
||||||
|
assert.match(gateSource, /lastErrorType: "oauth_missing_project_id"/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user