Skip to content
Closed
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
194 changes: 194 additions & 0 deletions apps/api/v2/src/modules/stripe/stripe.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { BadRequestException, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import Stripe from "stripe";

import { OAuthCallbackState, StripeService } from "./stripe.service";

const mockOAuthToken = jest.fn();
const mockAccountsRetrieve = jest.fn();

const mockFindAllCredentialsByTypeAndUserId = jest.fn().mockResolvedValue([]);
const mockDeleteAppCredentials = jest.fn();
const mockCreateAppCredential = jest.fn();

jest.mock("@/modules/credentials/credentials.repository", () => {
return {
CredentialsRepository: jest.fn().mockImplementation(() => ({
findAllCredentialsByTypeAndUserId: mockFindAllCredentialsByTypeAndUserId,
findCredentialByTypeAndUserId: jest.fn(),
})),
};
});

jest.mock("@/modules/users/users.repository", () => {
return {
UsersRepository: jest.fn().mockImplementation(() => ({})),
UserWithProfile: {},
};
});

jest.mock("@/modules/memberships/memberships.repository", () => {
return {
MembershipsRepository: jest.fn().mockImplementation(() => ({})),
};
});

jest.mock("@/modules/apps/apps.repository", () => {
return {
AppsRepository: jest.fn().mockImplementation(() => ({
getAppBySlug: jest.fn(),
deleteAppCredentials: mockDeleteAppCredentials,
createAppCredential: mockCreateAppCredential,
})),
};
});

jest.mock("@/modules/stripe/utils/newStripeInstance", () => ({
stripeInstance: {
oauth: { token: (...args: unknown[]) => mockOAuthToken(...args) },
accounts: { retrieve: (...args: unknown[]) => mockAccountsRetrieve(...args) },
},
}));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { CredentialsRepository } = require("@/modules/credentials/credentials.repository");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { AppsRepository } = require("@/modules/apps/apps.repository");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { MembershipsRepository } = require("@/modules/memberships/memberships.repository");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { UsersRepository } = require("@/modules/users/users.repository");

describe("StripeService", () => {
let service: StripeService;

const mockState: OAuthCallbackState = {
accessToken: "test-token",
returnTo: "/settings",
};

const mockConfigGet = jest.fn((key: string) => {
const config: Record<string, string> = {
"stripe.apiKey": "sk_test_fake",
"api.url": "https://api.test.com",
"app.baseUrl": "https://app.test.com",
"env.type": "test",
"stripe.teamMonthlyPriceId": "price_test",
};
return config[key] ?? "";
});

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
StripeService,
{ provide: ConfigService, useValue: { get: mockConfigGet } },
{ provide: AppsRepository, useValue: { getAppBySlug: jest.fn(), deleteAppCredentials: mockDeleteAppCredentials, createAppCredential: mockCreateAppCredential } },
{ provide: CredentialsRepository, useValue: { findAllCredentialsByTypeAndUserId: mockFindAllCredentialsByTypeAndUserId } },
{ provide: MembershipsRepository, useValue: {} },
{ provide: UsersRepository, useValue: {} },
],
}).compile();

service = module.get<StripeService>(StripeService);

jest.clearAllMocks();
mockFindAllCredentialsByTypeAndUserId.mockResolvedValue([]);
});

