super-productivity/packages/super-sync-server/tests/auth-flows.spec.ts
Johannes Millan 7070fde51e
Pr 6741 (#6743)
* fix(build): fix tag resolution and add error handling in bump-android-version

The previous two-tag approach (`currentTag` → `prevTag`) would resolve the
wrong changelog range during `npm version` since the new tag doesn't exist
yet. Simplified to a single `lastTag...HEAD` range which correctly captures
all commits since the last release.

Added try-catch with fallback to last 20 commits when no tags exist, and
a fallback message for empty changelogs.

* fix(sync): differentiate auth error messages and clarify token revocation

- Rename server dashboard "Refresh Token" to "Revoke & Replace Token"
  with explicit warning that ALL devices will be disconnected
- Return rejection reason in 401 responses (revoked, expired, etc.)
  via discriminated union TokenVerificationResult type
- Show different client error messages for server-side token rejection
  vs missing local credentials to aid user diagnosis
- Extract server error reason from JSON response body in AuthFailSPError

Closes #6597

* refactor(sync): use generic auth reason and improve test readability

- Replace 'User not found' and 'Account not verified' with generic
  'Account unavailable' to avoid leaking account state in API responses
- Extract long reason strings to constants in middleware.spec.ts
2026-03-05 21:17:59 +01:00

489 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { initDb, getDb, User } from '../src/db';
import {
registerUser,
verifyEmail,
loginUser,
verifyToken,
revokeAllTokens,
replaceToken,
} from '../src/auth';
import { sendVerificationEmail } from '../src/email';
import * as bcrypt from 'bcryptjs';
// Mock email sending to prevent actual emails during tests
vi.mock('../src/email', () => ({
sendVerificationEmail: vi.fn().mockResolvedValue(true),
}));
describe('Authentication Flows', () => {
beforeEach(() => {
initDb('./data', true);
vi.clearAllMocks();
});
describe('Account Lockout', () => {
const email = 'lockout@test.com';
const password = 'correct-password-123!';
beforeEach(async () => {
const db = getDb();
const passwordHash = await bcrypt.hash(password, 12);
db.prepare(
`INSERT INTO users (email, password_hash, is_verified, created_at)
VALUES (?, ?, 1, ?)`,
).run(email, passwordHash, Date.now());
});
it('should lock account after 5 failed login attempts', async () => {
// Fail 5 times
for (let i = 1; i <= 5; i++) {
try {
await loginUser(email, 'wrong-password');
} catch (err) {
if (i < 5) {
expect((err as Error).message).toBe('Invalid credentials');
}
}
}
// Check account is locked
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User;
expect(user.failed_login_attempts).toBe(5);
expect(user.locked_until).not.toBeNull();
expect(user.locked_until).toBeGreaterThan(Date.now());
});
it('should reject login when account is locked', async () => {
const db = getDb();
const fifteenMinutesMs = 15 * 60 * 1000;
// Lock the account manually
db.prepare(
'UPDATE users SET locked_until = ?, failed_login_attempts = 5 WHERE email = ?',
).run(
Date.now() + fifteenMinutesMs, // 15 minutes from now
email,
);
// Try to login with correct credentials - should fail because locked
await expect(loginUser(email, password)).rejects.toThrow(
'Account temporarily locked',
);
});
it('should allow login after lockout expires', async () => {
const db = getDb();
// Set lockout in the past (already expired)
db.prepare(
'UPDATE users SET locked_until = ?, failed_login_attempts = 5 WHERE email = ?',
).run(Date.now() - 1000, email); // 1 second ago
// Login should work now
const result = await loginUser(email, password);
expect(result.token).toBeDefined();
expect(result.user.email).toBe(email);
// Failed attempts should be reset
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User;
expect(user.failed_login_attempts).toBe(0);
expect(user.locked_until).toBeNull();
});
it('should reset failed attempts on successful login', async () => {
const db = getDb();
// Fail 3 times (not enough to lock)
for (let i = 0; i < 3; i++) {
try {
await loginUser(email, 'wrong-password');
} catch {
// expected
}
}
// Verify counter
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User;
expect(user.failed_login_attempts).toBe(3);
// Successful login
await loginUser(email, password);
// Counter should be reset
user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User;
expect(user.failed_login_attempts).toBe(0);
});
it('should track 15 minute lockout duration', async () => {
const db = getDb();
// Lock account
for (let i = 0; i < 5; i++) {
try {
await loginUser(email, 'wrong-password');
} catch {
// expected
}
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User;
const lockoutDuration = user.locked_until! - Date.now();
// Should be approximately 15 minutes (allow some timing slack)
expect(lockoutDuration).toBeGreaterThan(14 * 60 * 1000);
expect(lockoutDuration).toBeLessThanOrEqual(15 * 60 * 1000);
});
});
describe('Email Verification', () => {
it('should create verification token on registration', async () => {
await registerUser('newuser@test.com', 'SecurePass123!');
const db = getDb();
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('newuser@test.com') as User;
expect(user).toBeDefined();
expect(user.is_verified).toBe(0);
expect(user.verification_token).toBeDefined();
expect(user.verification_token!.length).toBe(64); // 32 bytes hex
expect(user.verification_token_expires_at).toBeDefined();
expect(user.terms_accepted_at).toBeDefined();
});
it('should store custom terms acceptance time', async () => {
const customTime = 1600000000000;
await registerUser('terms@test.com', 'SecurePass123!', customTime);
const db = getDb();
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('terms@test.com') as User;
expect(user.terms_accepted_at).toBe(customTime);
});
it('should verify user with valid token', async () => {
await registerUser('verify@test.com', 'SecurePass123!');
const db = getDb();
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('verify@test.com') as User;
const token = user.verification_token!;
const result = verifyEmail(token);
expect(result).toBe(true);
// Check user is now verified
const verifiedUser = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('verify@test.com') as User;
expect(verifiedUser.is_verified).toBe(1);
expect(verifiedUser.verification_token).toBeNull();
});
it('should reject invalid verification token', () => {
expect(() => verifyEmail('invalid-token-12345')).toThrow(
'Invalid verification token',
);
});
it('should reject expired verification token', async () => {
await registerUser('expired@test.com', 'SecurePass123!');
const db = getDb();
const oneHourMs = 60 * 60 * 1000;
// Set token to expired (1 hour ago)
db.prepare(
'UPDATE users SET verification_token_expires_at = ? WHERE email = ?',
).run(Date.now() - oneHourMs, 'expired@test.com');
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('expired@test.com') as User;
expect(() => verifyEmail(user.verification_token!)).toThrow(
'Verification token has expired',
);
});
it('should set token expiry to 24 hours', async () => {
await registerUser('expiry@test.com', 'SecurePass123!');
const db = getDb();
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('expiry@test.com') as User;
const expiryTime = user.verification_token_expires_at! - Date.now();
// Should be approximately 24 hours (allow some timing slack)
expect(expiryTime).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(expiryTime).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
});
it('should clear verification token after successful verification (one-time use)', async () => {
await registerUser('onetime@test.com', 'SecurePass123!');
const db = getDb();
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('onetime@test.com') as User;
const token = user.verification_token!;
// First verification should work
verifyEmail(token);
// Second verification with same token should fail
expect(() => verifyEmail(token)).toThrow('Invalid verification token');
});
it('should reject login for unverified user', async () => {
await registerUser('unverified@test.com', 'SecurePass123!');
await expect(loginUser('unverified@test.com', 'SecurePass123!')).rejects.toThrow(
'Email not verified',
);
});
it('should resend verification token even after previous resend when token expired', async () => {
await registerUser('stuck@test.com', 'SecurePass123!');
const db = getDb();
const expiredToken = 'expired-token';
// Simulate prior resend and expiry
db.prepare(
`
UPDATE users
SET verification_token = ?, verification_token_expires_at = ?, verification_resend_count = 1
WHERE email = ?
`,
).run(expiredToken, Date.now() - 1000, 'stuck@test.com');
await registerUser('stuck@test.com', 'AnotherPass123!');
const updatedUser = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('stuck@test.com') as User;
expect(updatedUser.verification_resend_count).toBe(2);
expect(updatedUser.verification_token).not.toBe(expiredToken);
expect(updatedUser.verification_token_expires_at).toBeGreaterThan(Date.now());
expect(vi.mocked(sendVerificationEmail)).toHaveBeenCalledTimes(2);
});
});
describe('Login Flow', () => {
const email = 'login@test.com';
const password = 'SecurePass123!';
beforeEach(async () => {
const db = getDb();
const passwordHash = await bcrypt.hash(password, 12);
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, created_at)
VALUES (1, ?, ?, 1, ?)`,
).run(email, passwordHash, Date.now());
});
it('should return JWT token on successful login', async () => {
const result = await loginUser(email, password);
expect(result.token).toBeDefined();
expect(typeof result.token).toBe('string');
expect(result.token.split('.').length).toBe(3); // JWT has 3 parts
expect(result.user.id).toBe(1);
expect(result.user.email).toBe(email);
});
it('should reject login with wrong password', async () => {
await expect(loginUser(email, 'wrong-password')).rejects.toThrow(
'Invalid credentials',
);
});
it('should reject login with non-existent email', async () => {
await expect(loginUser('nonexistent@test.com', password)).rejects.toThrow(
'Invalid credentials',
);
});
it('should include token version in JWT', async () => {
const result = await loginUser(email, password);
// Verify the token is valid and contains tokenVersion
const verified = await verifyToken(result.token);
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.userId).toBe(1);
expect(verified.email).toBe(email);
}
});
});
describe('Token Verification', () => {
const email = 'token@test.com';
const password = 'SecurePass123!';
beforeEach(async () => {
const db = getDb();
const passwordHash = await bcrypt.hash(password, 12);
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, token_version, created_at)
VALUES (1, ?, ?, 1, 0, ?)`,
).run(email, passwordHash, Date.now());
});
it('should verify valid token', async () => {
const loginResult = await loginUser(email, password);
const verified = await verifyToken(loginResult.token);
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.userId).toBe(1);
expect(verified.email).toBe(email);
}
});
it('should reject token after version increment (revoked)', async () => {
const loginResult = await loginUser(email, password);
// Token should be valid initially
expect((await verifyToken(loginResult.token)).valid).toBe(true);
// Revoke all tokens
revokeAllTokens(1);
// Token should now be invalid
const result = await verifyToken(loginResult.token);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.reason).toContain('revoked');
}
});
it('should reject token for deleted user', async () => {
const loginResult = await loginUser(email, password);
// Delete the user
const db = getDb();
db.prepare('DELETE FROM users WHERE id = ?').run(1);
// Token should now be invalid
expect((await verifyToken(loginResult.token)).valid).toBe(false);
});
it('should reject malformed tokens', async () => {
expect((await verifyToken('not.a.valid.token')).valid).toBe(false);
expect((await verifyToken('completely-invalid')).valid).toBe(false);
expect((await verifyToken('')).valid).toBe(false);
});
});
describe('Token Replacement', () => {
const email = 'replace@test.com';
const password = 'SecurePass123!';
beforeEach(async () => {
const db = getDb();
const passwordHash = await bcrypt.hash(password, 12);
db.prepare(
`INSERT INTO users (id, email, password_hash, is_verified, token_version, created_at)
VALUES (1, ?, ?, 1, 0, ?)`,
).run(email, passwordHash, Date.now());
});
it('should return new token with incremented version', async () => {
const result = replaceToken(1, email);
expect(result.token).toBeDefined();
expect(result.user.id).toBe(1);
expect(result.user.email).toBe(email);
// Verify new token is valid
const verified = await verifyToken(result.token);
expect(verified.valid).toBe(true);
});
it('should invalidate all previous tokens after replacement', async () => {
// Login to get initial token
const loginResult = await loginUser(email, password);
expect((await verifyToken(loginResult.token)).valid).toBe(true);
// Replace token
const newResult = replaceToken(1, email);
// Old token should be invalid
expect((await verifyToken(loginResult.token)).valid).toBe(false);
// New token should be valid
expect((await verifyToken(newResult.token)).valid).toBe(true);
});
it('should increment token version in database', () => {
const db = getDb();
// Initial version should be 0
let user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(1) as {
token_version: number;
};
expect(user.token_version).toBe(0);
// Replace token
replaceToken(1, email);
// Version should be 1
user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(1) as {
token_version: number;
};
expect(user.token_version).toBe(1);
// Replace again
replaceToken(1, email);
// Version should be 2
user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(1) as {
token_version: number;
};
expect(user.token_version).toBe(2);
});
it('should throw for non-existent user', () => {
expect(() => replaceToken(999, 'nobody@test.com')).toThrow('User not found');
});
});
describe('Registration with Existing Email', () => {
it('should handle duplicate registration for unverified account', async () => {
// First registration
await registerUser('dup@test.com', 'SecurePass123!');
// Second registration with same email
const result = await registerUser('dup@test.com', 'DifferentPass456!');
expect(result.message).toContain('Registration successful');
// Should have incremented resend count
const db = getDb();
const user = db
.prepare('SELECT * FROM users WHERE email = ?')
.get('dup@test.com') as User;
expect(user.verification_resend_count).toBe(1);
});
it('should not send multiple verification emails for verified account', async () => {
// Create verified account
const db = getDb();
const passwordHash = await bcrypt.hash('SecurePass123!', 12);
db.prepare(
`INSERT INTO users (email, password_hash, is_verified, created_at)
VALUES ('verified@test.com', ?, 1, ?)`,
).run(passwordHash, Date.now());
// Try to register again - should succeed without error (silent handling)
const result = await registerUser('verified@test.com', 'NewPass123!');
expect(result.message).toContain('Registration successful');
});
});
});