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

[legacy-framework] Add Cypress example #2836

Merged
merged 13 commits into from
Oct 14, 2021
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
11 changes: 11 additions & 0 deletions examples/cypress/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# https://EditorConfig.org

root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
3 changes: 3 additions & 0 deletions examples/cypress/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This env file should be checked into source control
# This is the place for default values for all environments
# Values in `.env.local` and `.env.production` will override these values
3 changes: 3 additions & 0 deletions examples/cypress/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
extends: ["blitz"],
}
56 changes: 56 additions & 0 deletions examples/cypress/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# dependencies
node_modules
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
.npm
web_modules/

# blitz
/.blitz/
/.next/
*.sqlite
*.sqlite-journal
.now
.blitz**
blitz-log.log

# misc
.DS_Store

# local env files
.env.local
.env.*.local
.envrc

# Logs
logs
*.log

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Testing
.coverage
*.lcov
.nyc_output
lib-cov

# Caches
*.tsbuildinfo
.eslintcache
.node_repl_history
.yarn-integrity

# Serverless directories
.serverless/

# Stores VSCode versions used for testing VSCode extensions
.vscode-test
7 changes: 7 additions & 0 deletions examples/cypress/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
save-exact=true
legacy-peer-deps=true

public-hoist-pattern[]=next
public-hoist-pattern[]=secure-password
public-hoist-pattern[]=*jest*
public-hoist-pattern[]=@testing-library/*
9 changes: 9 additions & 0 deletions examples/cypress/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.gitkeep
.env*
*.ico
*.lock
db/migrations
.next
.blitz
.yarn
.pnp*
25 changes: 25 additions & 0 deletions examples/cypress/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# **cypress**

This is an example [Cypress](https://www.cypress.io/) integration.

## Getting Started

Migrate the database:

```
blitz prisma migrate dev
```

Run e2e tests:

```
yarn test:e2e
```

Open Cypress dashboard:

```
yarn cypress:open
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
49 changes: 49 additions & 0 deletions examples/cypress/app/auth/components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { AuthenticationError, Link, useMutation } from "blitz"
import { LabeledTextField } from "app/core/components/LabeledTextField"
import { Form, FORM_ERROR } from "app/core/components/Form"
import login from "app/auth/mutations/login"
import { Login } from "app/auth/validations"

type LoginFormProps = {
onSuccess?: () => void
}

export const LoginForm = (props: LoginFormProps) => {
const [loginMutation] = useMutation(login)

return (
<div>
<h1>Login</h1>

<Form
submitText="Login"
schema={Login}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await loginMutation(values)
props.onSuccess?.()
} catch (error: any) {
if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" }
} else {
return {
[FORM_ERROR]:
"Sorry, we had an unexpected error. Please try again. - " + error.toString(),
}
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField name="password" label="Password" placeholder="Password" type="password" />
</Form>

<div style={{ marginTop: "1rem" }}>
Or <Link href="signup">Sign Up</Link>
</div>
</div>
)
}

export default LoginForm
43 changes: 43 additions & 0 deletions examples/cypress/app/auth/components/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useMutation } from "blitz"
import { LabeledTextField } from "app/core/components/LabeledTextField"
import { Form, FORM_ERROR } from "app/core/components/Form"
import signup from "app/auth/mutations/signup"
import { Signup } from "app/auth/validations"

type SignupFormProps = {
onSuccess?: () => void
}

export const SignupForm = (props: SignupFormProps) => {
const [signupMutation] = useMutation(signup)

return (
<div>
<h1>Create an Account</h1>

<Form
submitText="Create Account"
schema={Signup}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await signupMutation(values)
props.onSuccess?.()
} catch (error: any) {
if (error.code === "P2002" && error.meta?.target?.includes("email")) {
// This error comes from Prisma
return { email: "This email is already being used" }
} else {
return { [FORM_ERROR]: error.toString() }
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField name="password" label="Password" placeholder="Password" type="password" />
</Form>
</div>
)
}

export default SignupForm
23 changes: 23 additions & 0 deletions examples/cypress/app/auth/mutations/changePassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NotFoundError, SecurePassword, resolver } from "blitz"
import db from "db"
import { authenticateUser } from "./login"
import { ChangePassword } from "../validations"

export default resolver.pipe(
resolver.zod(ChangePassword),
resolver.authorize(),
async ({ currentPassword, newPassword }, ctx) => {
const user = await db.user.findFirst({ where: { id: ctx.session.userId! } })
if (!user) throw new NotFoundError()

await authenticateUser(user.email, currentPassword)

const hashedPassword = await SecurePassword.hash(newPassword.trim())
await db.user.update({
where: { id: user.id },
data: { hashedPassword },
})

return true
}
)
56 changes: 56 additions & 0 deletions examples/cypress/app/auth/mutations/forgotPassword.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { hash256, Ctx } from "blitz"
import forgotPassword from "./forgotPassword"
import db from "db"
import previewEmail from "preview-email"

beforeEach(async () => {
await db.$reset()
})

const generatedToken = "plain-token"
jest.mock("next/stdlib-server", () => ({
...jest.requireActual<object>("next/stdlib-server")!,
generateToken: () => generatedToken,
}))
jest.mock("preview-email", () => jest.fn())

describe("forgotPassword mutation", () => {
it("does not throw error if user doesn't exist", async () => {
await expect(forgotPassword({ email: "[email protected]" }, {} as Ctx)).resolves.not.toThrow()
})

it("works correctly", async () => {
// Create test user
const user = await db.user.create({
data: {
email: "[email protected]",
tokens: {
// Create old token to ensure it's deleted
create: {
type: "RESET_PASSWORD",
hashedToken: "token",
expiresAt: new Date(),
sentTo: "[email protected]",
},
},
},
include: { tokens: true },
})

// Invoke the mutation
await forgotPassword({ email: user.email }, {} as Ctx)

const tokens = await db.token.findMany({ where: { userId: user.id } })
const token = tokens[0]

// delete's existing tokens
expect(tokens.length).toBe(1)

expect(token.id).not.toBe(user.tokens[0].id)
expect(token.type).toBe("RESET_PASSWORD")
expect(token.sentTo).toBe(user.email)
expect(token.hashedToken).toBe(hash256(generatedToken))
expect(token.expiresAt > new Date()).toBe(true)
expect(previewEmail).toBeCalled()
})
})
41 changes: 41 additions & 0 deletions examples/cypress/app/auth/mutations/forgotPassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { resolver, generateToken, hash256 } from "blitz"
import db from "db"
import { forgotPasswordMailer } from "mailers/forgotPasswordMailer"
import { ForgotPassword } from "../validations"

const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 4

export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
// 1. Get the user
const user = await db.user.findFirst({ where: { email: email.toLowerCase() } })

// 2. Generate the token and expiration date.
const token = generateToken()
const hashedToken = hash256(token)
const expiresAt = new Date()
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS)

// 3. If user with this email was found
if (user) {
// 4. Delete any existing password reset tokens
await db.token.deleteMany({ where: { type: "RESET_PASSWORD", userId: user.id } })
// 5. Save this new token in the database.
await db.token.create({
data: {
user: { connect: { id: user.id } },
type: "RESET_PASSWORD",
expiresAt,
hashedToken,
sentTo: user.email,
},
})
// 6. Send the email
await forgotPasswordMailer({ to: user.email, token }).send()
} else {
// 7. If no user found wait the same time so attackers can't tell the difference
await new Promise((resolve) => setTimeout(resolve, 750))
}

// 8. Return the same result whether a password reset email was sent or not
return
})
31 changes: 31 additions & 0 deletions examples/cypress/app/auth/mutations/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { resolver, SecurePassword, AuthenticationError } from "blitz"
import db from "db"
import { Login } from "../validations"
import { Role } from "types"

export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
const email = rawEmail.toLowerCase().trim()
const password = rawPassword.trim()
const user = await db.user.findFirst({ where: { email } })
if (!user) throw new AuthenticationError()

const result = await SecurePassword.verify(user.hashedPassword, password)

if (result === SecurePassword.VALID_NEEDS_REHASH) {
// Upgrade hashed password with a more secure hash
const improvedHash = await SecurePassword.hash(password)
await db.user.update({ where: { id: user.id }, data: { hashedPassword: improvedHash } })
}

const { hashedPassword, ...rest } = user
return rest
}

export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ctx) => {
// This throws an error if credentials are invalid
const user = await authenticateUser(email, password)

await ctx.session.$create({ userId: user.id, role: user.role as Role })

return user
})
5 changes: 5 additions & 0 deletions examples/cypress/app/auth/mutations/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Ctx } from "blitz"

export default async function logout(_: any, ctx: Ctx) {
return await ctx.session.$revoke()
}
Loading