Skip to content

Commit 88ecb4d

Browse files
authored
Merge pull request #124 from Recoupable-com/test
Merge test to main
2 parents 15f29a1 + abe9c7f commit 88ecb4d

File tree

7 files changed

+309
-0
lines changed

7 files changed

+309
-0
lines changed

lib/chat/__tests__/handleChatGenerate.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ vi.mock("@/lib/keys/getApiKeyDetails", () => ({
1818
getApiKeyDetails: vi.fn(),
1919
}));
2020

21+
vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({
22+
validateOrganizationAccess: vi.fn(),
23+
}));
24+
2125
vi.mock("@/lib/chat/setupChatRequest", () => ({
2226
setupChatRequest: vi.fn(),
2327
}));

lib/chat/__tests__/handleChatStream.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ vi.mock("@/lib/keys/getApiKeyDetails", () => ({
1818
getApiKeyDetails: vi.fn(),
1919
}));
2020

21+
vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({
22+
validateOrganizationAccess: vi.fn(),
23+
}));
24+
2125
vi.mock("@/lib/chat/setupChatRequest", () => ({
2226
setupChatRequest: vi.fn(),
2327
}));

lib/chat/__tests__/integration/chatEndToEnd.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ vi.mock("@/lib/keys/getApiKeyDetails", () => ({
3131
getApiKeyDetails: vi.fn(),
3232
}));
3333

34+
vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({
35+
validateOrganizationAccess: vi.fn(),
36+
}));
37+
3438
// Mock Supabase dependencies
3539
vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({
3640
default: vi.fn(),

lib/chat/__tests__/validateChatRequest.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@ vi.mock("@/lib/keys/getApiKeyDetails", () => ({
1919
getApiKeyDetails: vi.fn(),
2020
}));
2121

22+
vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({
23+
validateOrganizationAccess: vi.fn(),
24+
}));
25+
2226
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
2327
import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId";
2428
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
2529
import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails";
30+
import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess";
2631

2732
const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId);
2833
const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId);
2934
const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId);
3035
const mockGetApiKeyDetails = vi.mocked(getApiKeyDetails);
36+
const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess);
3137

