Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remix: destroy access requests #5142

Merged
merged 2 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions utopia-remix/app/models/projectAccessRequest.server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { AccessRequestStatus, UserProjectRole } from '../types'
import {
createAccessRequest,
destroyAccessRequest,
listProjectAccessRequests,
updateAccessRequestStatus,
} from './projectAccessRequest.server'
Expand Down Expand Up @@ -327,4 +328,99 @@ describe('projectAccessRequest', () => {
expect(got[3].User?.name).toBe('person4')
})
})

describe('destroyAccessRequest', () => {
let revokeAllRolesFromUserMock: jest.SpyInstance

afterEach(async () => {
await truncateTables([
prisma.projectID,
prisma.projectAccessRequest,
prisma.projectCollaborator,
prisma.project,
prisma.userDetails,
])

revokeAllRolesFromUserMock.mockRestore()
})

beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestUser(prisma, { id: 'carol' })
await createTestUser(prisma, { id: 'dorothy' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
await createTestProject(prisma, { id: 'three', ownerId: 'alice' })
await createTestProjectAccessRequest(prisma, {
projectId: 'two',
token: 'token1',
userId: 'carol',
status: AccessRequestStatus.PENDING,
})
await createTestProjectAccessRequest(prisma, {
projectId: 'three',
token: 'token2',
userId: 'dorothy',
status: AccessRequestStatus.PENDING,
})
await createTestProjectAccessRequest(prisma, {
projectId: 'two',
token: 'token3',
userId: 'bob',
status: AccessRequestStatus.PENDING,
})

revokeAllRolesFromUserMock = jest.spyOn(permissionsService, 'revokeAllRolesFromUser')
})
it('errors if the project is not found', async () => {
const fn = async () =>
destroyAccessRequest({ projectId: 'UNKNOWN', ownerId: 'bob', token: 'token1' })
await expect(fn).rejects.toThrow('Record to delete does not exist')
})
it('errors if the project is not owned by the user', async () => {
const fn = async () =>
destroyAccessRequest({ projectId: 'two', ownerId: 'bob', token: 'token1' })
await expect(fn).rejects.toThrow('Record to delete does not exist')
})
it('errors if the token is not found', async () => {
const fn = async () =>
destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'tokenWRONG' })
await expect(fn).rejects.toThrow('Record to delete does not exist')
})
it('errors if the token exists but not on that project', async () => {
const fn = async () =>
destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'token2' })
await expect(fn).rejects.toThrow('Record to delete does not exist')
})
it('deletes the request and revokes roles on FGA', async () => {
const existing = await prisma.projectAccessRequest.findMany({ where: { project_id: 'two' } })
expect(existing.length).toBe(2)
await destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'token1' })
const current = await prisma.projectAccessRequest.findMany({
where: { project_id: 'two' },
})
expect(current.length).toBe(1)
expect(current[0].user_id).toBe('bob')

expect(revokeAllRolesFromUserMock).toHaveBeenCalledWith('two', 'carol')
})
it('rolls back if the FGA revoking fails', async () => {
revokeAllRolesFromUserMock.mockImplementation(() => {
throw new Error('boom')
})

const existing = await prisma.projectAccessRequest.findMany({ where: { project_id: 'two' } })
expect(existing.length).toBe(2)

const fn = async () =>
await destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'token1' })
await expect(fn).rejects.toThrow('boom')

const current = await prisma.projectAccessRequest.findMany({
where: { project_id: 'two' },
})
expect(current.length).toBe(2)
})
})
})
23 changes: 23 additions & 0 deletions utopia-remix/app/models/projectAccessRequest.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,26 @@ export async function listProjectAccessRequests(params: {
User: users.find((u) => u.user_id === r.user_id) ?? null,
}))
}

