skills/test-user-cleanup/SKILL.md
Clean up test users created during E2E and integration testing. Use when project.json has authentication.cleanup configured. Triggers on: cleanup test users, remove test data, test teardown, clean test database.
npx skillsauth add mdmagnuson-creator/yo-go test-user-cleanupInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Remove test users created during E2E testing to keep the database clean and avoid pollution from automated test runs.
Project configuration in docs/project.json:
{
"authentication": {
"cleanup": {
"enabled": true,
"trigger": "auto",
"pattern": "test-*@example.com",
"maxAgeHours": 24,
"safetyChecks": {
"requireTestEmailPattern": true,
"blockProduction": true
}
}
}
}
Environment variables in .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
NODE_ENV=development
"trigger": "manual")Run cleanup explicitly via script or command:
npx tsx scripts/cleanup-test-users.ts
Use when:
"trigger": "auto")Cleanup runs automatically after each test suite:
// In your test setup (e.g., playwright.config.ts globalTeardown)
import { cleanupTestUsers } from './test-utils/cleanup';
export default async function globalTeardown() {
await cleanupTestUsers();
}
Use when:
"trigger": "scheduled")Cleanup runs on a schedule (cron job or scheduled action):
# .github/workflows/cleanup-test-users.yml
name: Cleanup Test Users
on:
schedule:
- cron: '0 0 * * *' # Daily at midnight
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx tsx scripts/cleanup-test-users.ts
env:
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
Use when:
Identify test users by email pattern:
{
"cleanup": {
"pattern": "test-*@example.com"
}
}
Pattern supports:
* - matches any characters? - matches single charactertest-*@example.com - emails starting with "test-"*@testmail.example.com - any email at testmail subdomain[email protected] - e2e- followed by exactly 4 charactersOnly delete users older than a threshold:
{
"cleanup": {
"maxAgeHours": 24,
"preserveRecentMinutes": 5
}
}
This prevents deleting users from currently running tests.
Mark test users with a flag column:
{
"cleanup": {
"identifyBy": "column",
"table": "users",
"column": "is_test_user",
"value": true
}
}
Never delete users that don't match the test pattern:
function isTestEmail(email: string, pattern: string): boolean {
const regex = new RegExp(
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
);
return regex.test(email);
}
// Safety: Reject any email that doesn't match
if (!isTestEmail(user.email, config.cleanup.pattern)) {
console.warn(`Skipping ${user.email}: does not match test pattern`);
continue;
}
Never run cleanup in production:
function assertNotProduction(): void {
const env = process.env.NODE_ENV;
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
if (env === 'production') {
throw new Error('SAFETY: Cleanup cannot run with NODE_ENV=production');
}
// Additional check: block if URL looks like production
if (!url.includes('localhost') && !url.includes('staging') && !url.includes('dev')) {
throw new Error(
`SAFETY: Cleanup blocked. URL "${url}" may be production. ` +
'Set ALLOW_CLEANUP_ON_REMOTE=true to override.'
);
}
}
Preview what would be deleted without actually deleting:
DRY_RUN=true npx tsx scripts/cleanup-test-users.ts
import { createClient } from '@supabase/supabase-js';
import * as fs from 'fs';
import * as path from 'path';
interface CleanupConfig {
enabled: boolean;
trigger: 'manual' | 'auto' | 'scheduled';
pattern: string;
maxAgeHours?: number;
preserveRecentMinutes?: number;
safetyChecks?: {
requireTestEmailPattern?: boolean;
blockProduction?: boolean;
};
}
function loadCleanupConfig(projectRoot: string): CleanupConfig {
const projectJsonPath = path.join(projectRoot, 'docs', 'project.json');
const projectJson = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'));
return projectJson.authentication?.cleanup || { enabled: false };
}
function loadEnv(projectRoot: string): void {
const envPath = path.join(projectRoot, '.env.local');
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf-8');
content.split('\n').forEach(line => {
const match = line.trim().match(/^([^=]+)=(.*)$/);
if (match && !process.env[match[1]]) {
process.env[match[1]] = match[2];
}
});
}
}
function getSupabaseServiceClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
throw new Error('Missing Supabase env vars');
}
return createClient(url, key, {
auth: { autoRefreshToken: false, persistSession: false }
});
}
function isTestEmail(email: string, pattern: string): boolean {
const regex = new RegExp(
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$',
'i'
);
return regex.test(email);
}
function assertNotProduction(): void {
if (process.env.NODE_ENV === 'production') {
throw new Error('SAFETY: Cleanup cannot run in production');
}
}
interface CleanupResult {
deleted: string[];
skipped: string[];
errors: Array<{ email: string; error: string }>;
}
async function cleanupSupabaseTestUsers(
projectRoot: string,
options: { dryRun?: boolean } = {}
): Promise<CleanupResult> {
loadEnv(projectRoot);
const config = loadCleanupConfig(projectRoot);
if (!config.enabled) {
console.log('Cleanup is disabled in project.json');
return { deleted: [], skipped: [], errors: [] };
}
// Safety checks
if (config.safetyChecks?.blockProduction !== false) {
assertNotProduction();
}
const supabase = getSupabaseServiceClient();
const result: CleanupResult = { deleted: [], skipped: [], errors: [] };
// Get all users
const { data: { users }, error } = await supabase.auth.admin.listUsers();
if (error) {
throw new Error(`Failed to list users: ${error.message}`);
}
const now = new Date();
const maxAgeMs = (config.maxAgeHours || 24) * 60 * 60 * 1000;
const preserveMs = (config.preserveRecentMinutes || 5) * 60 * 1000;
for (const user of users) {
const email = user.email || '';
// Check pattern
if (config.safetyChecks?.requireTestEmailPattern !== false) {
if (!isTestEmail(email, config.pattern)) {
result.skipped.push(email);
continue;
}
}
// Check age
const createdAt = new Date(user.created_at);
const ageMs = now.getTime() - createdAt.getTime();
if (ageMs < preserveMs) {
console.log(`Preserving ${email}: created ${Math.round(ageMs / 1000)}s ago (too recent)`);
result.skipped.push(email);
continue;
}
if (config.maxAgeHours && ageMs < maxAgeMs) {
console.log(`Preserving ${email}: only ${Math.round(ageMs / 3600000)}h old`);
result.skipped.push(email);
continue;
}
// Delete user
if (options.dryRun) {
console.log(`[DRY RUN] Would delete: ${email}`);
result.deleted.push(email);
} else {
try {
const { error: deleteError } = await supabase.auth.admin.deleteUser(user.id);
if (deleteError) {
result.errors.push({ email, error: deleteError.message });
} else {
console.log(`Deleted: ${email}`);
result.deleted.push(email);
}
} catch (err) {
result.errors.push({ email, error: String(err) });
}
}
}
return result;
}
// Main execution
async function main() {
const projectRoot = process.cwd();
const dryRun = process.env.DRY_RUN === 'true';
console.log(`Running test user cleanup (dry run: ${dryRun})`);
const result = await cleanupSupabaseTestUsers(projectRoot, { dryRun });
console.log('\n--- Cleanup Summary ---');
console.log(`Deleted: ${result.deleted.length} users`);
console.log(`Skipped: ${result.skipped.length} users`);
console.log(`Errors: ${result.errors.length}`);
if (result.errors.length > 0) {
console.error('\nErrors:');
result.errors.forEach(e => console.error(` ${e.email}: ${e.error}`));
process.exit(1);
}
}
main().catch(console.error);
import { PrismaClient } from '@prisma/client';
async function cleanupPrismaTestUsers(
pattern: string,
options: { dryRun?: boolean; maxAgeHours?: number } = {}
): Promise<CleanupResult> {
const prisma = new PrismaClient();
const result: CleanupResult = { deleted: [], skipped: [], errors: [] };
try {
// Convert glob pattern to SQL LIKE pattern
const likePattern = pattern.replace(/\*/g, '%').replace(/\?/g, '_');
const cutoffDate = options.maxAgeHours
? new Date(Date.now() - options.maxAgeHours * 60 * 60 * 1000)
: new Date(0);
// Find matching users
const users = await prisma.user.findMany({
where: {
email: { like: likePattern },
createdAt: { lt: cutoffDate }
},
select: { id: true, email: true }
});
for (const user of users) {
if (options.dryRun) {
console.log(`[DRY RUN] Would delete: ${user.email}`);
result.deleted.push(user.email);
} else {
try {
await prisma.user.delete({ where: { id: user.id } });
console.log(`Deleted: ${user.email}`);
result.deleted.push(user.email);
} catch (err) {
result.errors.push({ email: user.email, error: String(err) });
}
}
}
} finally {
await prisma.$disconnect();
}
return result;
}
Save as scripts/cleanup-test-users.ts:
#!/usr/bin/env npx tsx
// Full Supabase cleanup implementation from above
// Run with: npx tsx scripts/cleanup-test-users.ts
// Dry run: DRY_RUN=true npx tsx scripts/cleanup-test-users.ts
// playwright.config.ts
export default defineConfig({
globalTeardown: './tests/global-teardown.ts',
});
// tests/global-teardown.ts
import { cleanupSupabaseTestUsers } from '../scripts/cleanup-test-users';
export default async function globalTeardown() {
const config = JSON.parse(
await fs.promises.readFile('./docs/project.json', 'utf-8')
);
if (config.authentication?.cleanup?.trigger === 'auto') {
console.log('Running automatic test user cleanup...');
await cleanupSupabaseTestUsers(process.cwd());
}
}
// tests/setup.ts
import { cleanupSupabaseTestUsers } from '../scripts/cleanup-test-users';
afterAll(async () => {
if (process.env.CLEANUP_AFTER_TESTS === 'true') {
await cleanupSupabaseTestUsers(process.cwd());
}
});
When deleting test users, also clean up related records:
async function cleanupUserAndRelatedData(
prisma: PrismaClient,
userId: string
): Promise<void> {
// Delete in order of dependencies
await prisma.session.deleteMany({ where: { userId } });
await prisma.account.deleteMany({ where: { userId } });
await prisma.userSettings.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
}
For Supabase with RLS and foreign keys, consider using CASCADE or cleaning up in order:
-- If your schema uses CASCADE, auth.users deletion handles it
-- Otherwise, clean up manually:
DELETE FROM public.user_profiles WHERE user_id = $1;
DELETE FROM public.user_settings WHERE user_id = $1;
-- Then delete from auth.users via admin API
This is intentional. Never run cleanup in production. If you need to clean test data from a staging environment that looks like production, set:
ALLOW_CLEANUP_ON_REMOTE=true
Ensure environment variables are set:
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
Check:
cleanup.enabled is trueClean up related records first, or configure CASCADE deletes in your schema.
This skill is used after test runs by:
ui-tester-playwright - when cleanup.trigger is autocleanup.trigger is scheduledcleanup.trigger is manualAgents should:
project.json for authentication.cleanup configdata-ai
Generate verification contracts before delegating tasks to sub-agents, defining how success will be measured. Triggers on: verification contract, delegation contract, task verification, contract-first delegation.
testing
Verify that Vercel environment variables point to the correct Supabase project for each environment to prevent staging/production cross-wiring. Triggers on: vercel supabase check, environment alignment, env var check, supabase environment.
development
Manage codebase and database vectorization for semantic search. Use when initializing, refreshing, or querying the vector index. Triggers on: vectorize init, vectorize refresh, vectorize search, semantic search, vector index, enable vectorization.
testing
Patterns for XCUITest UI tests for native Apple apps (macOS/iOS). Use when writing or reviewing XCUITest tests for Swift apps. Triggers on: XCUITest, xcuitest, native app testing, Apple UI tests, SwiftUI tests, AppKit tests, UIKit tests.