3238
// Helper to create mock NextRequest
3339
function createMockRequest(body: unknown, headers: Record<string, string> = {}): Request {
@@ -417,4 +423,127 @@ describe("validateChatRequest", () => {
417423
expect(result.success).toBe(false);
418424
});
419425
});
426+
427+
describe("organizationId override", () => {
428+
it("accepts organizationId in schema", () => {
429+
const result = chatRequestSchema.safeParse({
430+
prompt: "test",
431+
organizationId: "org-123",
432+
});
433+
expect(result.success).toBe(true);
434+
});
435+
436+
it("uses provided organizationId when user is member of org (bearer token)", async () => {
437+
mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123");
438+
mockValidateOrganizationAccess.mockResolvedValue(true);
439+
440+
const request = createMockRequest(
441+
{ prompt: "Hello", organizationId: "org-456" },
442+
{ authorization: "Bearer valid-jwt-token" },
443+
);
444+
445+
const result = await validateChatRequest(request as any);
446+
447+
expect(result).not.toBeInstanceOf(NextResponse);
448+
expect((result as any).orgId).toBe("org-456");
449+
expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({
450+
accountId: "user-account-123",
451+
organizationId: "org-456",
452+
});
453+
});
454+
455+
it("uses provided organizationId when user is member of org (API key)", async () => {
456+
mockGetApiKeyAccountId.mockResolvedValue("api-key-account-123");
457+
mockGetApiKeyDetails.mockResolvedValue({
458+
accountId: "api-key-account-123",
459+
orgId: null,
460+
});
461+
mockValidateOrganizationAccess.mockResolvedValue(true);
462+
463+
const request = createMockRequest(
464+
{ prompt: "Hello", organizationId: "org-789" },
465+
{ "x-api-key": "personal-api-key" },
466+
);
467+
468+
const result = await validateChatRequest(request as any);
469+
470+
expect(result).not.toBeInstanceOf(NextResponse);
471+
expect((result as any).orgId).toBe("org-789");
472+
expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({
473+
accountId: "api-key-account-123",
474+
organizationId: "org-789",
475+
});
476+
});
477+
478+
it("overwrites API key orgId with provided organizationId when user is member", async () => {
479+
mockGetApiKeyAccountId.mockResolvedValue("org-account-123");
480+
mockGetApiKeyDetails.mockResolvedValue({
481+
accountId: "org-account-123",
482+
orgId: "original-org-123",
483+
});
484+
mockValidateOrganizationAccess.mockResolvedValue(true);
485+
486+
const request = createMockRequest(
487+
{ prompt: "Hello", organizationId: "different-org-456" },
488+
{ "x-api-key": "org-api-key" },
489+
);
490+
491+
const result = await validateChatRequest(request as any);
492+
493+
expect(result).not.toBeInstanceOf(NextResponse);
494+
expect((result as any).orgId).toBe("different-org-456");
495+
});
496+
497+
it("rejects organizationId when user is NOT a member of org", async () => {
498+
mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123");
499+
mockValidateOrganizationAccess.mockResolvedValue(false);
500+
501+
const request = createMockRequest(
502+
{ prompt: "Hello", organizationId: "org-not-member" },
503+
{ authorization: "Bearer valid-jwt-token" },
504+
);
505+
506+
const result = await validateChatRequest(request as any);
507+
508+
expect(result).toBeInstanceOf(NextResponse);
509+
const json = await (result as NextResponse).json();
510+
expect(json.status).toBe("error");
511+
expect(json.message).toBe("Access denied to specified organizationId");
512+
});
513+
514+
it("uses API key orgId when no organizationId is provided", async () => {
515+
mockGetApiKeyAccountId.mockResolvedValue("org-account-123");
516+
mockGetApiKeyDetails.mockResolvedValue({
517+
accountId: "org-account-123",
518+
orgId: "api-key-org-123",
519+
});
520+
521+
const request = createMockRequest(
522+
{ prompt: "Hello" },
523+
{ "x-api-key": "org-api-key" },
524+
);
525+
526+
const result = await validateChatRequest(request as any);
527+
528+
expect(result).not.toBeInstanceOf(NextResponse);
529+
expect((result as any).orgId).toBe("api-key-org-123");
530+
// Should not validate org access when no organizationId is provided
531+
expect(mockValidateOrganizationAccess).not.toHaveBeenCalled();
532+
});
533+
534+
it("returns null orgId when no organizationId provided and bearer token auth", async () => {
535+
mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123");
536+
537+
const request = createMockRequest(
538+
{ prompt: "Hello" },
539+
{ authorization: "Bearer valid-jwt-token" },
540+
);
541+
542+
const result = await validateChatRequest(request as any);
543+
544+
expect(result).not.toBeInstanceOf(NextResponse);
545+
expect((result as any).orgId).toBeNull();
546+
expect(mockValidateOrganizationAccess).not.toHaveBeenCalled();
547+
});
548+
});
420549
});

lib/chat/validateChatRequest.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"
77
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
88
import { getMessages } from "@/lib/messages/getMessages";
99
import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails";
10+
import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess";
1011

1112
export const chatRequestSchema = z
1213
.object({
@@ -17,6 +18,7 @@ export const chatRequestSchema = z
1718
roomId: z.string().optional(),
1819
accountId: z.string().optional(),
1920
artistId: z.string().optional(),
21+
organizationId: z.string().optional(),
2022
model: z.string().optional(),
2123
excludeTools: z.array(z.string()).optional(),
2224
})
@@ -138,6 +140,30 @@ export async function validateChatRequest(
138140
accountId = accountIdOrError;
139141
}
140142

