Skip to content
Draft
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
23 changes: 23 additions & 0 deletions .env.jwt-testing
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Example environment configuration for local JWT testing
# Copy this file to .env and uncomment the JWT keys to enable JWT authentication tests

# JWT Test Public Key — app uses this to verify tokens (see cypress/fixtures/jwt-keys.json)
# WARNING: TEST KEY ONLY — DO NOT use in production!
#NEXT_PUBLIC_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
#MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj760PNWDk5AWonv/G63Q
#08b1XAqUdCVttXLxr6AEXcJeYWXwDPRZAGKBpMTVcu0SIl7I958ebVx2A1I4dNAZ
#6xCku2bgOzoOiFJqNF1EzaxhHbk2gBQt6q92X5RaPFZh3UUkmvISACoiDH+Mja2W
#kW3o8o4iRWaRUvo0sRpbv+O7PSx+3FBABGZSSz1wV7rz7YMjDUjHCF2gsS3XKeA3
#ZzmwYlLmpxM1kD6h/XloO9OHgH2h2IlOyhm7VkhRYYc1auj5zJYKzKkWfCvbozF+
#rufZNFqMGjlUzmH5KYr4CcnuzYFTN0RxUJrCs1UDh/KbI2wZx3ZXXt4zp4QQNAO0
#RQIDAQAB
#-----END PUBLIC KEY-----"

# JWT Test Private Key — used only by Cypress Node tasks to sign test tokens.
# Must match the public key above. NEVER commit. Omit to skip JWT auth tests that need valid tokens.
# See cypress/fixtures/JWT_TEST_SETUP.md for key generation and rotation (required if keys were ever committed).
#TEST_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
#...
#-----END PRIVATE KEY-----"

# Other environment variables (see .env.example for complete list)
6 changes: 6 additions & 0 deletions .github/workflows/cypress_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ on:
required: true
CYPRESS_USER_COOKIE:
required: true
NEXT_PUBLIC_JWT_PUBLIC_KEY:
required: false
TEST_JWT_PRIVATE_KEY:
required: false

jobs:
cypress:
Expand Down Expand Up @@ -40,6 +44,8 @@ jobs:
CYPRESS_NODE_ENV: test
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_ORCID_API_URL: ${{ secrets.NEXT_PUBLIC_ORCID_API_URL }}
NEXT_PUBLIC_JWT_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_JWT_PUBLIC_KEY }}
TEST_JWT_PRIVATE_KEY: ${{ secrets.TEST_JWT_PRIVATE_KEY }}
SITEMAPS_URL: ${{ secrets.SITEMAPS_URL }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_USER_COOKIE: ${{ secrets.CYPRESS_USER_COOKIE }}
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ Note: `yarn dev` only runs the frontend. If you need the API, use `yarn dev-all`
* `yarn cy:run`: Runs Cypress tests in headless mode.
* `yarn cy:open`: Opens the Cypress Test Runner for interactive testing.

#### JWT Authentication Tests

To run JWT authentication tests with valid tokens:
1. Copy `.env.jwt-testing` to `.env` and uncomment `NEXT_PUBLIC_JWT_PUBLIC_KEY` and `TEST_JWT_PRIVATE_KEY` (matching pair)
2. See `cypress/fixtures/JWT_TEST_SETUP.md` for setup and credential rotation
3. `cypress/fixtures/jwt-keys.json` contains only the public key; the private key is never committed

For more details on JWT testing, see: `docs/JWT_TESTING.md`

### Linting and Formatting

* `yarn lint`: Runs ESLint to check for code quality issues.
Expand Down
58 changes: 56 additions & 2 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,71 @@
import { defineConfig } from 'cypress'
import * as jwt from 'jsonwebtoken'

function normalizeKey(key: string | undefined): string | undefined {
return key?.replace(/\\n/g, '\n')
}

function setupJWTConfigAndTasks(on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) {
const publicKey = normalizeKey(process.env.NEXT_PUBLIC_JWT_PUBLIC_KEY)
if (publicKey) {
config.env.jwtPublicKey = publicKey
config.env.jwtPublicKeyConfigured = true
}

on('task', {
signJWT({ payload, expiresIn }: { payload: { uid: string; name: string }; expiresIn?: string }) {
const privateKey = normalizeKey(process.env.TEST_JWT_PRIVATE_KEY)
if (!privateKey) throw new Error('TEST_JWT_PRIVATE_KEY is required for signJWT task')
const token = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: expiresIn ?? '1h'
})
return token
},
signExpiredJWT({ payload }: { payload: { uid: string; name: string } }) {
const privateKey = normalizeKey(process.env.TEST_JWT_PRIVATE_KEY)
if (!privateKey) throw new Error('TEST_JWT_PRIVATE_KEY is required for signExpiredJWT task')
const token = jwt.sign(
{ ...payload, exp: Math.floor(Date.now() / 1000) - 3600 },
privateKey,
{ algorithm: 'RS256' }
)
return token
},
verifyJWT({ token }: { token: string }) {
const key = normalizeKey(process.env.NEXT_PUBLIC_JWT_PUBLIC_KEY)
if (!key) return null
return new Promise((resolve) => {
jwt.verify(token, key, { algorithms: ['RS256'] }, (err, decoded) => {
if (err) resolve(null)
else resolve(decoded)
})
})
}
})
}

export default defineConfig({
experimentalFetchPolyfill: false,
video: false,
projectId: 'yur1cf',
retries: 2,
e2e: {
setupNodeEvents(on, config) {},
setupNodeEvents(on, config) {
if (process.env.CYPRESS_USER_COOKIE) {
config.env.userCookie = process.env.CYPRESS_USER_COOKIE
}
setupJWTConfigAndTasks(on, config)
return config
},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.test.*',
},
component: {
setupNodeEvents(on, config) { },
setupNodeEvents(on, config) {
setupJWTConfigAndTasks(on, config)
return config
},
specPattern: 'src/components/**/*.test.*',
devServer: {
bundler: 'webpack',
Expand Down
161 changes: 161 additions & 0 deletions cypress/e2e/jwtAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/// <reference types="cypress" />

import { setAuthenticatedSession } from '../support/jwt-helper'

describe('JWT Authentication', () => {
beforeEach(() => {
cy.setCookie('_consent', 'true')
})

describe('Unauthenticated User', () => {
it('should show sign in link when not authenticated', () => {
cy.visit('/')
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})

it('should not show user menu when not authenticated', () => {
cy.visit('/')
cy.get('#sign-in').should('not.exist')
})
})

describe('Authenticated User (with valid JWT)', () => {
beforeEach(() => {
if (!Cypress.env('jwtPublicKeyConfigured')) return
setAuthenticatedSession({ uid: 'test-user-123', name: 'Test User' })
})

it('should display user name when authenticated with valid JWT', () => {
if (!Cypress.env('jwtPublicKeyConfigured')) {
cy.log('Skipping: NEXT_PUBLIC_JWT_PUBLIC_KEY not configured for tests')
return
}
cy.visit('/')
cy.get('#sign-in', { timeout: 30000 }).should('be.visible')
})

it('should show user dropdown menu when authenticated with valid JWT', () => {
if (!Cypress.env('jwtPublicKeyConfigured')) {
cy.log('Skipping: NEXT_PUBLIC_JWT_PUBLIC_KEY not configured for tests')
return
}
cy.visit('/')
cy.get('#sign-in', { timeout: 30000 }).click()
cy.get('[data-cy=settings]').should('be.visible')
})
})

describe('Invalid JWT Token', () => {
it('should handle invalid token gracefully', () => {
// Set an invalid JWT token
const invalidCookie = JSON.stringify({
authenticated: {
access_token: 'invalid.jwt.token'
}
})
cy.setCookie('_datacite', invalidCookie)

cy.visit('/')
// Should behave like unauthenticated user
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})

it('should handle malformed cookie gracefully', () => {
// Set a malformed cookie
cy.setCookie('_datacite', 'not-valid-json')

cy.visit('/')
// Should behave like unauthenticated user
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})

it('should handle missing access_token in cookie', () => {
// Set a cookie without access_token
const incompleteCookie = JSON.stringify({
authenticated: {}
})
cy.setCookie('_datacite', incompleteCookie)

cy.visit('/')
// Should behave like unauthenticated user
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})
})

describe('JWT Verification Error Handling', () => {
it('should not crash the app with expired token', () => {
// Set an expired JWT token (this will be caught by JWT verification)
const expiredCookie = JSON.stringify({
authenticated: {
access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJ0ZXN0LXVzZXIiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZXhwIjoxfQ.invalid'
}
})
cy.setCookie('_datacite', expiredCookie)

cy.visit('/')
// App should still load
cy.get('body').should('be.visible')
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})

it('should not crash the app with corrupted token', () => {
// Set a corrupted JWT token
const corruptedCookie = JSON.stringify({
authenticated: {
access_token: 'corrupted-token-that-is-not-valid'
}
})
cy.setCookie('_datacite', corruptedCookie)

cy.visit('/')
// App should still load
cy.get('body').should('be.visible')
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})
})

describe('Session Persistence', () => {
it('should maintain session across page navigations', () => {
// Set a cookie (even though token verification may fail without proper JWT setup)
const testCookie = JSON.stringify({
authenticated: {
access_token: 'test.token.value'
}
})
cy.setCookie('_datacite', testCookie)

cy.visit('/')
// Cookie should be present on first page
cy.getCookie('_datacite').should('exist')

// Navigate to different pages
cy.visit('/about')
cy.get('body').should('be.visible')

// Cookie should still be present after navigation
cy.getCookie('_datacite').should('exist')

// Authentication state should remain consistent (signed out in this case)
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})

it('should handle session when NEXT_PUBLIC_JWT_PUBLIC_KEY is not configured', () => {
// This test verifies that when NEXT_PUBLIC_JWT_PUBLIC_KEY env var is not set,
// the app doesn't crash but handles it gracefully
const testCookie = JSON.stringify({
authenticated: {
access_token: 'test.token.value'
}
})
cy.setCookie('_datacite', testCookie)

cy.visit('/')
// App should still be functional
cy.get('body').should('be.visible')
// Should show sign in link since JWT verification fails without the key
cy.get('a[href*="sign_in"]', { timeout: 30000 }).should('be.visible')
})
})
})

export {}
Loading
Loading