📦 deps(thirdparty): update snapshots
This commit is contained in:
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: auth-implementation-patterns
|
||||
description: "Build secure, scalable authentication and authorization systems using industry-standard patterns and modern best practices."
|
||||
risk: unknown
|
||||
source: community
|
||||
date_added: "2026-02-27"
|
||||
---
|
||||
|
||||
# Authentication & Authorization Implementation Patterns
|
||||
|
||||
Build secure, scalable authentication and authorization systems using industry-standard patterns and modern best practices.
|
||||
|
||||
## Use this skill when
|
||||
|
||||
- Implementing user authentication systems
|
||||
- Securing REST or GraphQL APIs
|
||||
- Adding OAuth2/social login or SSO
|
||||
- Designing session management or RBAC
|
||||
- Debugging authentication or authorization issues
|
||||
|
||||
## Do not use this skill when
|
||||
|
||||
- You only need UI copy or login page styling
|
||||
- The task is infrastructure-only without identity concerns
|
||||
- You cannot change auth policies or credential storage
|
||||
|
||||
## Instructions
|
||||
|
||||
- Define users, tenants, flows, and threat model constraints.
|
||||
- Choose auth strategy (session, JWT, OIDC) and token lifecycle.
|
||||
- Design authorization model and policy enforcement points.
|
||||
- Plan secrets storage, rotation, logging, and audit requirements.
|
||||
- If detailed examples are required, open `resources/implementation-playbook.md`.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never log secrets, tokens, or credentials.
|
||||
- Enforce least privilege and secure storage for keys.
|
||||
|
||||
## Resources
|
||||
|
||||
- `resources/implementation-playbook.md` for detailed patterns and examples.
|
||||
|
||||
## Limitations
|
||||
- Use this skill only when the task clearly matches the scope described above.
|
||||
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
|
||||
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
|
||||
+618
@@ -0,0 +1,618 @@
|
||||
# Authentication and Authorization Implementation Patterns Implementation Playbook
|
||||
|
||||
This file contains detailed patterns, checklists, and code samples referenced by the skill.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Authentication vs Authorization
|
||||
|
||||
**Authentication (AuthN)**: Who are you?
|
||||
- Verifying identity (username/password, OAuth, biometrics)
|
||||
- Issuing credentials (sessions, tokens)
|
||||
- Managing login/logout
|
||||
|
||||
**Authorization (AuthZ)**: What can you do?
|
||||
- Permission checking
|
||||
- Role-based access control (RBAC)
|
||||
- Resource ownership validation
|
||||
- Policy enforcement
|
||||
|
||||
### 2. Authentication Strategies
|
||||
|
||||
**Session-Based:**
|
||||
- Server stores session state
|
||||
- Session ID in cookie
|
||||
- Traditional, simple, stateful
|
||||
|
||||
**Token-Based (JWT):**
|
||||
- Stateless, self-contained
|
||||
- Scales horizontally
|
||||
- Can store claims
|
||||
|
||||
**OAuth2/OpenID Connect:**
|
||||
- Delegate authentication
|
||||
- Social login (Google, GitHub)
|
||||
- Enterprise SSO
|
||||
|
||||
## JWT Authentication
|
||||
|
||||
### Pattern 1: JWT Implementation
|
||||
|
||||
```typescript
|
||||
// JWT structure: header.payload.signature
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
function generateTokens(userId: string, email: string, role: string) {
|
||||
const accessToken = jwt.sign(
|
||||
{ userId, email, role },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '15m' } // Short-lived
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ userId },
|
||||
process.env.JWT_REFRESH_SECRET!,
|
||||
{ expiresIn: '7d' } // Long-lived
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
function verifyToken(token: string): JWTPayload {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware
|
||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
req.user = payload; // Attach user to request
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
app.get('/api/profile', authenticate, (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 2: Refresh Token Flow
|
||||
|
||||
```typescript
|
||||
interface StoredRefreshToken {
|
||||
token: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
class RefreshTokenService {
|
||||
// Store refresh token in database
|
||||
async storeRefreshToken(userId: string, refreshToken: string) {
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await db.refreshTokens.create({
|
||||
token: await hash(refreshToken), // Hash before storing
|
||||
userId,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
async refreshAccessToken(refreshToken: string) {
|
||||
// Verify refresh token
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(
|
||||
refreshToken,
|
||||
process.env.JWT_REFRESH_SECRET!
|
||||
) as { userId: string };
|
||||
} catch {
|
||||
throw new Error('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Check if token exists in database
|
||||
const storedToken = await db.refreshTokens.findOne({
|
||||
where: {
|
||||
token: await hash(refreshToken),
|
||||
userId: payload.userId,
|
||||
expiresAt: { $gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new Error('Refresh token not found or expired');
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await db.users.findById(payload.userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
// Revoke refresh token (logout)
|
||||
async revokeRefreshToken(refreshToken: string) {
|
||||
await db.refreshTokens.deleteOne({
|
||||
token: await hash(refreshToken),
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke all user tokens (logout all devices)
|
||||
async revokeAllUserTokens(userId: string) {
|
||||
await db.refreshTokens.deleteMany({ userId });
|
||||
}
|
||||
}
|
||||
|
||||
// API endpoints
|
||||
app.post('/api/auth/refresh', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
try {
|
||||
const { accessToken } = await refreshTokenService
|
||||
.refreshAccessToken(refreshToken);
|
||||
res.json({ accessToken });
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid refresh token' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', authenticate, async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
await refreshTokenService.revokeRefreshToken(refreshToken);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
```
|
||||
|
||||
## Session-Based Authentication
|
||||
|
||||
### Pattern 1: Express Session
|
||||
|
||||
```typescript
|
||||
import session from 'express-session';
|
||||
import RedisStore from 'connect-redis';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
// Setup Redis for session storage
|
||||
const redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
await redisClient.connect();
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new RedisStore({ client: redisClient }),
|
||||
secret: process.env.SESSION_SECRET!,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only
|
||||
httpOnly: true, // No JavaScript access
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
sameSite: 'strict', // CSRF protection
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Login
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const user = await db.users.findOne({ email });
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Store user in session
|
||||
req.session.userId = user.id;
|
||||
req.session.role = user.role;
|
||||
|
||||
res.json({ user: { id: user.id, email: user.email, role: user.role } });
|
||||
});
|
||||
|
||||
// Session middleware
|
||||
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session.userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Protected route
|
||||
app.get('/api/profile', requireAuth, async (req, res) => {
|
||||
const user = await db.users.findById(req.session.userId);
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
// Logout
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## OAuth2 / Social Login
|
||||
|
||||
### Pattern 1: OAuth2 with Passport.js
|
||||
|
||||
```typescript
|
||||
import passport from 'passport';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||
|
||||
// Google OAuth
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
callbackURL: '/api/auth/google/callback',
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
// Find or create user
|
||||
let user = await db.users.findOne({
|
||||
googleId: profile.id,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
googleId: profile.id,
|
||||
email: profile.emails?.[0]?.value,
|
||||
name: profile.displayName,
|
||||
avatar: profile.photos?.[0]?.value,
|
||||
});
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
return done(error, undefined);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Routes
|
||||
app.get('/api/auth/google', passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
}));
|
||||
|
||||
app.get(
|
||||
'/api/auth/google/callback',
|
||||
passport.authenticate('google', { session: false }),
|
||||
(req, res) => {
|
||||
// Generate JWT
|
||||
const tokens = generateTokens(req.user.id, req.user.email, req.user.role);
|
||||
// Redirect to frontend with token
|
||||
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${tokens.accessToken}`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Authorization Patterns
|
||||
|
||||
### Pattern 1: Role-Based Access Control (RBAC)
|
||||
|
||||
```typescript
|
||||
enum Role {
|
||||
USER = 'user',
|
||||
MODERATOR = 'moderator',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
const roleHierarchy: Record<Role, Role[]> = {
|
||||
[Role.ADMIN]: [Role.ADMIN, Role.MODERATOR, Role.USER],
|
||||
[Role.MODERATOR]: [Role.MODERATOR, Role.USER],
|
||||
[Role.USER]: [Role.USER],
|
||||
};
|
||||
|
||||
function hasRole(userRole: Role, requiredRole: Role): boolean {
|
||||
return roleHierarchy[userRole].includes(requiredRole);
|
||||
}
|
||||
|
||||
// Middleware
|
||||
function requireRole(...roles: Role[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
if (!roles.some(role => hasRole(req.user.role, role))) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
app.delete('/api/users/:id',
|
||||
authenticate,
|
||||
requireRole(Role.ADMIN),
|
||||
async (req, res) => {
|
||||
// Only admins can delete users
|
||||
await db.users.delete(req.params.id);
|
||||
res.json({ message: 'User deleted' });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 2: Permission-Based Access Control
|
||||
|
||||
```typescript
|
||||
enum Permission {
|
||||
READ_USERS = 'read:users',
|
||||
WRITE_USERS = 'write:users',
|
||||
DELETE_USERS = 'delete:users',
|
||||
READ_POSTS = 'read:posts',
|
||||
WRITE_POSTS = 'write:posts',
|
||||
}
|
||||
|
||||
const rolePermissions: Record<Role, Permission[]> = {
|
||||
[Role.USER]: [Permission.READ_POSTS, Permission.WRITE_POSTS],
|
||||
[Role.MODERATOR]: [
|
||||
Permission.READ_POSTS,
|
||||
Permission.WRITE_POSTS,
|
||||
Permission.READ_USERS,
|
||||
],
|
||||
[Role.ADMIN]: Object.values(Permission),
|
||||
};
|
||||
|
||||
function hasPermission(userRole: Role, permission: Permission): boolean {
|
||||
return rolePermissions[userRole]?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
function requirePermission(...permissions: Permission[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const hasAllPermissions = permissions.every(permission =>
|
||||
hasPermission(req.user.role, permission)
|
||||
);
|
||||
|
||||
if (!hasAllPermissions) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
app.get('/api/users',
|
||||
authenticate,
|
||||
requirePermission(Permission.READ_USERS),
|
||||
async (req, res) => {
|
||||
const users = await db.users.findAll();
|
||||
res.json({ users });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 3: Resource Ownership
|
||||
|
||||
```typescript
|
||||
// Check if user owns resource
|
||||
async function requireOwnership(
|
||||
resourceType: 'post' | 'comment',
|
||||
resourceIdParam: string = 'id'
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const resourceId = req.params[resourceIdParam];
|
||||
|
||||
// Admins can access anything
|
||||
if (req.user.role === Role.ADMIN) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
let resource;
|
||||
if (resourceType === 'post') {
|
||||
resource = await db.posts.findById(resourceId);
|
||||
} else if (resourceType === 'comment') {
|
||||
resource = await db.comments.findById(resourceId);
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
return res.status(404).json({ error: 'Resource not found' });
|
||||
}
|
||||
|
||||
if (resource.userId !== req.user.userId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
app.put('/api/posts/:id',
|
||||
authenticate,
|
||||
requireOwnership('post'),
|
||||
async (req, res) => {
|
||||
// User can only update their own posts
|
||||
const post = await db.posts.update(req.params.id, req.body);
|
||||
res.json({ post });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Pattern 1: Password Security
|
||||
|
||||
```typescript
|
||||
import bcrypt from 'bcrypt';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Password validation schema
|
||||
const passwordSchema = z.string()
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain special character');
|
||||
|
||||
// Hash password
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12; // 2^12 iterations
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
async function verifyPassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Registration with password validation
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Validate password
|
||||
passwordSchema.parse(password);
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await db.users.findOne({ email });
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await db.users.create({
|
||||
email,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = generateTokens(user.id, user.email, user.role);
|
||||
|
||||
res.status(201).json({
|
||||
user: { id: user.id, email: user.email },
|
||||
...tokens,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: error.errors[0].message });
|
||||
}
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 2: Rate Limiting
|
||||
|
||||
```typescript
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
|
||||
// Login rate limiter
|
||||
const loginLimiter = rateLimit({
|
||||
store: new RedisStore({ client: redisClient }),
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // 5 attempts
|
||||
message: 'Too many login attempts, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// API rate limiter
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 100, // 100 requests per minute
|
||||
standardHeaders: true,
|
||||
});
|
||||
|
||||
// Apply to routes
|
||||
app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
// Login logic
|
||||
});
|
||||
|
||||
app.use('/api/', apiLimiter);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Never Store Plain Passwords**: Always hash with bcrypt/argon2
|
||||
2. **Use HTTPS**: Encrypt data in transit
|
||||
3. **Short-Lived Access Tokens**: 15-30 minutes max
|
||||
4. **Secure Cookies**: httpOnly, secure, sameSite flags
|
||||
5. **Validate All Input**: Email format, password strength
|
||||
6. **Rate Limit Auth Endpoints**: Prevent brute force attacks
|
||||
7. **Implement CSRF Protection**: For session-based auth
|
||||
8. **Rotate Secrets Regularly**: JWT secrets, session secrets
|
||||
9. **Log Security Events**: Login attempts, failed auth
|
||||
10. **Use MFA When Possible**: Extra security layer
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Weak Passwords**: Enforce strong password policies
|
||||
- **JWT in localStorage**: Vulnerable to XSS, use httpOnly cookies
|
||||
- **No Token Expiration**: Tokens should expire
|
||||
- **Client-Side Auth Checks Only**: Always validate server-side
|
||||
- **Insecure Password Reset**: Use secure tokens with expiration
|
||||
- **No Rate Limiting**: Vulnerable to brute force
|
||||
- **Trusting Client Data**: Always validate on server
|
||||
|
||||
## Resources
|
||||
|
||||
- **references/jwt-best-practices.md**: JWT implementation guide
|
||||
- **references/oauth2-flows.md**: OAuth2 flow diagrams and examples
|
||||
- **references/session-security.md**: Secure session management
|
||||
- **assets/auth-security-checklist.md**: Security review checklist
|
||||
- **assets/password-policy-template.md**: Password requirements template
|
||||
- **scripts/token-validator.ts**: JWT validation utility
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: e2e-testing-patterns
|
||||
description: "Build reliable, fast, and maintainable end-to-end test suites that provide confidence to ship code quickly and catch regressions before users do."
|
||||
risk: safe
|
||||
source: community
|
||||
date_added: "2026-02-27"
|
||||
---
|
||||
|
||||
# E2E Testing Patterns
|
||||
|
||||
Build reliable, fast, and maintainable end-to-end test suites that provide confidence to ship code quickly and catch regressions before users do.
|
||||
|
||||
## Use this skill when
|
||||
|
||||
- Implementing end-to-end test automation
|
||||
- Debugging flaky or unreliable tests
|
||||
- Testing critical user workflows
|
||||
- Setting up CI/CD test pipelines
|
||||
- Testing across multiple browsers
|
||||
- Validating accessibility requirements
|
||||
- Testing responsive designs
|
||||
- Establishing E2E testing standards
|
||||
|
||||
## Do not use this skill when
|
||||
|
||||
- You only need unit or integration tests
|
||||
- The environment cannot support stable UI automation
|
||||
- You cannot provision safe test accounts or data
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Identify critical user journeys and success criteria.
|
||||
2. Build stable selectors and test data strategies.
|
||||
3. Implement tests with retries, tracing, and isolation.
|
||||
4. Run in CI with parallelization and artifact capture.
|
||||
|
||||
## Safety
|
||||
|
||||
- Avoid running destructive tests against production.
|
||||
- Use dedicated test data and scrub sensitive output.
|
||||
|
||||
## Resources
|
||||
|
||||
- `resources/implementation-playbook.md` for detailed E2E patterns and templates.
|
||||
|
||||
## Limitations
|
||||
- Use this skill only when the task clearly matches the scope described above.
|
||||
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
|
||||
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
|
||||
+531
@@ -0,0 +1,531 @@
|
||||
# E2E Testing Patterns Implementation Playbook
|
||||
|
||||
This file contains detailed patterns, checklists, and code samples referenced by the skill.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. E2E Testing Fundamentals
|
||||
|
||||
**What to Test with E2E:**
|
||||
- Critical user journeys (login, checkout, signup)
|
||||
- Complex interactions (drag-and-drop, multi-step forms)
|
||||
- Cross-browser compatibility
|
||||
- Real API integration
|
||||
- Authentication flows
|
||||
|
||||
**What NOT to Test with E2E:**
|
||||
- Unit-level logic (use unit tests)
|
||||
- API contracts (use integration tests)
|
||||
- Edge cases (too slow)
|
||||
- Internal implementation details
|
||||
|
||||
### 2. Test Philosophy
|
||||
|
||||
**The Testing Pyramid:**
|
||||
```
|
||||
/\
|
||||
/E2E\ ← Few, focused on critical paths
|
||||
/─────\
|
||||
/Integr\ ← More, test component interactions
|
||||
/────────\
|
||||
/Unit Tests\ ← Many, fast, isolated
|
||||
/────────────\
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Test user behavior, not implementation
|
||||
- Keep tests independent
|
||||
- Make tests deterministic
|
||||
- Optimize for speed
|
||||
- Use data-testid, not CSS selectors
|
||||
|
||||
## Playwright Patterns
|
||||
|
||||
### Setup and Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html'],
|
||||
['junit', { outputFile: 'results.xml' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 1: Page Object Model
|
||||
|
||||
```typescript
|
||||
// pages/LoginPage.ts
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
readonly emailInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly loginButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.emailInput = page.getByLabel('Email');
|
||||
this.passwordInput = page.getByLabel('Password');
|
||||
this.loginButton = page.getByRole('button', { name: 'Login' });
|
||||
this.errorMessage = page.getByRole('alert');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.emailInput.fill(email);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.loginButton.click();
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
return await this.errorMessage.textContent() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Test using Page Object
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
|
||||
test('successful login', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login('user@example.com', 'password123');
|
||||
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
test('failed login shows error', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login('invalid@example.com', 'wrong');
|
||||
|
||||
const error = await loginPage.getErrorMessage();
|
||||
expect(error).toContain('Invalid credentials');
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 2: Fixtures for Test Data
|
||||
|
||||
```typescript
|
||||
// fixtures/test-data.ts
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
type TestData = {
|
||||
testUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
};
|
||||
adminUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const test = base.extend<TestData>({
|
||||
testUser: async ({}, use) => {
|
||||
const user = {
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
password: 'Test123!@#',
|
||||
name: 'Test User',
|
||||
};
|
||||
// Setup: Create user in database
|
||||
await createTestUser(user);
|
||||
await use(user);
|
||||
// Teardown: Clean up user
|
||||
await deleteTestUser(user.email);
|
||||
},
|
||||
|
||||
adminUser: async ({}, use) => {
|
||||
await use({
|
||||
email: 'admin@example.com',
|
||||
password: process.env.ADMIN_PASSWORD!,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage in tests
|
||||
import { test } from './fixtures/test-data';
|
||||
|
||||
test('user can update profile', async ({ page, testUser }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(testUser.email);
|
||||
await page.getByLabel('Password').fill(testUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await page.goto('/profile');
|
||||
await page.getByLabel('Name').fill('Updated Name');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.getByText('Profile updated')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 3: Waiting Strategies
|
||||
|
||||
```typescript
|
||||
// ❌ Bad: Fixed timeouts
|
||||
await page.waitForTimeout(3000); // Flaky!
|
||||
|
||||
// ✅ Good: Wait for specific conditions
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForURL('/dashboard');
|
||||
await page.waitForSelector('[data-testid="user-profile"]');
|
||||
|
||||
// ✅ Better: Auto-waiting with assertions
|
||||
await expect(page.getByText('Welcome')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' }))
|
||||
.toBeEnabled();
|
||||
|
||||
// Wait for API response
|
||||
const responsePromise = page.waitForResponse(
|
||||
response => response.url().includes('/api/users') && response.status() === 200
|
||||
);
|
||||
await page.getByRole('button', { name: 'Load Users' }).click();
|
||||
const response = await responsePromise;
|
||||
const data = await response.json();
|
||||
expect(data.users).toHaveLength(10);
|
||||
|
||||
// Wait for multiple conditions
|
||||
await Promise.all([
|
||||
page.waitForURL('/success'),
|
||||
page.waitForLoadState('networkidle'),
|
||||
expect(page.getByText('Payment successful')).toBeVisible(),
|
||||
]);
|
||||
```
|
||||
|
||||
### Pattern 4: Network Mocking and Interception
|
||||
|
||||
```typescript
|
||||
// Mock API responses
|
||||
test('displays error when API fails', async ({ page }) => {
|
||||
await page.route('**/api/users', route => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal Server Error' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/users');
|
||||
await expect(page.getByText('Failed to load users')).toBeVisible();
|
||||
});
|
||||
|
||||
// Intercept and modify requests
|
||||
test('can modify API request', async ({ page }) => {
|
||||
await page.route('**/api/users', async route => {
|
||||
const request = route.request();
|
||||
const postData = JSON.parse(request.postData() || '{}');
|
||||
|
||||
// Modify request
|
||||
postData.role = 'admin';
|
||||
|
||||
await route.continue({
|
||||
postData: JSON.stringify(postData),
|
||||
});
|
||||
});
|
||||
|
||||
// Test continues...
|
||||
});
|
||||
|
||||
// Mock third-party services
|
||||
test('payment flow with mocked Stripe', async ({ page }) => {
|
||||
await page.route('**/api/stripe/**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
id: 'mock_payment_id',
|
||||
status: 'succeeded',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Test payment flow with mocked response
|
||||
});
|
||||
```
|
||||
|
||||
## Cypress Patterns
|
||||
|
||||
### Setup and Configuration
|
||||
|
||||
```typescript
|
||||
// cypress.config.ts
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
video: false,
|
||||
screenshotOnRunFailure: true,
|
||||
defaultCommandTimeout: 10000,
|
||||
requestTimeout: 10000,
|
||||
setupNodeEvents(on, config) {
|
||||
// Implement node event listeners
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 1: Custom Commands
|
||||
|
||||
```typescript
|
||||
// cypress/support/commands.ts
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
login(email: string, password: string): Chainable<void>;
|
||||
createUser(userData: UserData): Chainable<User>;
|
||||
dataCy(value: string): Chainable<JQuery<HTMLElement>>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add('login', (email: string, password: string) => {
|
||||
cy.visit('/login');
|
||||
cy.get('[data-testid="email"]').type(email);
|
||||
cy.get('[data-testid="password"]').type(password);
|
||||
cy.get('[data-testid="login-button"]').click();
|
||||
cy.url().should('include', '/dashboard');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('createUser', (userData: UserData) => {
|
||||
return cy.request('POST', '/api/users', userData)
|
||||
.its('body');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('dataCy', (value: string) => {
|
||||
return cy.get(`[data-cy="${value}"]`);
|
||||
});
|
||||
|
||||
// Usage
|
||||
cy.login('user@example.com', 'password');
|
||||
cy.dataCy('submit-button').click();
|
||||
```
|
||||
|
||||
### Pattern 2: Cypress Intercept
|
||||
|
||||
```typescript
|
||||
// Mock API calls
|
||||
cy.intercept('GET', '/api/users', {
|
||||
statusCode: 200,
|
||||
body: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
}).as('getUsers');
|
||||
|
||||
cy.visit('/users');
|
||||
cy.wait('@getUsers');
|
||||
cy.get('[data-testid="user-list"]').children().should('have.length', 2);
|
||||
|
||||
// Modify responses
|
||||
cy.intercept('GET', '/api/users', (req) => {
|
||||
req.reply((res) => {
|
||||
// Modify response
|
||||
res.body.users = res.body.users.slice(0, 5);
|
||||
res.send();
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate slow network
|
||||
cy.intercept('GET', '/api/data', (req) => {
|
||||
req.reply((res) => {
|
||||
res.delay(3000); // 3 second delay
|
||||
res.send();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Pattern 1: Visual Regression Testing
|
||||
|
||||
```typescript
|
||||
// With Playwright
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('homepage looks correct', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveScreenshot('homepage.png', {
|
||||
fullPage: true,
|
||||
maxDiffPixels: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test('button in all states', async ({ page }) => {
|
||||
await page.goto('/components');
|
||||
|
||||
const button = page.getByRole('button', { name: 'Submit' });
|
||||
|
||||
// Default state
|
||||
await expect(button).toHaveScreenshot('button-default.png');
|
||||
|
||||
// Hover state
|
||||
await button.hover();
|
||||
await expect(button).toHaveScreenshot('button-hover.png');
|
||||
|
||||
// Disabled state
|
||||
await button.evaluate(el => el.setAttribute('disabled', 'true'));
|
||||
await expect(button).toHaveScreenshot('button-disabled.png');
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 2: Parallel Testing with Sharding
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'shard-1',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
grepInvert: /@slow/,
|
||||
shard: { current: 1, total: 4 },
|
||||
},
|
||||
{
|
||||
name: 'shard-2',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
shard: { current: 2, total: 4 },
|
||||
},
|
||||
// ... more shards
|
||||
],
|
||||
});
|
||||
|
||||
// Run in CI
|
||||
// npx playwright test --shard=1/4
|
||||
// npx playwright test --shard=2/4
|
||||
```
|
||||
|
||||
### Pattern 3: Accessibility Testing
|
||||
|
||||
```typescript
|
||||
// Install: npm install @axe-core/playwright
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
test('page should not have accessibility violations', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const accessibilityScanResults = await new AxeBuilder({ page })
|
||||
.exclude('#third-party-widget')
|
||||
.analyze();
|
||||
|
||||
expect(accessibilityScanResults.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('form is accessible', async ({ page }) => {
|
||||
await page.goto('/signup');
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include('form')
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Data Attributes**: `data-testid` or `data-cy` for stable selectors
|
||||
2. **Avoid Brittle Selectors**: Don't rely on CSS classes or DOM structure
|
||||
3. **Test User Behavior**: Click, type, see - not implementation details
|
||||
4. **Keep Tests Independent**: Each test should run in isolation
|
||||
5. **Clean Up Test Data**: Create and destroy test data in each test
|
||||
6. **Use Page Objects**: Encapsulate page logic
|
||||
7. **Meaningful Assertions**: Check actual user-visible behavior
|
||||
8. **Optimize for Speed**: Mock when possible, parallel execution
|
||||
|
||||
```typescript
|
||||
// ❌ Bad selectors
|
||||
cy.get('.btn.btn-primary.submit-button').click();
|
||||
cy.get('div > form > div:nth-child(2) > input').type('text');
|
||||
|
||||
// ✅ Good selectors
|
||||
cy.getByRole('button', { name: 'Submit' }).click();
|
||||
cy.getByLabel('Email address').type('user@example.com');
|
||||
cy.get('[data-testid="email-input"]').type('user@example.com');
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Flaky Tests**: Use proper waits, not fixed timeouts
|
||||
- **Slow Tests**: Mock external APIs, use parallel execution
|
||||
- **Over-Testing**: Don't test every edge case with E2E
|
||||
- **Coupled Tests**: Tests should not depend on each other
|
||||
- **Poor Selectors**: Avoid CSS classes and nth-child
|
||||
- **No Cleanup**: Clean up test data after each test
|
||||
- **Testing Implementation**: Test user behavior, not internals
|
||||
|
||||
## Debugging Failing Tests
|
||||
|
||||
```typescript
|
||||
// Playwright debugging
|
||||
// 1. Run in headed mode
|
||||
npx playwright test --headed
|
||||
|
||||
// 2. Run in debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
// 3. Use trace viewer
|
||||
await page.screenshot({ path: 'screenshot.png' });
|
||||
await page.video()?.saveAs('video.webm');
|
||||
|
||||
// 4. Add test.step for better reporting
|
||||
test('checkout flow', async ({ page }) => {
|
||||
await test.step('Add item to cart', async () => {
|
||||
await page.goto('/products');
|
||||
await page.getByRole('button', { name: 'Add to Cart' }).click();
|
||||
});
|
||||
|
||||
await test.step('Proceed to checkout', async () => {
|
||||
await page.goto('/cart');
|
||||
await page.getByRole('button', { name: 'Checkout' }).click();
|
||||
});
|
||||
});
|
||||
|
||||
// 5. Inspect page state
|
||||
await page.pause(); // Pauses execution, opens inspector
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **references/playwright-best-practices.md**: Playwright-specific patterns
|
||||
- **references/cypress-best-practices.md**: Cypress-specific patterns
|
||||
- **references/flaky-test-debugging.md**: Debugging unreliable tests
|
||||
- **assets/e2e-testing-checklist.md**: What to test with E2E
|
||||
- **assets/selector-strategies.md**: Finding reliable selectors
|
||||
- **scripts/test-analyzer.ts**: Analyze test flakiness and duration
|
||||
Reference in New Issue
Block a user