Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/apiv2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { expect } from "chai";
import * as nock from "nock";
import AbortController from "abort-controller";
const proxySetup = require("proxy");

Check warning on line 5 in src/apiv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Require statement not part of import statement

Check warning on line 5 in src/apiv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

import { Client, CLI_OAUTH_PROJECT_NUMBER } from "./apiv2";
import { FirebaseError } from "./error";
Expand Down Expand Up @@ -162,6 +162,20 @@
expect(nock.isDone()).to.be.true;
});

it("should handle non-JSON error responses gracefully even when responseType is json", async () => {
const xml =
"<?xml version='1.0' encoding='UTF-8'?><Error><Code>BillingDisabled</Code></Error>";
nock("https://example.com").get("/path/to/foo").reply(403, xml);

const c = new Client({ urlPrefix: "https://example.com" });
const r = c.request({
method: "GET",
path: "/path/to/foo",
});
await expect(r).to.eventually.be.rejectedWith(FirebaseError, /BillingDisabled/);
expect(nock.isDone()).to.be.true;
});

it("should error with a FirebaseError if an error happens", async () => {
nock("https://example.com").get("/path/to/foo").replyWithError("boom");

Expand Down Expand Up @@ -488,7 +502,7 @@
let targetServer: Server;
let oldProxy: string | undefined;
before(async () => {
proxyServer = proxySetup(createServer());

Check warning on line 505 in src/apiv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 505 in src/apiv2.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
targetServer = createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ proxied: true }));
Expand Down
6 changes: 5 additions & 1 deletion src/apiv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

// Using import would require resolveJsonModule, which seems to break the
// build/output format.
const pkg = require("../package.json");

Check warning on line 19 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Require statement not part of import statement

Check warning on line 19 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
const CLI_VERSION: string = pkg.version;

Check warning on line 20 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .version on an `any` value

Check warning on line 20 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

export const standardHeaders: () => Record<string, string> = () => {
const agent = detectAIAgent();
Expand Down Expand Up @@ -134,7 +134,7 @@

/**
* Gets a singleton access token
* @returns An access token

Check warning on line 137 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
export async function getAccessToken(): Promise<string> {
const valid = auth.haveValidTokens(refreshToken, []);
Expand Down Expand Up @@ -239,7 +239,7 @@
* Makes a request as specified by the options.
* By default, this will:
* - use content-type: application/json
* - assume the HTTP GET method

Check warning on line 242 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Expected only 0 line after block description
*
* @example
* const res = apiv2.request<ResourceType>({
Expand Down Expand Up @@ -477,7 +477,11 @@
} catch (err: unknown) {
// JSON-parse errors are useless. Log the response for better debugging.
this.logResponse(res, text, options);
throw new FirebaseError(`Unable to parse JSON: ${err}`);
if (res.status >= 400) {
body = text as unknown as ResT;
} else {
throw new FirebaseError(`Unable to parse JSON: ${err}`);
}
Comment on lines +480 to +484

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When resolveOnHTTPError is true, the caller expects the returned body to be an object (matching the generic ResT type). If we assign the raw non-JSON text string directly to body, any caller attempting to access properties on it (e.g., body.error.message) will encounter a runtime TypeError (e.g., Cannot read properties of undefined).

To prevent this and maintain type safety/compatibility, we can wrap the raw text in a standard error object structure: { error: { message: text } }. This structure is fully compatible with responseToError (which expects this shape or a string) and ensures that callers using resolveOnHTTPError: true can safely inspect the error message without throwing runtime exceptions.

Suggested change
if (res.status >= 400) {
body = text as unknown as ResT;
} else {
throw new FirebaseError(`Unable to parse JSON: ${err}`);
}
if (res.status >= 400) {
body = { error: { message: text } } as unknown as ResT;
} else {
throw new FirebaseError(`Unable to parse JSON: ${err}`);
}

}
}
} else if (options.responseType === "xml") {
Expand Down
18 changes: 18 additions & 0 deletions src/apphosting/secrets/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,24 @@
undefined,
);
});

it("appends original message for 403 errors", async () => {
gcsm.getSecret.withArgs("project", "secret").rejects(new Error("Forbidden"));

await expect(secrets.upsertSecret("project", "secret")).to.be.rejectedWith("Unexpected error loading secret: Forbidden");

Check failure on line 196 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Replace `"Unexpected·error·loading·secret:·Forbidden"` with `⏎········"Unexpected·error·loading·secret:·Forbidden",⏎······`

Check failure on line 196 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `"Unexpected·error·loading·secret:·Forbidden"` with `⏎········"Unexpected·error·loading·secret:·Forbidden",⏎······`

Check failure on line 196 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `"Unexpected·error·loading·secret:·Forbidden"` with `⏎········"Unexpected·error·loading·secret:·Forbidden",⏎······`
});

it("appends original message for 400 errors", async () => {
gcsm.getSecret.withArgs("project", "secret").rejects(new Error("Bad Request"));

await expect(secrets.upsertSecret("project", "secret")).to.be.rejectedWith("Unexpected error loading secret: Bad Request");

Check failure on line 202 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Replace `"Unexpected·error·loading·secret:·Bad·Request"` with `⏎········"Unexpected·error·loading·secret:·Bad·Request",⏎······`

Check failure on line 202 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `"Unexpected·error·loading·secret:·Bad·Request"` with `⏎········"Unexpected·error·loading·secret:·Bad·Request",⏎······`

Check failure on line 202 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `"Unexpected·error·loading·secret:·Bad·Request"` with `⏎········"Unexpected·error·loading·secret:·Bad·Request",⏎······`
});

it("appends original message for other errors", async () => {
gcsm.getSecret.withArgs("project", "secret").rejects(new Error("Internal Error"));

await expect(secrets.upsertSecret("project", "secret")).to.be.rejectedWith("Unexpected error loading secret: Internal Error");

Check failure on line 208 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Replace `"Unexpected·error·loading·secret:·Internal·Error"` with `⏎········"Unexpected·error·loading·secret:·Internal·Error",⏎······`

Check failure on line 208 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `"Unexpected·error·loading·secret:·Internal·Error"` with `⏎········"Unexpected·error·loading·secret:·Internal·Error",⏎······`

Check failure on line 208 in src/apphosting/secrets/index.spec.ts

View workflow job for this annotation

GitHub Actions / unit (24)

Replace `"Unexpected·error·loading·secret:·Internal·Error"` with `⏎········"Unexpected·error·loading·secret:·Internal·Error",⏎······`
});
});

describe("toMulti", () => {
Expand Down
3 changes: 2 additions & 1 deletion src/apphosting/secrets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ export async function upsertSecret(
existing = await gcsm.getSecret(project, secret);
} catch (err: unknown) {
if (getErrStatus(err) !== 404) {
throw new FirebaseError("Unexpected error loading secret", { original: getError(err) });
const original = getError(err);
throw new FirebaseError(`Unexpected error loading secret: ${original.message}`, { original });
}
await gcsm.createSecret(project, secret, gcsm.labels("apphosting"), location);
return true;
Expand Down
3 changes: 2 additions & 1 deletion src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ export function isBillingError(e: {
return !!e.context?.body?.error?.details?.find((d) => {
return (
d.violations?.find((v) => v.type === "serviceusage/billing-enabled") ||
d.reason === "UREQ_PROJECT_BILLING_NOT_FOUND"
d.reason === "UREQ_PROJECT_BILLING_NOT_FOUND" ||
d.reason === "BILLING_DISABLED"
);
});
}
Expand Down
Loading