mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-16 14:25:22 +00:00
fix(web): Fix carousel perf issue + improvements to withAuth middleware (#507)
This commit is contained in:
parent
660623ac52
commit
c9e864d53a
27 changed files with 1112 additions and 164 deletions
|
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed Bitbucket Cloud pagination not working beyond first page. [#295](https://github.com/sourcebot-dev/sourcebot/issues/295)
|
- Fixed Bitbucket Cloud pagination not working beyond first page. [#295](https://github.com/sourcebot-dev/sourcebot/issues/295)
|
||||||
- Fixed search bar line wrapping. [#501](https://github.com/sourcebot-dev/sourcebot/pull/501)
|
- Fixed search bar line wrapping. [#501](https://github.com/sourcebot-dev/sourcebot/pull/501)
|
||||||
|
- Fixed carousel perf issues. [#507](https://github.com/sourcebot-dev/sourcebot/pull/507)
|
||||||
|
|
||||||
## [4.6.7] - 2025-09-08
|
## [4.6.7] - 2025-09-08
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,10 +161,10 @@ server.tool(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const content: TextContent[] = response.repos.map(repo => {
|
const content: TextContent[] = response.map(repo => {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `id: ${repo.name}\nurl: ${repo.webUrl}`,
|
text: `id: ${repo.repoName}\nurl: ${repo.webUrl}`,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,16 +92,34 @@ export const searchResponseSchema = z.object({
|
||||||
isBranchFilteringEnabled: z.boolean(),
|
isBranchFilteringEnabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const repositorySchema = z.object({
|
enum RepoIndexingStatus {
|
||||||
name: z.string(),
|
NEW = 'NEW',
|
||||||
branches: z.array(z.string()),
|
IN_INDEX_QUEUE = 'IN_INDEX_QUEUE',
|
||||||
|
INDEXING = 'INDEXING',
|
||||||
|
INDEXED = 'INDEXED',
|
||||||
|
FAILED = 'FAILED',
|
||||||
|
IN_GC_QUEUE = 'IN_GC_QUEUE',
|
||||||
|
GARBAGE_COLLECTING = 'GARBAGE_COLLECTING',
|
||||||
|
GARBAGE_COLLECTION_FAILED = 'GARBAGE_COLLECTION_FAILED'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const repositoryQuerySchema = z.object({
|
||||||
|
codeHostType: z.string(),
|
||||||
|
repoId: z.number(),
|
||||||
|
repoName: z.string(),
|
||||||
|
repoDisplayName: z.string().optional(),
|
||||||
|
repoCloneUrl: z.string(),
|
||||||
webUrl: z.string().optional(),
|
webUrl: z.string().optional(),
|
||||||
rawConfig: z.record(z.string(), z.string()).optional(),
|
linkedConnections: z.array(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
})),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
indexedAt: z.coerce.date().optional(),
|
||||||
|
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listRepositoriesResponseSchema = z.object({
|
export const listRepositoriesResponseSchema = repositoryQuerySchema.array();
|
||||||
repos: z.array(repositorySchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fileSourceRequestSchema = z.object({
|
export const fileSourceRequestSchema = z.object({
|
||||||
fileName: z.string(),
|
fileName: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ export type SearchResultChunk = SearchResultFile["chunks"][number];
|
||||||
export type SearchSymbol = z.infer<typeof symbolSchema>;
|
export type SearchSymbol = z.infer<typeof symbolSchema>;
|
||||||
|
|
||||||
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
||||||
export type Repository = ListRepositoriesResponse["repos"][number];
|
|
||||||
|
|
||||||
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
||||||
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"build": "cross-env SKIP_ENV_VALIDATION=1 next build",
|
"build": "cross-env SKIP_ENV_VALIDATION=1 next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "cross-env SKIP_ENV_VALIDATION=1 eslint .",
|
"lint": "cross-env SKIP_ENV_VALIDATION=1 eslint .",
|
||||||
"test": "vitest",
|
"test": "cross-env SKIP_ENV_VALIDATION=1 vitest",
|
||||||
"dev:emails": "email dev --dir ./src/emails",
|
"dev:emails": "email dev --dir ./src/emails",
|
||||||
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
|
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
|
||||||
},
|
},
|
||||||
|
|
@ -212,7 +212,8 @@
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite-tsconfig-paths": "^5.1.3",
|
"vite-tsconfig-paths": "^5.1.3",
|
||||||
"vitest": "^2.1.5"
|
"vitest": "^2.1.5",
|
||||||
|
"vitest-mock-extended": "^3.1.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "19.1.10",
|
"@types/react": "19.1.10",
|
||||||
|
|
|
||||||
48
packages/web/src/__mocks__/prisma.ts
Normal file
48
packages/web/src/__mocks__/prisma.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
|
||||||
|
import { ApiKey, Org, PrismaClient, User } from '@prisma/client';
|
||||||
|
import { beforeEach } from 'vitest';
|
||||||
|
import { mockDeep, mockReset } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReset(prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const prisma = mockDeep<PrismaClient>();
|
||||||
|
|
||||||
|
export const MOCK_ORG: Org = {
|
||||||
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
|
name: SINGLE_TENANT_ORG_NAME,
|
||||||
|
domain: SINGLE_TENANT_ORG_DOMAIN,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
isOnboarded: true,
|
||||||
|
imageUrl: null,
|
||||||
|
metadata: null,
|
||||||
|
memberApprovalRequired: false,
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionStatus: null,
|
||||||
|
stripeLastUpdatedAt: null,
|
||||||
|
inviteLinkEnabled: false,
|
||||||
|
inviteLinkId: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_API_KEY: ApiKey = {
|
||||||
|
name: 'Test API Key',
|
||||||
|
hash: 'apikey',
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
orgId: 1,
|
||||||
|
createdById: '1',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_USER: User = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@test.com',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
hashedPassword: null,
|
||||||
|
emailVerified: null,
|
||||||
|
image: null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||||
import { getOrgMetadata } from "@/lib/utils";
|
import { getOrgMetadata } from "@/lib/utils";
|
||||||
import { getOrgFromDomain } from "./data/org";
|
import { getOrgFromDomain } from "./data/org";
|
||||||
|
import { withOptionalAuthV2 } from "./withAuthV2";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -637,49 +638,47 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
|
||||||
}
|
}
|
||||||
})));
|
})));
|
||||||
|
|
||||||
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
||||||
withAuth((userId) =>
|
withOptionalAuthV2(async ({ org }) => {
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
const repos = await prisma.repo.findMany({
|
||||||
const repos = await prisma.repo.findMany({
|
where: {
|
||||||
where: {
|
orgId: org.id,
|
||||||
orgId: org.id,
|
...(filter.status ? {
|
||||||
...(filter.status ? {
|
repoIndexingStatus: { in: filter.status }
|
||||||
repoIndexingStatus: { in: filter.status }
|
} : {}),
|
||||||
} : {}),
|
...(filter.connectionId ? {
|
||||||
...(filter.connectionId ? {
|
|
||||||
connections: {
|
|
||||||
some: {
|
|
||||||
connectionId: filter.connectionId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
connections: {
|
connections: {
|
||||||
include: {
|
some: {
|
||||||
connection: true,
|
connectionId: filter.connectionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
connections: {
|
||||||
|
include: {
|
||||||
|
connection: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return repos.map((repo) => repositoryQuerySchema.parse({
|
return repos.map((repo) => repositoryQuerySchema.parse({
|
||||||
codeHostType: repo.external_codeHostType,
|
codeHostType: repo.external_codeHostType,
|
||||||
repoId: repo.id,
|
repoId: repo.id,
|
||||||
repoName: repo.name,
|
repoName: repo.name,
|
||||||
repoDisplayName: repo.displayName ?? undefined,
|
repoDisplayName: repo.displayName ?? undefined,
|
||||||
repoCloneUrl: repo.cloneUrl,
|
repoCloneUrl: repo.cloneUrl,
|
||||||
webUrl: repo.webUrl ?? undefined,
|
webUrl: repo.webUrl ?? undefined,
|
||||||
linkedConnections: repo.connections.map(({ connection }) => ({
|
linkedConnections: repo.connections.map(({ connection }) => ({
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
name: connection.name,
|
name: connection.name,
|
||||||
})),
|
})),
|
||||||
imageUrl: repo.imageUrl ?? undefined,
|
imageUrl: repo.imageUrl ?? undefined,
|
||||||
indexedAt: repo.indexedAt ?? undefined,
|
indexedAt: repo.indexedAt ?? undefined,
|
||||||
repoIndexingStatus: repo.repoIndexingStatus,
|
repoIndexingStatus: repo.repoIndexingStatus,
|
||||||
}));
|
}))
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
}));
|
||||||
));
|
|
||||||
|
|
||||||
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
|
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ interface PageProps {
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const languageModels = await getConfiguredLanguageModelsInfo();
|
const languageModels = await getConfiguredLanguageModelsInfo();
|
||||||
const repos = await getRepos(params.domain);
|
const repos = await getRepos();
|
||||||
const searchContexts = await getSearchContexts(params.domain);
|
const searchContexts = await getSearchContexts(params.domain);
|
||||||
const chatInfo = await getChatInfo({ chatId: params.id }, params.domain);
|
const chatInfo = await getChatInfo({ chatId: params.id }, params.domain);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ interface PageProps {
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const languageModels = await getConfiguredLanguageModelsInfo();
|
const languageModels = await getConfiguredLanguageModelsInfo();
|
||||||
const repos = await getRepos(params.domain);
|
const repos = await getRepos();
|
||||||
const searchContexts = await getSearchContexts(params.domain);
|
const searchContexts = await getSearchContexts(params.domain);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
|
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import { env } from "@/env.mjs";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
||||||
import { getConnections } from "@/actions";
|
import { getConnections } from "@/actions";
|
||||||
import { getRepos } from "@/actions";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getRepos } from "@/app/api/(client)/client";
|
||||||
|
|
||||||
export const ErrorNavIndicator = () => {
|
export const ErrorNavIndicator = () => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
@ -19,7 +19,7 @@ export const ErrorNavIndicator = () => {
|
||||||
|
|
||||||
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
|
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
|
||||||
queryKey: ['repos', domain],
|
queryKey: ['repos', domain],
|
||||||
queryFn: () => unwrapServiceError(getRepos(domain)),
|
queryFn: () => unwrapServiceError(getRepos()),
|
||||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
|
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { RepositoryCarousel } from "./repositoryCarousel";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
import { unwrapServiceError } from "@/lib/utils";
|
||||||
import { getRepos } from "@/actions";
|
import { getRepos } from "@/app/api/(client)/client";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,6 +22,8 @@ interface RepositorySnapshotProps {
|
||||||
repos: RepositoryQuery[];
|
repos: RepositoryQuery[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_REPOS_TO_DISPLAY_IN_CAROUSEL = 15;
|
||||||
|
|
||||||
export function RepositorySnapshot({
|
export function RepositorySnapshot({
|
||||||
repos: initialRepos,
|
repos: initialRepos,
|
||||||
}: RepositorySnapshotProps) {
|
}: RepositorySnapshotProps) {
|
||||||
|
|
@ -29,7 +31,7 @@ export function RepositorySnapshot({
|
||||||
|
|
||||||
const { data: repos, isPending, isError } = useQuery({
|
const { data: repos, isPending, isError } = useQuery({
|
||||||
queryKey: ['repos', domain],
|
queryKey: ['repos', domain],
|
||||||
queryFn: () => unwrapServiceError(getRepos(domain)),
|
queryFn: () => unwrapServiceError(getRepos()),
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||||
placeholderData: initialRepos,
|
placeholderData: initialRepos,
|
||||||
});
|
});
|
||||||
|
|
@ -78,7 +80,9 @@ export function RepositorySnapshot({
|
||||||
</Link>
|
</Link>
|
||||||
{` indexed`}
|
{` indexed`}
|
||||||
</span>
|
</span>
|
||||||
<RepositoryCarousel repos={indexedRepos} />
|
<RepositoryCarousel
|
||||||
|
repos={indexedRepos.slice(0, MAX_REPOS_TO_DISPLAY_IN_CAROUSEL)}
|
||||||
|
/>
|
||||||
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
Interested in using Sourcebot on your code? Check out our{' '}
|
Interested in using Sourcebot on your code? Check out our{' '}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getRepos } from "@/actions";
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
@ -10,14 +9,15 @@ import { RepoIndexingStatus } from "@prisma/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Loader2Icon } from "lucide-react";
|
import { Loader2Icon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { getRepos } from "@/app/api/(client)/client";
|
||||||
|
|
||||||
export const ProgressNavIndicator = () => {
|
export const ProgressNavIndicator = () => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
const { data: inProgressRepos, isPending, isError } = useQuery({
|
const { data: inProgressRepos, isPending, isError } = useQuery({
|
||||||
queryKey: ['repos', domain],
|
queryKey: ['repos'],
|
||||||
queryFn: () => unwrapServiceError(getRepos(domain)),
|
queryFn: () => unwrapServiceError(getRepos()),
|
||||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
|
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
VscSymbolVariable
|
VscSymbolVariable
|
||||||
} from "react-icons/vsc";
|
} from "react-icons/vsc";
|
||||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
import { getDisplayTime, isServiceError } from "@/lib/utils";
|
import { getDisplayTime, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,12 +37,12 @@ export const useSuggestionsData = ({
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
|
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
|
||||||
queryKey: ["repoSuggestions", domain],
|
queryKey: ["repoSuggestions"],
|
||||||
queryFn: () => getRepos(domain),
|
queryFn: () => unwrapServiceError(getRepos()),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
return data.repos
|
return data
|
||||||
.map(r => ({
|
.map(r => ({
|
||||||
value: r.name,
|
value: r.repoName,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
enabled: suggestionMode === "repo",
|
enabled: suggestionMode === "repo",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
||||||
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
|
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
|
||||||
queryKey: ['repos', domain, connectionId],
|
queryKey: ['repos', domain, connectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const repos = await unwrapServiceError(getRepos(domain, { connectionId }));
|
const repos = await unwrapServiceError(getRepos({ connectionId }));
|
||||||
return repos.sort((a, b) => {
|
return repos.sort((a, b) => {
|
||||||
const priorityA = getPriority(a.repoIndexingStatus);
|
const priorityA = getPriority(a.repoIndexingStatus);
|
||||||
const priorityB = getPriority(b.repoIndexingStatus);
|
const priorityB = getPriority(b.repoIndexingStatus);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default async function Home(props: { params: Promise<{ domain: string }>
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
const models = await getConfiguredLanguageModelsInfo();
|
const models = await getConfiguredLanguageModelsInfo();
|
||||||
const repos = await getRepos(domain);
|
const repos = await getRepos();
|
||||||
const searchContexts = await getSearchContexts(domain);
|
const searchContexts = await getSearchContexts(domain);
|
||||||
const chatHistory = session ? await getUserChatHistory(domain) : [];
|
const chatHistory = session ? await getUserChatHistory(domain) : [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
import { columns, RepositoryColumnInfo } from "./columns";
|
import { columns, RepositoryColumnInfo } from "./columns";
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
import { unwrapServiceError } from "@/lib/utils";
|
||||||
import { getRepos } from "@/actions";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||||
|
|
@ -14,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { AddRepositoryDialog } from "./components/addRepositoryDialog";
|
import { AddRepositoryDialog } from "./components/addRepositoryDialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { getRepos } from "@/app/api/(client)/client";
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
interface RepositoryTableProps {
|
||||||
isAddReposButtonVisible: boolean
|
isAddReposButtonVisible: boolean
|
||||||
|
|
@ -26,9 +26,9 @@ export const RepositoryTable = ({
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
|
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
|
||||||
queryKey: ['repos', domain],
|
queryKey: ['repos'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await unwrapServiceError(getRepos(domain));
|
return await unwrapServiceError(getRepos());
|
||||||
},
|
},
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: true,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getVersionResponseSchema } from "@/lib/schemas";
|
import { getVersionResponseSchema, getReposResponseSchema } from "@/lib/schemas";
|
||||||
import { ServiceError } from "@/lib/serviceError";
|
import { ServiceError } from "@/lib/serviceError";
|
||||||
import { GetVersionResponse } from "@/lib/types";
|
import { GetVersionResponse, GetReposResponse } from "@/lib/types";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
FileSourceResponse,
|
FileSourceResponse,
|
||||||
FileSourceRequest,
|
FileSourceRequest,
|
||||||
ListRepositoriesResponse,
|
|
||||||
SearchRequest,
|
SearchRequest,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
} from "@/features/search/types";
|
} from "@/features/search/types";
|
||||||
import {
|
import {
|
||||||
fileSourceResponseSchema,
|
fileSourceResponseSchema,
|
||||||
listRepositoriesResponseSchema,
|
|
||||||
searchResponseSchema,
|
searchResponseSchema,
|
||||||
} from "@/features/search/schemas";
|
} from "@/features/search/schemas";
|
||||||
|
|
||||||
|
|
@ -47,16 +45,15 @@ export const fetchFileSource = async (body: FileSourceRequest, domain: string):
|
||||||
return fileSourceResponseSchema.parse(result);
|
return fileSourceResponseSchema.parse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRepos = async (domain: string): Promise<ListRepositoriesResponse> => {
|
export const getRepos = async (): Promise<GetReposResponse> => {
|
||||||
const result = await fetch("/api/repos", {
|
const result = await fetch("/api/repos", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Org-Domain": domain,
|
|
||||||
},
|
},
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
||||||
return listRepositoriesResponseSchema.parse(result);
|
return getReposResponseSchema.parse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getVersion = async (): Promise<GetVersionResponse> => {
|
export const getVersion = async (): Promise<GetVersionResponse> => {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,11 @@
|
||||||
'use server';
|
import { getRepos } from "@/actions";
|
||||||
|
|
||||||
import { listRepositories } from "@/features/search/listReposApi";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { serviceErrorResponse } from "@/lib/serviceError";
|
import { serviceErrorResponse } from "@/lib/serviceError";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { GetReposResponse } from "@/lib/types";
|
||||||
|
|
||||||
export const GET = async (request: NextRequest) => {
|
|
||||||
const domain = request.headers.get("X-Org-Domain");
|
|
||||||
const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
|
|
||||||
if (!domain) {
|
|
||||||
return serviceErrorResponse({
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
|
|
||||||
message: "Missing X-Org-Domain header",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await listRepositories(domain, apiKey);
|
export const GET = async () => {
|
||||||
|
const response: GetReposResponse = await getRepos();
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
return serviceErrorResponse(response);
|
return serviceErrorResponse(response);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ export const searchReposTool = tool({
|
||||||
limit: z.number().default(10).describe("Maximum number of repositories to return (default: 10)")
|
limit: z.number().default(10).describe("Maximum number of repositories to return (default: 10)")
|
||||||
}),
|
}),
|
||||||
execute: async ({ query, limit }) => {
|
execute: async ({ query, limit }) => {
|
||||||
const reposResponse = await getRepos(SINGLE_TENANT_ORG_DOMAIN);
|
const reposResponse = await getRepos();
|
||||||
|
|
||||||
if (isServiceError(reposResponse)) {
|
if (isServiceError(reposResponse)) {
|
||||||
return reposResponse;
|
return reposResponse;
|
||||||
|
|
@ -255,7 +255,7 @@ export const listAllReposTool = tool({
|
||||||
description: `Lists all repositories in the codebase. This provides a complete overview of all available repositories.`,
|
description: `Lists all repositories in the codebase. This provides a complete overview of all available repositories.`,
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const reposResponse = await getRepos(SINGLE_TENANT_ORG_DOMAIN);
|
const reposResponse = await getRepos();
|
||||||
|
|
||||||
if (isServiceError(reposResponse)) {
|
if (isServiceError(reposResponse)) {
|
||||||
return reposResponse;
|
return reposResponse;
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { OrgRole } from "@sourcebot/db";
|
|
||||||
import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
|
|
||||||
import { ListRepositoriesResponse } from "./types";
|
|
||||||
import { zoektFetch } from "./zoektClient";
|
|
||||||
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
|
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
|
||||||
|
|
||||||
export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise<ListRepositoriesResponse | ServiceError> => sew(() =>
|
|
||||||
withAuth((userId, _apiKeyHash) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
opts: {
|
|
||||||
Field: 0,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let header: Record<string, string> = {};
|
|
||||||
header = {
|
|
||||||
"X-Tenant-ID": org.id.toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
const listResponse = await zoektFetch({
|
|
||||||
path: "/api/list",
|
|
||||||
body,
|
|
||||||
header,
|
|
||||||
method: "POST",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!listResponse.ok) {
|
|
||||||
return invalidZoektResponse(listResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
const listBody = await listResponse.json();
|
|
||||||
|
|
||||||
const parser = zoektListRepositoriesResponseSchema.transform(({ List }) => ({
|
|
||||||
repos: List.Repos.map((repo) => ({
|
|
||||||
name: repo.Repository.Name,
|
|
||||||
webUrl: repo.Repository.URL.length > 0 ? repo.Repository.URL : undefined,
|
|
||||||
branches: repo.Repository.Branches?.map((branch) => branch.Name) ?? [],
|
|
||||||
rawConfig: repo.Repository.RawConfig ?? undefined,
|
|
||||||
}))
|
|
||||||
} satisfies ListRepositoriesResponse));
|
|
||||||
|
|
||||||
const result = parser.parse(listBody);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
|
||||||
);
|
|
||||||
|
|
@ -94,17 +94,6 @@ export const searchResponseSchema = z.object({
|
||||||
isBranchFilteringEnabled: z.boolean(),
|
isBranchFilteringEnabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const repositorySchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
branches: z.array(z.string()),
|
|
||||||
webUrl: z.string().optional(),
|
|
||||||
rawConfig: z.record(z.string(), z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listRepositoriesResponseSchema = z.object({
|
|
||||||
repos: z.array(repositorySchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fileSourceRequestSchema = z.object({
|
export const fileSourceRequestSchema = z.object({
|
||||||
fileName: z.string(),
|
fileName: z.string(),
|
||||||
repository: z.string(),
|
repository: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// @NOTE : Please keep this file in sync with @sourcebot/mcp/src/types.ts
|
// @NOTE : Please keep this file in sync with @sourcebot/mcp/src/types.ts
|
||||||
import {
|
import {
|
||||||
fileSourceResponseSchema,
|
fileSourceResponseSchema,
|
||||||
listRepositoriesResponseSchema,
|
|
||||||
locationSchema,
|
locationSchema,
|
||||||
searchRequestSchema,
|
searchRequestSchema,
|
||||||
searchResponseSchema,
|
searchResponseSchema,
|
||||||
|
|
@ -19,9 +18,6 @@ export type SearchResultFile = SearchResponse["files"][number];
|
||||||
export type SearchResultChunk = SearchResultFile["chunks"][number];
|
export type SearchResultChunk = SearchResultFile["chunks"][number];
|
||||||
export type SearchSymbol = z.infer<typeof symbolSchema>;
|
export type SearchSymbol = z.infer<typeof symbolSchema>;
|
||||||
|
|
||||||
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
|
||||||
export type Repository = ListRepositoriesResponse["repos"][number];
|
|
||||||
|
|
||||||
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
||||||
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { checkIfOrgDomainExists } from "@/actions";
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isServiceError } from "./utils";
|
import { isServiceError } from "./utils";
|
||||||
|
import { serviceErrorSchema } from "./serviceError";
|
||||||
|
|
||||||
export const secretCreateRequestSchema = z.object({
|
export const secretCreateRequestSchema = z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
|
|
@ -24,7 +25,7 @@ export const repositoryQuerySchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
})),
|
})),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
indexedAt: z.date().optional(),
|
indexedAt: z.coerce.date().optional(),
|
||||||
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
|
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -74,3 +75,5 @@ export const orgDomainSchema = z.string()
|
||||||
export const getVersionResponseSchema = z.object({
|
export const getVersionResponseSchema = z.object({
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getReposResponseSchema = z.union([repositoryQuerySchema.array(), serviceErrorSchema]);
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getVersionResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas";
|
import { getReposResponseSchema, getVersionResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas";
|
||||||
import { tenancyModeSchema } from "@/env.mjs";
|
import { tenancyModeSchema } from "@/env.mjs";
|
||||||
|
|
||||||
export type KeymapType = "default" | "vim";
|
export type KeymapType = "default" | "vim";
|
||||||
|
|
@ -26,4 +26,5 @@ export type NewsItem = {
|
||||||
|
|
||||||
export type TenancyMode = z.infer<typeof tenancyModeSchema>;
|
export type TenancyMode = z.infer<typeof tenancyModeSchema>;
|
||||||
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
|
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
|
||||||
export type SearchContextQuery = z.infer<typeof searchContextQuerySchema>;
|
export type SearchContextQuery = z.infer<typeof searchContextQuerySchema>;
|
||||||
|
export type GetReposResponse = z.infer<typeof getReposResponseSchema>;
|
||||||
733
packages/web/src/withAuthV2.test.ts
Normal file
733
packages/web/src/withAuthV2.test.ts
Normal file
|
|
@ -0,0 +1,733 @@
|
||||||
|
import { expect, test, vi, beforeEach, describe } from 'vitest';
|
||||||
|
import { Session } from 'next-auth';
|
||||||
|
import { notAuthenticated } from './lib/serviceError';
|
||||||
|
import { getAuthContext, getAuthenticatedUser, withAuthV2, withOptionalAuthV2 } from './withAuthV2';
|
||||||
|
import { MOCK_API_KEY, MOCK_ORG, MOCK_USER, prisma } from './__mocks__/prisma';
|
||||||
|
import { OrgRole } from '@sourcebot/db';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
// Defaults to a empty session.
|
||||||
|
auth: vi.fn(async (): Promise<Session | null> => null),
|
||||||
|
headers: vi.fn(async (): Promise<Headers> => new Headers()),
|
||||||
|
hasEntitlement: vi.fn((_entitlement: string) => false),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./auth', () => ({
|
||||||
|
auth: mocks.auth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/env.mjs', () => ({
|
||||||
|
env: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
headers: mocks.headers,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/env.mjs', () => ({
|
||||||
|
env: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/prisma', async () => {
|
||||||
|
// @see: https://github.com/prisma/prisma/discussions/20244#discussioncomment-7976447
|
||||||
|
const actual = await vi.importActual<typeof import('@/__mocks__/prisma')>('@/__mocks__/prisma');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@sourcebot/crypto', () => ({
|
||||||
|
hashSecret: vi.fn((secret: string) => secret),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('server-only', () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@sourcebot/shared', () => ({
|
||||||
|
hasEntitlement: mocks.hasEntitlement,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test utility to set the mock session
|
||||||
|
const setMockSession = (session: Session | null) => {
|
||||||
|
mocks.auth.mockResolvedValue(session);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMockHeaders = (headers: Headers) => {
|
||||||
|
mocks.headers.mockResolvedValue(headers);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to create mock session objects
|
||||||
|
const createMockSession = (overrides: Partial<Session> = {}): Session => ({
|
||||||
|
user: {
|
||||||
|
id: 'test-user-id',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
image: null,
|
||||||
|
...overrides.user,
|
||||||
|
},
|
||||||
|
expires: '2099-01-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.auth.mockResolvedValue(null);
|
||||||
|
mocks.headers.mockResolvedValue(new Headers());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAuthenticatedUser', () => {
|
||||||
|
test('should return a user object if a valid session is present', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).not.toBeUndefined();
|
||||||
|
expect(user?.id).toBe(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a user object if a valid api key is present', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'apikey',
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).not.toBeUndefined();
|
||||||
|
expect(user?.id).toBe(userId);
|
||||||
|
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
hash: 'apikey',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastUsedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return undefined if no session or api key is present', async () => {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return undefined if a api key does not exist', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue(null);
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return undefined if a api key is present but is invalid', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'different-hash',
|
||||||
|
createdById: 'test-user-id',
|
||||||
|
});
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return undefined if a valid session is present but the user is not found', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return undefined if a valid api key is present but the user is not found', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'apikey',
|
||||||
|
createdById: 'test-user-id',
|
||||||
|
});
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAuthContext', () => {
|
||||||
|
test('should return a auth context object if a valid session is present and the user is a member of the organization', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
const authContext = await getAuthContext();
|
||||||
|
expect(authContext).not.toBeUndefined();
|
||||||
|
expect(authContext).toStrictEqual({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.OWNER,
|
||||||
|
});
|
||||||
|
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
const authContext = await getAuthContext();
|
||||||
|
expect(authContext).not.toBeUndefined();
|
||||||
|
expect(authContext).toStrictEqual({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.OWNER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a auth context object if a valid session is present and the user is not a member of the organization. The role should be GUEST.', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
setMockSession(createMockSession({ user: { id: userId } }));
|
||||||
|
const authContext = await getAuthContext();
|
||||||
|
expect(authContext).not.toBeUndefined();
|
||||||
|
expect(authContext).toStrictEqual({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.GUEST,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a auth context object if no auth session is present. The role should be GUEST and the user should be undefined.', async () => {
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const authContext = await getAuthContext();
|
||||||
|
expect(authContext).not.toBeUndefined();
|
||||||
|
expect(authContext).toStrictEqual({
|
||||||
|
user: undefined,
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.GUEST,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withAuthV2', () => {
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.MEMBER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.OWNER,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.OWNER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization (api key)', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'apikey',
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.MEMBER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role (api key)', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.OWNER,
|
||||||
|
});
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'apikey',
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.OWNER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a service error if the user is a member of the organization but does not have a valid session', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
setMockSession(null);
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a service error if the user is a guest of the organization', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.GUEST,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('should return a service error if the user is not a member of the organization (guest role)', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
// user is not a member of the organization
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withOptionalAuthV2', () => {
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.MEMBER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.OWNER,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.OWNER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization (api key)', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'apikey',
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.MEMBER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if a valid session is present and the user is a member of the organization with OWNER role (api key)', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.OWNER,
|
||||||
|
});
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_API_KEY,
|
||||||
|
hash: 'apikey',
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: MOCK_ORG,
|
||||||
|
role: OrgRole.OWNER
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a service error if the user is a member of the organization but does not have a valid session', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
});
|
||||||
|
setMockSession(null);
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a service error if the user is a guest of the organization', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
prisma.userToOrg.findUnique.mockResolvedValue({
|
||||||
|
joinedAt: new Date(),
|
||||||
|
userId: userId,
|
||||||
|
orgId: MOCK_ORG.id,
|
||||||
|
role: OrgRole.GUEST,
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('should return a service error if the user is not a member of the organization (guest role)', async () => {
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
});
|
||||||
|
// user is not a member of the organization
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback with the auth context object if the user is a guest of the organization and the anonymous access entitlement is enabled', async () => {
|
||||||
|
mocks.hasEntitlement.mockReturnValue(true);
|
||||||
|
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
metadata: {
|
||||||
|
anonymousAccessEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).toHaveBeenCalledWith({
|
||||||
|
user: {
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
org: {
|
||||||
|
...MOCK_ORG,
|
||||||
|
metadata: {
|
||||||
|
anonymousAccessEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
role: OrgRole.GUEST,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a service error when anonymousAccessEnabled is true but hasAnonymousAccessEntitlement is false', async () => {
|
||||||
|
mocks.hasEntitlement.mockReturnValue(false);
|
||||||
|
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
metadata: {
|
||||||
|
anonymousAccessEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a service error when hasAnonymousAccessEntitlement is true but anonymousAccessEnabled is false', async () => {
|
||||||
|
mocks.hasEntitlement.mockReturnValue(true);
|
||||||
|
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_USER,
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
prisma.org.findUnique.mockResolvedValue({
|
||||||
|
...MOCK_ORG,
|
||||||
|
metadata: {
|
||||||
|
anonymousAccessEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
|
||||||
|
|
||||||
|
const cb = vi.fn();
|
||||||
|
const result = await withOptionalAuthV2(cb);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
expect(result).toStrictEqual(notAuthenticated());
|
||||||
|
});
|
||||||
|
});
|
||||||
196
packages/web/src/withAuthV2.ts
Normal file
196
packages/web/src/withAuthV2.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
import { hashSecret } from "@sourcebot/crypto";
|
||||||
|
import { ApiKey, Org, OrgRole, User } from "@sourcebot/db";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
|
||||||
|
import { SINGLE_TENANT_ORG_ID } from "./lib/constants";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
import { ErrorCode } from "./lib/errorCodes";
|
||||||
|
import { getOrgMetadata, isServiceError } from "./lib/utils";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
|
||||||
|
interface OptionalAuthContext {
|
||||||
|
user?: User;
|
||||||
|
org: Org;
|
||||||
|
role: OrgRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequiredAuthContext {
|
||||||
|
user: User;
|
||||||
|
org: Org;
|
||||||
|
role: Omit<OrgRole, 'GUEST'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise<T>) => {
|
||||||
|
const authContext = await getAuthContext();
|
||||||
|
|
||||||
|
if (isServiceError(authContext)) {
|
||||||
|
return authContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, org, role } = authContext;
|
||||||
|
|
||||||
|
if (!user || role === OrgRole.GUEST) {
|
||||||
|
return notAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn({ user, org, role });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) => Promise<T>) => {
|
||||||
|
const authContext = await getAuthContext();
|
||||||
|
if (isServiceError(authContext)) {
|
||||||
|
return authContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, org, role } = authContext;
|
||||||
|
|
||||||
|
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
|
||||||
|
const orgMetadata = getOrgMetadata(org);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
!user ||
|
||||||
|
role === OrgRole.GUEST
|
||||||
|
) && (
|
||||||
|
!hasAnonymousAccessEntitlement ||
|
||||||
|
!orgMetadata?.anonymousAccessEnabled
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return notAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn({ user, org, role });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceError> => {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
const org = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return notFound("Organization not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = user ? await prisma.userToOrg.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
orgId: org.id,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: user ?? undefined,
|
||||||
|
org,
|
||||||
|
role: membership?.role ?? OrgRole.GUEST,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuthenticatedUser = async () => {
|
||||||
|
// First, check if we have a valid JWT session.
|
||||||
|
const session = await auth();
|
||||||
|
if (session) {
|
||||||
|
const userId = session.user.id;
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return user ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not, check if we have a valid API key.
|
||||||
|
const apiKeyString = (await headers()).get("X-Sourcebot-Api-Key") ?? undefined;
|
||||||
|
if (apiKeyString) {
|
||||||
|
const apiKey = await getVerifiedApiObject(apiKeyString);
|
||||||
|
if (!apiKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to find the user associated with this api key.
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: apiKey.createdById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last used at timestamp for this api key.
|
||||||
|
await prisma.apiKey.update({
|
||||||
|
where: {
|
||||||
|
hash: apiKey.hash,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a API key object if the API key string is valid, otherwise returns undefined.
|
||||||
|
*/
|
||||||
|
const getVerifiedApiObject = async (apiKeyString: string): Promise<ApiKey | undefined> => {
|
||||||
|
const parts = apiKeyString.split("-");
|
||||||
|
if (parts.length !== 2 || parts[0] !== "sourcebot") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = hashSecret(parts[1]);
|
||||||
|
const apiKey = await prisma.apiKey.findUnique({
|
||||||
|
where: {
|
||||||
|
hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withMinimumOrgRole = async <T>(
|
||||||
|
userRole: OrgRole,
|
||||||
|
minRequiredRole: OrgRole = OrgRole.MEMBER,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const getAuthorizationPrecedence = (role: OrgRole): number => {
|
||||||
|
switch (role) {
|
||||||
|
case OrgRole.GUEST:
|
||||||
|
return 0;
|
||||||
|
case OrgRole.MEMBER:
|
||||||
|
return 1;
|
||||||
|
case OrgRole.OWNER:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
getAuthorizationPrecedence(userRole) < getAuthorizationPrecedence(minRequiredRole)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.FORBIDDEN,
|
||||||
|
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
message: "You do not have sufficient permissions to perform this action.",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
25
yarn.lock
25
yarn.lock
|
|
@ -6939,6 +6939,7 @@ __metadata:
|
||||||
usehooks-ts: "npm:^3.1.0"
|
usehooks-ts: "npm:^3.1.0"
|
||||||
vite-tsconfig-paths: "npm:^5.1.3"
|
vite-tsconfig-paths: "npm:^5.1.3"
|
||||||
vitest: "npm:^2.1.5"
|
vitest: "npm:^2.1.5"
|
||||||
|
vitest-mock-extended: "npm:^3.1.0"
|
||||||
vscode-icons-js: "npm:^11.6.1"
|
vscode-icons-js: "npm:^11.6.1"
|
||||||
zod: "npm:^3.25.74"
|
zod: "npm:^3.25.74"
|
||||||
zod-to-json-schema: "npm:^3.24.5"
|
zod-to-json-schema: "npm:^3.24.5"
|
||||||
|
|
@ -18337,6 +18338,18 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"ts-essentials@npm:>=10.0.0":
|
||||||
|
version: 10.1.1
|
||||||
|
resolution: "ts-essentials@npm:10.1.1"
|
||||||
|
peerDependencies:
|
||||||
|
typescript: ">=4.5.0"
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
checksum: 10c0/8c59148a03eae086e7b1454fa6895e94e2f71385089ccda7e1f720a586749ede7e49ff7338e5f27e44a79f4bed740cc5dc3ad59313769bec028a85fa985685ff
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"ts-interface-checker@npm:^0.1.9":
|
"ts-interface-checker@npm:^0.1.9":
|
||||||
version: 0.1.13
|
version: 0.1.13
|
||||||
resolution: "ts-interface-checker@npm:0.1.13"
|
resolution: "ts-interface-checker@npm:0.1.13"
|
||||||
|
|
@ -18979,6 +18992,18 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"vitest-mock-extended@npm:^3.1.0":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "vitest-mock-extended@npm:3.1.0"
|
||||||
|
dependencies:
|
||||||
|
ts-essentials: "npm:>=10.0.0"
|
||||||
|
peerDependencies:
|
||||||
|
typescript: 3.x || 4.x || 5.x
|
||||||
|
vitest: ">=3.0.0"
|
||||||
|
checksum: 10c0/1d73c15b26c11f06ec8d1e8d3c9c2c727725fe2238e936dc260f1be919e0be67b90c92cab3ce67c79536264c0ffe77ce14d66bce60244429cf6d2e6c8273e36b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"vitest@npm:^2.1.5, vitest@npm:^2.1.9":
|
"vitest@npm:^2.1.5, vitest@npm:^2.1.9":
|
||||||
version: 2.1.9
|
version: 2.1.9
|
||||||
resolution: "vitest@npm:2.1.9"
|
resolution: "vitest@npm:2.1.9"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue