diff --git a/frontend/website/cypress/e2e/login.cy.ts b/frontend/website/cypress/e2e/login.cy.ts new file mode 100644 index 0000000..516b742 --- /dev/null +++ b/frontend/website/cypress/e2e/login.cy.ts @@ -0,0 +1,170 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +function fillLoginForm(): void { + cy.get('#login-email').type('user@example.com') + cy.get('#login-password').type('correct-password') +} + +describe('password login', () => { + beforeEach(() => { + cy.intercept('GET', '**/api/me', { + statusCode: 401, + body: { error: 'unauthenticated' }, + }).as('me') + }) + + it('logs in and redirects to the dashboard', () => { + cy.intercept('POST', '**/api/login', (request) => { + expect(request.body).to.deep.equal({ + email: 'user@example.com', + password: 'correct-password', + }) + request.reply({ + statusCode: 200, + body: { user: authenticatedUser }, + }) + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.location('pathname').should('equal', '/dashboard') + }) + + it('returns to a safe internal redirect after login', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('login') + + cy.visit('/login?redirect=%2Fdashboard%3Ffocus%3Dtoday') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.location('pathname').should('equal', '/dashboard') + cy.location('search').should('equal', '?focus=today') + }) + + it('ignores an unsafe redirect after login', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('login') + + cy.visit('/login?redirect=https%3A%2F%2Fexample.com') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.location('pathname').should('equal', '/dashboard') + }) + + it('validates required login fields before submitting', () => { + cy.visit('/login') + cy.get('form').submit() + + cy.get('#login-email-error') + .should('have.text', 'Enter a valid email address.') + .and('be.visible') + cy.get('#login-email').should('have.attr', 'aria-invalid', 'true') + cy.get('#login-password-error') + .should('have.text', 'Enter your password.') + .and('be.visible') + cy.get('#login-password').should('have.attr', 'aria-invalid', 'true') + cy.location('pathname').should('equal', '/login') + }) + + it('shows a generic invalid-credentials error', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 401, + body: { error: 'invalid_credentials' }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.get('[role="alert"]') + .should('have.text', 'Email or password is incorrect.') + .and('be.visible') + cy.location('pathname').should('equal', '/login') + }) + + it('shows backend field validation errors', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 422, + body: { + message: 'The email field must be a valid email address.', + errors: { + email: ['The email field must be a valid email address.'], + }, + }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.get('#login-email-error') + .should( + 'have.text', + 'The email field must be a valid email address.', + ) + .and('be.visible') + }) + + it('shows throttling and malformed-response errors', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 429, + body: { message: 'Too Many Attempts.' }, + }).as('throttledLogin') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@throttledLogin') + cy.get('[role="alert"]').should( + 'have.text', + 'Too many login attempts. Try again in a minute.', + ) + + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: { id: 7 } }, + }).as('malformedLogin') + + cy.get('form').submit() + cy.wait('@malformedLogin') + cy.get('[role="alert"]').should( + 'have.text', + 'Unable to log in. Please try again.', + ) + }) + + it('disables the form while login is pending', () => { + cy.intercept('POST', '**/api/login', { + delay: 500, + statusCode: 200, + body: { user: authenticatedUser }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + + cy.get('button[type="submit"]') + .should('be.disabled') + .and('have.text', 'Logging in...') + cy.get('#login-email').should('be.disabled') + cy.get('#login-password').should('be.disabled') + cy.wait('@login') + }) +})