diff --git a/.changeset/team-project-context.md b/.changeset/team-project-context.md new file mode 100644 index 00000000..b2f477f0 --- /dev/null +++ b/.changeset/team-project-context.md @@ -0,0 +1,5 @@ +--- +"@shelve/app": patch +--- + +Align nested project API handlers with the team from the URL path. diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.delete.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.delete.ts index d5269fc9..48eef1b6 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.delete.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.delete.ts @@ -1,21 +1,15 @@ import { TeamRole } from '@types' -import { projectIdParamsSchema } from '~~/server/db/zod' export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.OWNER }) + const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.OWNER }) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) - - const project = await new ProjectsService().getProject(projectId) - - await new ProjectsService().deleteProject(projectId, team.id) + await new ProjectsService().deleteProject(project.id, team.id) await logAudit(event, { teamId: team.id, action: 'project.delete', resourceType: 'project', - resourceId: projectId, + resourceId: project.id, metadata: { name: project.name }, }) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.get.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.get.ts index 73448795..af24a5cf 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.get.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.get.ts @@ -1,8 +1,4 @@ -import { projectIdParamsSchema } from '~~/server/db/zod' - export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) - return await new ProjectsService().getProject(projectId) + const { project } = await requireUserTeamProject(event) + return project }) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts index c39baecf..3e917961 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/index.put.ts @@ -15,23 +15,13 @@ const updateProjectSchema = z.object({ syncPolicy: syncPolicySchema, }) -const projectIdParamsSchema = z.object({ - projectId: z.coerce.number({ - error: 'Project ID is required', - }).int().positive(), -}) - export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) - - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) - + const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN }) const body = await readValidatedBody(event, updateProjectSchema.parse) return await new ProjectsService().updateProject({ - id: projectId, + id: project.id, ...body, - teamId: team.id + teamId: team.id, }) }) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts index b229f37c..2b06b34f 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts @@ -1,12 +1,11 @@ import { TeamRole } from '@types' -import { projectIdParamsSchema, groupIdParamsSchema } from '~~/server/db/zod' +import { groupIdParamsSchema } from '~~/server/db/zod' export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) - await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN }) const { groupId } = await getValidatedRouterParams(event, groupIdParamsSchema.parse) + await new VariableGroupsService().getGroupForProject(groupId, project.id) await new VariableGroupsService().deleteGroup(groupId) return { statusCode: 200, message: 'Variable group deleted successfully' } diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.put.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.put.ts index a37e8299..e1b61408 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.put.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.put.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { TeamRole } from '@types' -import { projectIdParamsSchema, groupIdParamsSchema } from '~~/server/db/zod' +import { groupIdParamsSchema } from '~~/server/db/zod' const updateGroupSchema = z.object({ name: z.string().min(1).max(50).trim().optional(), @@ -9,12 +9,12 @@ const updateGroupSchema = z.object({ }) export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) - await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN }) const { groupId } = await getValidatedRouterParams(event, groupIdParamsSchema.parse) const body = await readValidatedBody(event, updateGroupSchema.parse) + await new VariableGroupsService().getGroupForProject(groupId, project.id) + const group = await new VariableGroupsService().updateGroup({ id: groupId, ...body, diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.get.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.get.ts index 4ec9c61a..0324451f 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.get.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.get.ts @@ -1,9 +1,4 @@ -import { projectIdParamsSchema } from '~~/server/db/zod' - export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) - - return await new VariableGroupsService().getGroups(projectId) + const { project } = await requireUserTeamProject(event) + return await new VariableGroupsService().getGroups(project.id) }) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.post.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.post.ts index 16956ba7..6a0ddcef 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.post.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/index.post.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { TeamRole } from '@types' -import { projectIdParamsSchema } from '~~/server/db/zod' const createGroupSchema = z.object({ name: z.string({ error: 'Group name is required' }).min(1).max(50).trim(), @@ -9,14 +8,12 @@ const createGroupSchema = z.object({ }) export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN }) const body = await readValidatedBody(event, createGroupSchema.parse) const group = await new VariableGroupsService().createGroup({ ...body, - projectId, + projectId: project.id, }) return { statusCode: 201, group } diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.delete.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.delete.ts index 4526e7ab..106e2b3f 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.delete.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.delete.ts @@ -1,22 +1,18 @@ import { TeamRole } from '@types' -import { projectIdParamsSchema, variableIdParamsSchema } from '~~/server/db/zod' +import { variableIdParamsSchema } from '~~/server/db/zod' export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.OWNER }) + const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.OWNER }) const { variableId } = await getValidatedRouterParams(event, variableIdParamsSchema.parse) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) const existing = await db.query.variables.findFirst({ where: and( eq(schema.variables.id, variableId), - eq(schema.variables.projectId, projectId), + eq(schema.variables.projectId, project.id), ), }) if (!existing) throw createError({ statusCode: 404, statusMessage: 'Variable not found' }) - const project = await new ProjectsService().getProject(existing.projectId) - await new VariablesService(event).deleteVariable(variableId) void logAudit(event, { @@ -26,7 +22,7 @@ export default eventHandler(async (event) => { resourceId: variableId, metadata: { key: existing.key, - projectId: existing.projectId, + projectId: project.id, projectName: project.name, }, }) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts index a89d27f8..815ec3f5 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/[variableId]/index.put.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { TeamRole } from '@types' -import { projectIdParamsSchema, variableIdParamsSchema } from '~~/server/db/zod' +import { variableIdParamsSchema } from '~~/server/db/zod' const updateVariableSchema = z.object({ autoUppercase: z.boolean().optional(), @@ -18,21 +18,18 @@ const updateVariableSchema = z.object({ }) export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) + const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN }) const { variableId } = await getValidatedRouterParams(event, variableIdParamsSchema.parse) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) const body = await readValidatedBody(event, updateVariableSchema.parse) const existing = await db.query.variables.findFirst({ where: and( eq(schema.variables.id, variableId), - eq(schema.variables.projectId, projectId), + eq(schema.variables.projectId, project.id), ), }) if (!existing) throw createError({ statusCode: 404, statusMessage: 'Variable not found' }) - const project = await new ProjectsService().getProject(existing.projectId) const environmentIds = [...new Set(body.values.map(v => v.environmentId))] await assertPushAllowedForEnvironmentIds(environmentIds, team.id, project.syncPolicy) @@ -52,7 +49,7 @@ export default eventHandler(async (event) => { resourceId: variableId, metadata: { key: body.autoUppercase ? body.key.toUpperCase() : body.key, - projectId: existing.projectId, + projectId: project.id, projectName: project.name, }, }) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/env/[envId].get.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/env/[envId].get.ts index a79acbe6..f096b9c8 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/env/[envId].get.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/env/[envId].get.ts @@ -1,11 +1,8 @@ import { z } from 'zod' import type { EnvVarExport } from '@types' -import { projectIdParamsSchema } from '~~/server/db/zod' export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const { team, project } = await requireUserTeamProject(event) const { envId } = await getValidatedRouterParams(event, z.object({ envId: z.coerce.number({ error: 'Environment ID is required', @@ -14,14 +11,12 @@ export default eventHandler(async (event) => { await requireTokenScope(event, { teamId: team.id, - projectId, + projectId: project.id, environmentId: envId, permission: 'read', }) const variablesService = new VariablesService(event) - - const project = await new ProjectsService().getProject(projectId) const environmentName = await getEnvironmentName(envId, team.id) void logAudit(event, { @@ -30,16 +25,16 @@ export default eventHandler(async (event) => { resourceType: 'environment', resourceId: envId, metadata: { - projectId, + projectId: project.id, projectName: project.name, environmentName, }, }) variablesService.incrementStatAsync(team.id, 'pull') - const result = await variablesService.getVariables(projectId, envId) + const result = await variablesService.getVariables(project.id, envId) - if (!result) throw createError({ statusCode: 404, statusMessage: `Variables not found for project ${projectId} and environment ${envId}` }) + if (!result) throw createError({ statusCode: 404, statusMessage: `Variables not found for project ${project.id} and environment ${envId}` }) const decryptedVariables = await variablesService.decryptVariables(result) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/group.patch.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/group.patch.ts index 4879b507..733a62e2 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/group.patch.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/group.patch.ts @@ -7,10 +7,12 @@ const bulkAssignGroupSchema = z.object({ }) export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN }) + const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN }) const { variableIds, groupId } = await readValidatedBody(event, bulkAssignGroupSchema.parse) - await new VariablesService(event).bulkAssignGroup(variableIds, groupId) + if (groupId !== null) { + await new VariableGroupsService().getGroupForProject(groupId, project.id) + } + await new VariablesService(event).bulkAssignGroup(project.id, variableIds, groupId) return { statusCode: 200, message: 'Variables assigned to group', diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.delete.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.delete.ts index a6825029..b4040e10 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.delete.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.delete.ts @@ -2,13 +2,12 @@ import { z } from 'zod' import { TeamRole } from '@types' export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - await requireUserTeam(event, slug, { minRole: TeamRole.OWNER }) + const { project } = await requireUserTeamProject(event, { minRole: TeamRole.OWNER }) const { variables } = await readValidatedBody(event, z.object({ variables: z.array(z.number()).min(1).max(100), }).parse) const variablesService = new VariablesService(event) - await Promise.all(variables.map(id => variablesService.deleteVariable(id))) + await variablesService.deleteVariablesInProject(project.id, variables) return { statusCode: 200, message: 'Variables deleted', diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.get.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.get.ts index c57c4ce0..023c795e 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.get.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.get.ts @@ -1,12 +1,8 @@ -import { projectIdParamsSchema } from '~~/server/db/zod' - export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const { team, project } = await requireUserTeamProject(event) const variablesService = new VariablesService(event) variablesService.incrementStatAsync(team.id, 'pull') - const encryptedVariables = await variablesService.getVariables(projectId) + const encryptedVariables = await variablesService.getVariables(project.id) if (!encryptedVariables) throw createError({ statusCode: 404, statusMessage: 'Project variables not found' }) return await variablesService.decryptVariables(encryptedVariables) diff --git a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts index 89e78078..22cd08ec 100644 --- a/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts +++ b/apps/shelve/server/api/teams/[slug]/projects/[projectId]/variables/index.post.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { projectIdParamsSchema } from '~~/server/db/zod' const createVariablesSchema = z.object({ autoUppercase: z.boolean().optional(), @@ -19,18 +18,15 @@ const createVariablesSchema = z.object({ }) export default eventHandler(async (event) => { - const slug = await getTeamSlugFromEvent(event) - const { team } = await requireUserTeam(event, slug) + const { team, project } = await requireUserTeamProject(event) const body = await readValidatedBody(event, createVariablesSchema.parse) - const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) - const project = await new ProjectsService().getProject(projectId) await assertPushAllowedForEnvironmentIds(body.environmentIds, team.id, project.syncPolicy) for (const environmentId of body.environmentIds) { await requireTokenScope(event, { teamId: team.id, - projectId, + projectId: project.id, environmentId, permission: 'write', }) @@ -38,7 +34,7 @@ export default eventHandler(async (event) => { const variablesService = new VariablesService(event) await variablesService.createVariables(event, { - projectId, + projectId: project.id, autoUppercase: body.autoUppercase, environmentIds: body.environmentIds, variables: body.variables.map(variable => ({ @@ -55,7 +51,7 @@ export default eventHandler(async (event) => { teamId: team.id, action: 'variables.create', resourceType: 'project', - resourceId: projectId, + resourceId: project.id, metadata: { keys: body.variables.map(v => v.key), environmentIds: body.environmentIds, diff --git a/apps/shelve/server/services/projects.ts b/apps/shelve/server/services/projects.ts index 2d46ee0d..c10a6016 100644 --- a/apps/shelve/server/services/projects.ts +++ b/apps/shelve/server/services/projects.ts @@ -23,9 +23,12 @@ export class ProjectsService { const [updatedProject] = await db.update(schema.projects) .set(input) - .where(eq(schema.projects.id, input.id)) + .where(and( + eq(schema.projects.id, input.id), + eq(schema.projects.teamId, input.teamId), + )) .returning() - if (!updatedProject) throw createError({ statusCode: 422, message: 'Failed to update project' }) + if (!updatedProject) throw createError({ statusCode: 404, message: `Project not found with id ${input.id}` }) await clearCache('Projects', updatedProject.teamId) await clearCache('Project', updatedProject.id) @@ -40,6 +43,15 @@ export class ProjectsService { return project }) + /** Resolves a project only when it belongs to the given team (prevents cross-tenant IDOR). */ + async getProjectForTeam(projectId: number, teamId: number): Promise { + const project = await this.getProject(projectId) + if (project.teamId !== teamId) { + throw createError({ statusCode: 404, message: `Project not found with id ${projectId}` }) + } + return project + } + getProjects = withCache('Projects', (teamId: number) => { return db.query.projects.findMany({ where: eq(schema.projects.teamId, teamId), diff --git a/apps/shelve/server/services/variable-groups.ts b/apps/shelve/server/services/variable-groups.ts index f29c39f3..6467805e 100644 --- a/apps/shelve/server/services/variable-groups.ts +++ b/apps/shelve/server/services/variable-groups.ts @@ -2,6 +2,17 @@ import type { VariableGroup, CreateVariableGroupInput, UpdateVariableGroupInput export class VariableGroupsService { + async getGroupForProject(groupId: number, projectId: number): Promise { + const group = await db.query.variableGroups.findFirst({ + where: and( + eq(schema.variableGroups.id, groupId), + eq(schema.variableGroups.projectId, projectId), + ), + }) + if (!group) throw createError({ statusCode: 404, statusMessage: 'Variable group not found' }) + return group + } + async getGroups(projectId: number): Promise { return await db.query.variableGroups.findMany({ where: eq(schema.variableGroups.projectId, projectId), diff --git a/apps/shelve/server/services/variables.ts b/apps/shelve/server/services/variables.ts index c1f0fe3c..238ef45f 100644 --- a/apps/shelve/server/services/variables.ts +++ b/apps/shelve/server/services/variables.ts @@ -194,17 +194,17 @@ export class VariablesService { : variables }) - async bulkAssignGroup(variableIds: number[], groupId: number | null): Promise { + async bulkAssignGroup(projectId: number, variableIds: number[], groupId: number | null): Promise { if (!variableIds.length) return await db.update(schema.variables) .set({ groupId }) - .where(inArray(schema.variables.id, variableIds)) + .where(and( + eq(schema.variables.projectId, projectId), + inArray(schema.variables.id, variableIds), + )) - const first = await db.query.variables.findFirst({ - where: eq(schema.variables.id, variableIds[0]!), - }) - if (first) await clearCache('Variables', first.projectId) + await clearCache('Variables', projectId) } async deleteVariable(id: number): Promise { @@ -217,6 +217,23 @@ export class VariablesService { await clearCache('Variables', deleted.projectId) } + async deleteVariablesInProject(projectId: number, variableIds: number[]): Promise { + if (!variableIds.length) return + + const deleted = await db.delete(schema.variables) + .where(and( + eq(schema.variables.projectId, projectId), + inArray(schema.variables.id, variableIds), + )) + .returning() + + if (deleted.length !== variableIds.length) { + throw createError({ statusCode: 404, statusMessage: 'One or more variables not found' }) + } + + await clearCache('Variables', projectId) + } + async decryptVariables(variables: Variable[]): Promise { return await Promise.all(variables.map(v => this.decryptVariable(v))) } diff --git a/apps/shelve/server/utils/teamProject.ts b/apps/shelve/server/utils/teamProject.ts new file mode 100644 index 00000000..880ad7c9 --- /dev/null +++ b/apps/shelve/server/utils/teamProject.ts @@ -0,0 +1,21 @@ +import type { H3Event } from 'h3' +import type { Member, Project, Team, User } from '@types' +import { TeamRole } from '@types' +import { projectIdParamsSchema } from '../db/zod' +import { ProjectsService } from '../services/projects' +import { getTeamSlugFromEvent, requireUserTeam } from './auth' + +/** + * Requires team membership and that the route's projectId belongs to that team. + * Use on all handlers under /api/teams/[slug]/projects/[projectId]/. + */ +export async function requireUserTeamProject( + event: H3Event, + options?: { minRole?: TeamRole } +): Promise<{ user: User; team: Team; member: Member; project: Project }> { + const slug = await getTeamSlugFromEvent(event) + const { user, team, member } = await requireUserTeam(event, slug, options) + const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse) + const project = await new ProjectsService().getProjectForTeam(projectId, team.id) + return { user, team, member, project } +} diff --git a/apps/shelve/test/unit/team-project-context.test.ts b/apps/shelve/test/unit/team-project-context.test.ts new file mode 100644 index 00000000..c160a0cb --- /dev/null +++ b/apps/shelve/test/unit/team-project-context.test.ts @@ -0,0 +1,36 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import type { Project } from '@types' + +vi.stubGlobal('createError', (opts: { statusCode: number; message?: string }) => { + const err = new Error(opts.message ?? '') as Error & { statusCode: number } + err.statusCode = opts.statusCode + return err +}) +vi.stubGlobal('withCache', (_entity: string, fn: (...args: unknown[]) => unknown) => fn) + +const projectOnA = { id: 10, teamId: 1, name: 'app' } as Project +const projectOnB = { id: 20, teamId: 2, name: 'other' } as Project + +describe('ProjectsService.getProjectForTeam', () => { + beforeEach(() => { + vi.resetModules() + }) + + test('returns the project when it belongs to the team', async () => { + const { ProjectsService } = await import('../../server/services/projects') + const service = new ProjectsService() + vi.spyOn(service, 'getProject').mockResolvedValue(projectOnA) + + await expect(service.getProjectForTeam(projectOnA.id, 1)).resolves.toEqual(projectOnA) + }) + + test('returns 404 when the project belongs to another team', async () => { + const { ProjectsService } = await import('../../server/services/projects') + const service = new ProjectsService() + vi.spyOn(service, 'getProject').mockResolvedValue(projectOnB) + + await expect(service.getProjectForTeam(projectOnB.id, 1)).rejects.toMatchObject({ + statusCode: 404, + }) + }) +})