describe("saveStripeAccount", () => {
it("throws UnauthorizedException when userId is falsy", async () => {
await expect(service.saveStripeAccount(mockState, "code_123", 0)).rejects.toThrow(UnauthorizedException);
});

it("succeeds with valid OAuth code and no stripe_user_id", async () => {
mockOAuthToken.mockResolvedValue({ access_token: "tok_123" });

const result = await service.saveStripeAccount(mockState, "code_123", 1);

expect(mockOAuthToken).toHaveBeenCalledWith({
grant_type: "authorization_code",
code: "code_123",
});
expect(result).toEqual({ url: "/settings" });
});

it("retrieves account details when stripe_user_id is present", async () => {
mockOAuthToken.mockResolvedValue({ access_token: "tok_123", stripe_user_id: "acct_123" });
mockAccountsRetrieve.mockResolvedValue({ default_currency: "usd" });

const result = await service.saveStripeAccount(mockState, "code_123", 1);

expect(mockAccountsRetrieve).toHaveBeenCalledWith("acct_123");
expect(result).toEqual({ url: "/settings" });
});

it("throws BadRequestException on StripeInvalidGrantError (expired/invalid code)", async () => {
mockOAuthToken.mockRejectedValue(
new Stripe.errors.StripeInvalidGrantError({
message: "Authorization code has been revoked",
type: "invalid_grant",
})
);

await expect(service.saveStripeAccount(mockState, "expired_code", 1)).rejects.toThrow(BadRequestException);
await expect(service.saveStripeAccount(mockState, "expired_code", 1)).rejects.toThrow(
"Invalid or expired Stripe authorization code"
);
});

it("throws InternalServerErrorException on unexpected Stripe OAuth error", async () => {
mockOAuthToken.mockRejectedValue(new Error("network timeout"));

await expect(service.saveStripeAccount(mockState, "code_123", 1)).rejects.toThrow(
InternalServerErrorException
);
await expect(service.saveStripeAccount(mockState, "code_123", 1)).rejects.toThrow(
"Failed to exchange Stripe authorization code"
);
});

it("throws BadRequestException on StripeInvalidRequestError (deleted account)", async () => {
mockOAuthToken.mockResolvedValue({ access_token: "tok_123", stripe_user_id: "acct_deleted" });
mockAccountsRetrieve.mockRejectedValue(
new Stripe.errors.StripeInvalidRequestError({
message: "No such account",
type: "invalid_request_error",
})
);

await expect(service.saveStripeAccount(mockState, "code_123", 1)).rejects.toThrow(BadRequestException);
await expect(service.saveStripeAccount(mockState, "code_123", 1)).rejects.toThrow(
"Stripe account could not be found"
);
});

it("throws InternalServerErrorException on unexpected Stripe account retrieval error", async () => {
mockOAuthToken.mockResolvedValue({ access_token: "tok_123", stripe_user_id: "acct_123" });
mockAccountsRetrieve.mockRejectedValue(new Error("connection reset"));

await expect(service.saveStripeAccount(mockState, "code_123", 1)).rejects.toThrow(
InternalServerErrorException
);
await expect(service.saveStripeAccount(mockState, "code_123", 1)).rejects.toThrow(
"Failed to retrieve Stripe account details"
);
});

it("deletes existing credentials before creating new ones", async () => {
mockOAuthToken.mockResolvedValue({ access_token: "tok_123" });
mockFindAllCredentialsByTypeAndUserId.mockResolvedValue([{ id: 10 }, { id: 20 }]);

await service.saveStripeAccount(mockState, "code_123", 1);

expect(mockDeleteAppCredentials).toHaveBeenCalledWith([10, 20], 1);
expect(mockCreateAppCredential).toHaveBeenCalledWith(
"stripe_payment",
expect.any(Object),
1,
"stripe"
);
});
});
});
27 changes: 21 additions & 6 deletions apps/api/v2/src/modules/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,30 @@ export class StripeService {
throw new UnauthorizedException("Invalid Access token.");
}

const response = await stripeInstance.oauth.token({
grant_type: "authorization_code",
code: code?.toString(),
});
let response;
try {
response = await stripeInstance.oauth.token({
grant_type: "authorization_code",
code: code?.toString(),
});
Comment on lines +105 to +110
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an early guard for missing OAuth code before calling Stripe.

Line [109] allows an empty/undefined code to reach oauth.token, which then falls into the 500 path. This should fail fast as a client error (400) before the external call.

Suggested fix
   async saveStripeAccount(state: OAuthCallbackState, code: string, userId: number): Promise<{ url: string }> {
     if (!userId) {
       throw new UnauthorizedException("Invalid Access token.");
     }
+    if (!code?.trim()) {
+      throw new BadRequestException("Missing Stripe authorization code.");
+    }
 
     let response;
     try {
       response = await stripeInstance.oauth.token({
         grant_type: "authorization_code",
-        code: code?.toString(),
+        code,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/v2/src/modules/stripe/stripe.service.ts` around lines 105 - 110, Add
an early guard that validates the OAuth `code` before calling
stripeInstance.oauth.token: check the `code` input (used where code?.toString()
is passed to stripeInstance.oauth.token) and if it's null/undefined/empty,
return or throw a client error (HTTP 400) with a clear message instead of
proceeding to call stripeInstance.oauth.token; only call
stripeInstance.oauth.token with the confirmed non-empty code (e.g., use
code.toString() after the guard).

} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidGrantError) {
throw new BadRequestException("Invalid or expired Stripe authorization code. Please try connecting again.");
}
throw new InternalServerErrorException("Failed to exchange Stripe authorization code.");
}

const data: StripeData = { ...response, default_currency: "" };
if (response["stripe_user_id"]) {
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
data["default_currency"] = account.default_currency;
try {
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
data["default_currency"] = account.default_currency;
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
throw new BadRequestException("Stripe account could not be found. It may have been deleted or deactivated.");
}
throw new InternalServerErrorException("Failed to retrieve Stripe account details.");
}
}

const existingCredentials = await this.credentialsRepository.findAllCredentialsByTypeAndUserId(
Expand Down
Loading