DEV Community

Amanda
Amanda

Posted on

Authenticated tests with Playwright, Prisma, Postgres, and NextAuth

Testing authenticated pages is hard. Lets demystify it!

In EddieHub community projects it's common to place authenticated pages behind a GitHub SSO login as it's the perfect fit for an Open Source project but when it comes time to test these pages it proved challenging to create a test that could pass the login flow as close to the user experience as possible.

This flow was based on inspiration from this post which has a similar approach but with a mock server which was not needed for our test.

This implementation was finished up on a Livestream. If you wanna check it out, you can see the replay on YouTube.

And finally before we dive in - this work and blog is a community effort with huge thanks to Eddie Jaoude and Dan B for getting this code finalized and merged. If you are interested in geeking out in the Eddiehub community with us, you can check out the Eddiehub Github Org.

The Project

This code is implemented in a project called The Open Source HealthCheck. This project helps repo owners check quickly if their projects are following best practices and quickly identifies places for improvement.

The following tutorial will show you how mock GitHub OAuth login is implemented in this project.

The Stack

  • NextJS / NextAuth
  • Postgres
  • Prisma
  • Playwright

Note that this tutorial assumes you have this tech stack set up and playwright configured with your database to run in github actions.

Interested in a version of this using MongoDb and Mongoose? Check out the code here in BioDrop. The code is almost the same except for the creation of the database user and some schema modifications.

How to implement : Setup auth

This code will mock the initial login request to GitHub by NextAuth. To read more about the NextAuth GitHub provider, check out the docs.

Prefer to see the full implementation instead of a tutorial, head to the repo

Let's get started...

Create a file called auth.js in tests/setup/auth.js - this file makes sure that all the needed values for the auth process are in place for the test login/logout

  1. Setup a test user in your db. This user will not be stored in your production db but only used in the test environment. What is required to create a user will depend on your schema.
const login = async (
  browser,
  user = {
    name: "Authenticated User",
    email: "authenticated-user@test.com",
  },
) => {
const date = new Date();
  let testUser;

  const userData = {
    email: user.email,
    name: user.name,
    image: "https://github.com/mona.png",
    emailVerified: null,
  };

  try {
    testUser = await prisma.user.upsert({
      where: { email: user.email },
      update: userData,
      create: userData,
    });

    if (!testUser) {
      throw new Error("Failed to create or retrieve test authenticated user");
    }
  } catch (e) {
    const error = "Test authenticated user creation failed";
    console.error(error, e);
    throw new Error(error);
  }
Enter fullscreen mode Exit fullscreen mode

2. Next, we need to creat a valid JWT for GitHub OAuth. The values used here are expected by GitHub including the profile image, access token (this is fake but in a similar structure), user email and name (in the spread ...user) and the sub which is the mock GitHub id. For more information on GitHub OAuth, check out the docs.

  const sessionToken = await encode({
    token: {
      image: "https://github.com/mona.png",
      accessToken: "ggg_zZl1pWIvKkf3UDynZ09zLvuyZsm1yC0YoRPt",
      ...user,
      sub: testUser.id,
    },
    secret: process.env.NEXTAUTH_SECRET,
  });
Enter fullscreen mode Exit fullscreen mode

3. Now that you have the valid JWT, you can create an authenticated user session with Prisma. Of note here is to make sure that your expiry date is in the future. By setting this date to a future date, GitHub won't be called to obtain new tokens while your tests are running.

const session = {
    sessionToken,
    userId: testUser.id,
    expires: new Date(date.getFullYear(), date.getMonth() + 1, 0),
  };

  try {
    await prisma.session.upsert({
      where: {
        sessionToken: sessionToken,
      },
      update: session,
      create: session,
    });
  } catch (e) {
    const error = "Test authenticated session creation failed";
    console.error(error, e);
    throw new Error(error);
  }
Enter fullscreen mode Exit fullscreen mode

4. Next you will create the mock account and insert it into the database. Note here that the access token you use should match the one you used in JWT creation. These values will depend on your database schema.

 const account = {
    type: "oauth",
    provider: "github",
    providerAccountId: testUser.id,
    userId: testUser.id,
    access_token: "ggg_zZl1pWIvKkf3UDynZ09zLvuyZsm1yC0YoRPt",
    token_type: "bearer",
    scope: "read:org,read:user,repo,user:email,test:all",
  };

  try {
    await prisma.account.upsert({
      where: {
        provider_providerAccountId: {
          provider: "github",
          providerAccountId: testUser.id,
        },
      },
      update: account,
      create: account,
    });
  } catch (e) {
    const error = "Test account creation failed";
    console.error(error, e);
    throw new Error(error);
  }
Enter fullscreen mode Exit fullscreen mode

5. Now that everything is setup you have what you need to create a new browser context, add your session token, and browse to a page as an authenticated user.

const context = await browser.newContext();
  await context.addCookies([
    {
      name: "next-auth.session-token",
      value: sessionToken,
      domain: "127.0.0.1",
      path: "/",
      httpOnly: true,
      sameSite: "Lax",
      secure: true,
      expires: -1,
    },
  ]);

  const page = await context.newPage();

  return page;
};
Enter fullscreen mode Exit fullscreen mode

6. Finally, in a separate function create a logout flow that clears out the cookie.

const logout = async (browser) => {
  const context = await browser.newContext();
  await context.clearCookies();

  const page = await context.newPage();

  return page;
};
Enter fullscreen mode Exit fullscreen mode

How to implement : Write the tests

1. In a separate file add.spec.ts in tests/account/repo you will write your tests. Where you do this depends on your approach. In the HealthCheck implementation, all tests from login through authenticated page tests are located in the same file. At the top of the file import your login/logout from the setup

  1. Write a test for "guest cannot login" to ensure an unauthenticated user cannot reach pages they should not. Here you will call logout first to ensure there is no saved authenticated session.
test("Guest user cannot access add repo", async ({ browser }) => {
  const page = await logout(browser);
  await page.goto("/account/repo/add");
  await expect(page).toHaveURL(/\//);
});
Enter fullscreen mode Exit fullscreen mode

3/. Next write tests for your logged in users. In the HealthCheck repo, we wanted to ensure that they could see the authenticated nav and get to an authenticated page. Your tests will depend upon the needs of your app.

test("Logged in user can access add repo", async ({ browser }) => {
  const page = await login(browser);
  await page.goto("/account/repo/add");
  await expect(page).toHaveURL(/account\/repo\/add/);
});

test("Logged in user can see add user nav button", async ({ browser }) => {
  const page = await login(browser);
  await page.goto("/");
  await page.getByRole("link", { name: "Add" }).click();
  await expect(page).toHaveURL(/account\/repo\/add/);
});
Enter fullscreen mode Exit fullscreen mode

Thanks for following along. You can find the full solution here.

If this interests you, stay tuned for part 2 of this blog where we setup a mock server to make GitHub api calls after login.

Top comments (2)

Collapse
 
bobjames profile image
Bob James

never even thought about this, you've also reminded me to implement OAuth

nice work Amanda 👏👏

Collapse
 
amandamartindev profile image
Amanda

Thanks for reading Bob!