143+
// Handle organizationId override from request body
144+
if (validatedBody.organizationId) {
145+
const hasOrgAccess = await validateOrganizationAccess({
146+
accountId,
147+
organizationId: validatedBody.organizationId,
148+
});
149+
150+
if (!hasOrgAccess) {
151+
return NextResponse.json(
152+
{
153+
status: "error",
154+
message: "Access denied to specified organizationId",
155+
},
156+
{
157+
status: 403,
158+
headers: getCorsHeaders(),
159+
},
160+
);
161+
}
162+
163+
// Use the provided organizationId as orgId
164+
orgId = validatedBody.organizationId;
165+
}
166+
141167
// Normalize chat content:
142168
// - If messages are provided, keep them as-is
143169
// - If only prompt is provided, convert it into a single user UIMessage
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { validateOrganizationAccess } from "../validateOrganizationAccess";
3+
4+
// Mock getAccountOrganizations supabase lib
5+
vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({
6+
getAccountOrganizations: vi.fn(),
7+
}));
8+
9+
import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations";
10+
11+
const mockGetAccountOrganizations = vi.mocked(getAccountOrganizations);
12+
13+
describe("validateOrganizationAccess", () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
18+
describe("account is the organization", () => {
19+
it("returns true when accountId equals organizationId", async () => {
20+
const result = await validateOrganizationAccess({
21+
accountId: "org-123",
22+
organizationId: "org-123",
23+
});
24+
25+
expect(result).toBe(true);
26+
// Should not query database when account IS the org
27+
expect(mockGetAccountOrganizations).not.toHaveBeenCalled();
28+
});
29+
});
30+
31+
describe("account is a member of the organization", () => {
32+
it("returns true when account is a member of the organization", async () => {
33+
mockGetAccountOrganizations.mockResolvedValue([
34+
{
35+
account_id: "member-account-456",
36+
organization_id: "org-123",
37+
created_at: new Date().toISOString(),
38+
organization: null,
39+
},
40+
]);
41+
42+
const result = await validateOrganizationAccess({
43+
accountId: "member-account-456",
44+
organizationId: "org-123",
45+
});
46+
47+
expect(result).toBe(true);
48+
expect(mockGetAccountOrganizations).toHaveBeenCalledWith({
49+
accountId: "member-account-456",
50+
organizationId: "org-123",
51+
});
52+
});
53+
54+
it("returns false when account is NOT a member of the organization", async () => {
55+
mockGetAccountOrganizations.mockResolvedValue([]);
56+
57+
const result = await validateOrganizationAccess({
58+
accountId: "non-member-account-789",
59+
organizationId: "org-123",
60+
});
61+
62+
expect(result).toBe(false);
63+
expect(mockGetAccountOrganizations).toHaveBeenCalledWith({
64+
accountId: "non-member-account-789",
65+
organizationId: "org-123",
66+
});
67+
});
68+
});
69+
70+
describe("invalid inputs", () => {
71+
it("returns false when accountId is empty", async () => {
72+
const result = await validateOrganizationAccess({
73+
accountId: "",
74+
organizationId: "org-123",
75+
});
76+
77+
expect(result).toBe(false);
78+
expect(mockGetAccountOrganizations).not.toHaveBeenCalled();
79+
});
80+
81+
it("returns false when organizationId is empty", async () => {
82+
const result = await validateOrganizationAccess({
83+
accountId: "account-123",
84+
organizationId: "",
85+
});
86+
87+
expect(result).toBe(false);
88+
expect(mockGetAccountOrganizations).not.toHaveBeenCalled();
89+
});
90+
91+
it("returns false when both are empty", async () => {
92+
const result = await validateOrganizationAccess({
93+
accountId: "",
94+
organizationId: "",
95+
});
96+
97+
expect(result).toBe(false);
98+
expect(mockGetAccountOrganizations).not.toHaveBeenCalled();
99+
});
100+
});
101+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations";
2+
3+
export interface ValidateOrganizationAccessParams {
4+
accountId: string;
5+
organizationId: string;
6+
}
7+
8+
/**
9+
* Validates if an account can operate on behalf of an organization.
10+
*
11+
* Access rules:
12+
* - If accountId equals organizationId (account IS the org), access is granted
13+
* - Otherwise, checks if accountId is a member of the organization
14+
*
15+
* @param params - The validation parameters
16+
* @param params.accountId - The account ID to validate
17+
* @param params.organizationId - The organization ID to check access for
18+
* @returns true if access is allowed, false otherwise
19+
*/
20+
export async function validateOrganizationAccess(
21+
params: ValidateOrganizationAccessParams,
22+
): Promise<boolean> {
23+
const { accountId, organizationId } = params;
24+
25+
if (!accountId || !organizationId) {
26+
return false;
27+
}
28+
29+
// Account IS the organization
30+
if (accountId === organizationId) {
31+
return true;
32+
}
33+
34+
// Check if account is a member of the organization
35+
const memberships = await getAccountOrganizations({
36+
accountId,
37+
organizationId,
38+
});
39+
40+
return memberships.length > 0;
41+
}

0 commit comments

Comments
 (0)