mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-19 01:17:31 +00:00
* 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
489 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|