Skip to content

Commit

Permalink
Add Blitz Guard (#18)
Browse files Browse the repository at this point in the history
* Initial blitz-guard commit

* Remove try/catch to let Quirrel and the log drain to catch

* Comment for now

* Comment unused parts

* Update blitz-guard package

- Update package
- Add tests
- Add prisma test environment

* Remove unused names

* Fix validation issue

* Add Gh Actions config

* Try new config

* User yarn to call blitz

* New config

* Fix yml 😠

* Change schema random generation

* Remove migrations step

* Unify the test environments

- Make Prisma Test Env inherit from JSDom
- Remove unused test env
- Enable Home test

* Change text for word in fakes

* Test new workflow

* Testing this

* Don't fail on migrate

* Updated prisma executable

* Add createReaction tests

* Reset DATABASE_URL on teardown

* Add deleteReaction tests

* Fix logo width and height

* Remove can from unauthorize

* Removed unused url

* Updates to 0.3.0 of blitz guard (#23)

* Updates to 0.2.0 of blitz guard

* Uses blitz-guard 0.3.0

* Remove unused console.log

* Make ability work

- `cannot` everything before
- Add a fail if no exception is raised

* Remove unnecessary queries

* Throw an error if no message

* Improve README

- Remove unnecessary migrate call
- Add testing guides to README

* Add missing fails

* Update README.md

Co-authored-by: Nicolas Torres <[email protected]>
Co-authored-by: dhernandez-ingsw <[email protected]>
  • Loading branch information
3 people authored Jan 29, 2021
1 parent a2fa003 commit 963a496
Show file tree
Hide file tree
Showing 33 changed files with 1,125 additions and 159 deletions.
41 changes: 41 additions & 0 deletions .github/workflows/run-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Run Tests
on: push

jobs:
container-job:
runs-on: ubuntu-latest
container: node:14

services:
postgres:
image: postgres:12
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: db
ports:
- 5432:5432
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out repository code
uses: actions/checkout@v2

- name: Cache dependencies
uses: actions/cache@v2
with:
path: "**/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Run tests
run: yarn test
env:
DATABASE_URL: postgresql://postgres:postgres@postgres:${{ job.services.postgres.ports[5432] }}/db
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ In a different terminal run de dev version of Quirrel.
$ yarn quirrel
```

## Local testing

You want to copy your file `.env.local` to `.env.test.local` and change the `DATABASE_URL` accordinghly.

To kick off the tests you can `yarn test` or `yarn test:watch`. The watch version will run everything and wait for changes in your code to re run important affected ones.

## Deployment

Pushing to `main` triggers a prod deploy while pushing to any other branch doesn't trigger any preview deployment.
2 changes: 0 additions & 2 deletions app/api/messages/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ export default Queue<CreateType>(
],
})

console.log(slackMessage.ts)

await db.message.update({
data: { slackTimeStamp: slackMessage.ts as string },
where: { id: messageId },
Expand Down
68 changes: 32 additions & 36 deletions app/api/slack/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,53 +50,49 @@ export default CronJob(
"api/slack/inbox",
"0 10 * * 0-5", // Every day of the week at 10am
async (_job) => {
try {
const web = new WebClient(process.env.SLACK_TOKEN)
const users = await db.user.findMany()
const web = new WebClient(process.env.SLACK_TOKEN)
const users = await db.user.findMany()

for (let user of users) {
// Gets the already seen messages
const viewed = await db.messageView.findMany({
where: { userId: user.id },
select: { messageId: true },
})
const viewedIds = viewed.map((v) => v.messageId)
for (let user of users) {
// Gets the already seen messages
const viewed = await db.messageView.findMany({
where: { userId: user.id },
select: { messageId: true },
})
const viewedIds = viewed.map((v) => v.messageId)

// Gets user's channels
const channelsResponse = (await web.users.conversations({
token: user.slackAccessToken,
types: "public_channel,private_channel",
user: user.slackUserId,
})) as WebAPICallResult & { channels: { id: string }[] }
// Gets user's channels
const channelsResponse = (await web.users.conversations({
token: user.slackAccessToken,
types: "public_channel,private_channel",
user: user.slackUserId,
})) as WebAPICallResult & { channels: { id: string }[] }

if (!channelsResponse.ok) continue
if (!channelsResponse.ok) continue

const channelIds = channelsResponse.channels.map((channel) => channel.id)
const channelIds = channelsResponse.channels.map((channel) => channel.id)

const missing = await db.message.findMany({
where: { id: { notIn: viewedIds }, slackChannelId: { in: channelIds } },
select: { title: true, body: true, id: true },
})
const missing = await db.message.findMany({
where: { id: { notIn: viewedIds }, slackChannelId: { in: channelIds } },
select: { title: true, body: true, id: true },
})

if (missing.length === 0) continue
if (missing.length === 0) continue

const conversation = (await web.conversations.open({
users: user.slackUserId,
})) as WebAPICallResult & { channel: { id: string } }
const conversation = (await web.conversations.open({
users: user.slackUserId,
})) as WebAPICallResult & { channel: { id: string } }

if (!conversation.ok) continue
if (!conversation.ok) continue

const blocks = buildTitle({ name: user.name ?? user.email })
const blocks = buildTitle({ name: user.name ?? user.email })

for (let { title, body, id } of missing) {
blocks.push(buildMessage({ title, body }))
blocks.push(buildActions({ id }))
}

web.chat.postMessage({ channel: conversation.channel.id, blocks, text: "" })
for (let { title, body, id } of missing) {
blocks.push(buildMessage({ title, body }))
blocks.push(buildActions({ id }))
}
} catch (e) {
console.error(e)

web.chat.postMessage({ channel: conversation.channel.id, blocks, text: "" })
}
}
)
13 changes: 13 additions & 0 deletions app/channels/lib/getUserChannels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { User } from "@prisma/client"
import { WebAPICallResult, WebClient } from "@slack/web-api"
import { Channel } from "../types"

export default async function getUserChannels({ user }: { user: User }) {
const web = new WebClient(user.slackAccessToken)
const result = (await web.users.conversations({
user: user?.slackUserId,
types: "public_channel,private_channel",
})) as WebAPICallResult & { channels: Channel[] }

return result.channels
}
69 changes: 69 additions & 0 deletions app/guard/ability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NotFoundError } from "blitz"
import db from "db"
import { GuardBuilder, PrismaModelsType } from "@blitz-guard/core"
import getUserChannels from "app/channels/lib/getUserChannels"
import { UpdateMessageInputType } from "app/messages/mutations/updateMessage"
import { DeleteMessageInput } from "app/messages/mutations/deleteMessage"
import { GetMessageInput } from "app/messages/queries/getMessage"
import { CreateReactionInput } from "app/reactions/mutations/createReaction"
import { DeleteReactionInput } from "app/reactions/mutations/deleteReaction"

type ExtendedResourceTypes = PrismaModelsType<typeof db>

export default GuardBuilder<ExtendedResourceTypes>(async (ctx, { can, cannot }) => {
cannot("manage", "all")
if (ctx.session.isAuthorized()) {
// Messages
can("read", "message", async ({ where }: GetMessageInput) => {
const messageRequest = db.message.findFirst({ where: { id: where?.id } })
const userRequest = db.user.findUnique({ where: { id: ctx.session.userId ?? undefined } })
const [message, user] = await db.$transaction([messageRequest, userRequest])
if (!message || !user) throw new NotFoundError()

const channels = await getUserChannels({ user })

return !!channels.find((channel) => channel.id === message.slackChannelId)
})
can("create", "message")
can("update", "message", async ({ where }: UpdateMessageInputType) => {
const message = await db.message.findUnique({ where: { id: where.id } })
if (!message) throw new NotFoundError()
return message.userId === ctx.session.userId
})
can("delete", "message", async ({ where }: DeleteMessageInput) => {
const message = await db.message.findUnique({ where: { id: where.id } })
if (!message) throw new NotFoundError()
return message.userId === ctx.session.userId
})

// Reactions
can("create", "reaction", async ({ data }: CreateReactionInput) => {
const messageRequest = db.message.findUnique({
where: { id: data?.message?.connect?.id as string },
})
const userRequest = db.user.findUnique({ where: { id: ctx.session.userId ?? undefined } })
const [message, user] = await db.$transaction([messageRequest, userRequest])
if (!message || !user) throw new NotFoundError()

const channels = await getUserChannels({ user })

return !!channels.find((channel) => channel.id === message.slackChannelId)
})
can("delete", "reaction", async ({ where }: DeleteReactionInput) => {
const reactionRequest = db.reaction.findUnique({
where: { id: where?.id },
include: { message: true },
})
const userRequest = db.user.findUnique({ where: { id: ctx.session.userId ?? undefined } })
const [reaction, user] = await db.$transaction([reactionRequest, userRequest])
if (!reaction || !user) throw new NotFoundError()

if (reaction.userId !== user.id) return false

const channels = await getUserChannels({ user })

return !!channels.find((channel) => channel.id === reaction?.message?.slackChannelId)
})
can("read", "reaction")
}
})
2 changes: 2 additions & 0 deletions app/guard/queries/getAbility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import Guard from "../ability"
export default Guard.getAbility
57 changes: 57 additions & 0 deletions app/messages/mutations/createMessage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AuthorizationError } from "blitz"
import db from "db"
import faker from "faker"
import { getUserAttributes } from "test/factories"
import { getSession } from "test/utils"
import createMessage from "./createMessage"

beforeAll(async () => {
await db.message.deleteMany({})
await db.user.deleteMany({})
})

afterAll(async () => {
await db.$disconnect()
})

describe("createMessage", () => {
describe("when user is not authorized", () => {
it("throws an AuhtorizationError", async () => {
try {
await createMessage(
{
data: {
body: faker.lorem.paragraphs(),
title: faker.lorem.word(10),
slackChannelId: faker.lorem.slug(),
},
},
getSession()
)
fail("This call should throw an exception")
} catch (e) {
let error = e as AuthorizationError
expect(error.statusCode).toEqual(403)
expect(error.name).toEqual("AuthorizationError")
}
})
})

it("creates the message", async () => {
const user = await db.user.create(getUserAttributes())
const previousCount = await db.message.count()
const returnedValue = await createMessage(
{
data: {
body: faker.lorem.paragraphs(),
title: faker.lorem.word(10),
slackChannelId: faker.lorem.slug(),
},
},
getSession({ user })
)
const currentCount = await db.message.count()
expect(currentCount).toEqual(previousCount + 1)
expect(returnedValue.id).not.toBeNull()
})
})
7 changes: 5 additions & 2 deletions app/messages/mutations/createMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { Ctx } from "blitz"
import db, { Prisma } from "db"
import { CreateMessageInput } from "app/messages/validations"
import CreateQueue from "app/api/messages/create"
import Guard from "app/guard/ability"

type CreateMessageInputType = Pick<Prisma.MessageCreateArgs, "data">

export default async function createMessage({ data }: CreateMessageInputType, ctx: Ctx) {
async function createMessage({ data }: CreateMessageInputType, ctx: Ctx) {
ctx.session.authorize()
const { title, body, slackChannelId } = CreateMessageInput.parse(data)

Expand All @@ -14,7 +15,7 @@ export default async function createMessage({ data }: CreateMessageInputType, ct
include: { user: true },
})

if (message.user?.slackAccessToken && message.slackChannelId) {
if (message.user?.slackAccessToken && message.slackChannelId && process.env.NODE_ENV !== "test") {
await CreateQueue.enqueue({
userToken: message.user?.slackAccessToken,
channel: message.slackChannelId,
Expand All @@ -26,3 +27,5 @@ export default async function createMessage({ data }: CreateMessageInputType, ct

return message
}

export default Guard.authorize("create", "message", createMessage)
Loading

1 comment on commit 963a496

@vercel
Copy link

@vercel vercel bot commented on 963a496 Jan 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.