export async function destroyAccessRequest(params: {
projectId: string
ownerId: string
token: string
}) {
await prisma.$transaction(async (tx) => {
// 1. delete the access request from the DB
const request = await tx.projectAccessRequest.delete({
where: {
project_id_token: {
project_id: params.projectId,
token: params.token,
},
Project: {
owner_id: params.ownerId,
},
},
})
// 2. revoke access on FGA
await permissionsService.revokeAllRolesFromUser(params.projectId, request.user_id)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Params } from '@remix-run/react'
import { prisma } from '../db.server'
import { action } from '../routes/internal.projects.$id.access.request.$token.destroy'
import {
createTestProject,
createTestProjectAccessRequest,
createTestSession,
createTestUser,
newTestRequest,
truncateTables,
} from '../test-util'
import { AccessRequestStatus } from '../types'
import type { ApiResponse } from '../util/api.server'
import { Status } from '../util/statusCodes'

describe('handleDestroyAccessRequest', () => {
afterEach(async () => {
await truncateTables([
prisma.projectID,
prisma.projectAccessRequest,
prisma.projectAccess,
prisma.persistentSession,
prisma.project,
prisma.userDetails,
])
})

beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestUser(prisma, { id: 'carol' })
await createTestSession(prisma, { userId: 'bob', key: 'the-key' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
await createTestProjectAccessRequest(prisma, {
projectId: 'one',
userId: 'alice',
status: AccessRequestStatus.PENDING,
token: 'alice-token',
})
await createTestProjectAccessRequest(prisma, {
projectId: 'one',
userId: 'carol',
status: AccessRequestStatus.PENDING,
token: 'carol-token',
})
})

it('requires a project id', async () => {
const got = await getActionResult(newTestRequest({ method: 'POST' }), {})
expect(got).toEqual({
message: 'Invalid project id',
status: Status.BAD_REQUEST,
error: 'Error',
})
})

it('requires an existing project', async () => {
const got = await getActionResult(
newTestRequest({ method: 'POST', authCookie: 'alice-token' }),
{
id: 'WRONG',
},
)
expect(got).toEqual({
message: 'Project not found',
status: Status.NOT_FOUND,
error: 'Error',
})
})

it('requires a valid auth cookie', async () => {
const got = await getActionResult(newTestRequest({ method: 'POST' }), { id: 'one' })
expect(got).toEqual({
message: 'Project not found',
status: Status.NOT_FOUND,
error: 'Error',
})
})

it('requires an accessible project', async () => {
const got = await getActionResult(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {
id: 'two',
})
expect(got).toEqual({
message: 'Project not found',
status: Status.NOT_FOUND,
error: 'Error',
})
})

it('requires a request token', async () => {
const got = await getActionResult(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {
id: 'one',
})
expect(got).toEqual({
message: 'invalid token',
status: Status.BAD_REQUEST,
error: 'Error',
})
})

it('destroys the access request', async () => {
const got = await getActionResult(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {
id: 'one',
token: 'alice-token',
})
expect(got).toEqual({})

const reqs = await prisma.projectAccessRequest.findMany({ where: { project_id: 'one' } })
expect(reqs.length).toBe(1)
expect(reqs[0].user_id).toBe('carol')
})
})

// TODO it would be good to make this a separate reausable helper that supports both actions and loaders
async function getActionResult(req: Request, params: Params<string>) {
const response = await (action({
request: req,
params: params,
context: {},
}) as Promise<ApiResponse<Record<string, never>>>)
return await response.json()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ActionFunctionArgs } from '@remix-run/node'
import type { Params } from '@remix-run/react'
import { validateProjectAccess } from '../handlers/validators'
import { UserProjectPermission } from '../types'
import { ensure, handle, requireUser } from '../util/api.server'
import { Status } from '../util/statusCodes'
import { destroyAccessRequest } from '../models/projectAccessRequest.server'

export async function action(args: ActionFunctionArgs) {
return handle(args, {
POST: {
handler: handleDestroyAccessRequest,
validator: validateProjectAccess(UserProjectPermission.CAN_MANAGE_PROJECT, {
getProjectId: (params) => params.id,
}),
},
})
}

export async function handleDestroyAccessRequest(req: Request, params: Params<string>) {
const user = await requireUser(req)

const projectId = params.id
ensure(projectId != null, 'invalid project id', Status.BAD_REQUEST)

const token = params.token
ensure(token != null && typeof token === 'string', 'invalid token', Status.BAD_REQUEST)

await destroyAccessRequest({
projectId: projectId,
ownerId: user.user_id,
token: token,
})

return {}
}
Loading