309 lines
9.0 KiB
TypeScript
309 lines
9.0 KiB
TypeScript
/**
|
|
* @fileoverview Unit tests for CredentialStore token expiration handling
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { CredentialStore } from './credential-store';
|
|
import type { AuthCredentials } from './types';
|
|
|
|
describe('CredentialStore - Token Expiration', () => {
|
|
let credentialStore: CredentialStore;
|
|
let tmpDir: string;
|
|
let authFile: string;
|
|
|
|
beforeEach(() => {
|
|
// Create temp directory for test credentials
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-cred-test-'));
|
|
authFile = path.join(tmpDir, 'auth.json');
|
|
|
|
// Create instance with test config
|
|
CredentialStore.resetInstance();
|
|
credentialStore = CredentialStore.getInstance({
|
|
configDir: tmpDir,
|
|
configFile: authFile
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up
|
|
try {
|
|
if (fs.existsSync(tmpDir)) {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
CredentialStore.resetInstance();
|
|
});
|
|
|
|
describe('Expiration Detection', () => {
|
|
it('should return null for expired token', () => {
|
|
const expiredCredentials: AuthCredentials = {
|
|
token: 'expired-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() - 60000).toISOString(), // 1 minute ago
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(expiredCredentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
|
|
it('should return credentials for valid token', () => {
|
|
const validCredentials: AuthCredentials = {
|
|
token: 'valid-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(validCredentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.token).toBe('valid-token');
|
|
});
|
|
|
|
it('should return expired token when allowExpired is true', () => {
|
|
const expiredCredentials: AuthCredentials = {
|
|
token: 'expired-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() - 60000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(expiredCredentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: true });
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.token).toBe('expired-token');
|
|
});
|
|
|
|
it('should return expired token by default (allowExpired defaults to true)', () => {
|
|
const expiredCredentials: AuthCredentials = {
|
|
token: 'expired-token-default',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() - 60000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(expiredCredentials);
|
|
|
|
// Call without options - should default to allowExpired: true
|
|
const retrieved = credentialStore.getCredentials();
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.token).toBe('expired-token-default');
|
|
});
|
|
});
|
|
|
|
describe('Clock Skew Tolerance', () => {
|
|
it('should reject token expiring within 30-second buffer', () => {
|
|
// Token expires in 15 seconds (within 30-second buffer)
|
|
const almostExpiredCredentials: AuthCredentials = {
|
|
token: 'almost-expired-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() + 15000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(almostExpiredCredentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
|
|
it('should accept token expiring outside 30-second buffer', () => {
|
|
// Token expires in 60 seconds (outside 30-second buffer)
|
|
const validCredentials: AuthCredentials = {
|
|
token: 'valid-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(validCredentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.token).toBe('valid-token');
|
|
});
|
|
});
|
|
|
|
describe('Timestamp Format Handling', () => {
|
|
it('should handle ISO string timestamps', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(credentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
|
|
});
|
|
|
|
it('should handle numeric timestamps', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: Date.now() + 3600000,
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(credentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(typeof retrieved?.expiresAt).toBe('number');
|
|
});
|
|
|
|
it('should return null for invalid timestamp format', () => {
|
|
// Manually write invalid timestamp to file
|
|
const invalidCredentials = {
|
|
token: 'test-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: 'invalid-date',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
fs.writeFileSync(authFile, JSON.stringify(invalidCredentials), {
|
|
mode: 0o600
|
|
});
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
|
|
it('should return null for missing expiresAt', () => {
|
|
const credentialsWithoutExpiry = {
|
|
token: 'test-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
fs.writeFileSync(authFile, JSON.stringify(credentialsWithoutExpiry), {
|
|
mode: 0o600
|
|
});
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Storage Persistence', () => {
|
|
it('should persist expiresAt as ISO string', () => {
|
|
const expiryTime = Date.now() + 3600000;
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: expiryTime,
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(credentials);
|
|
|
|
// Read raw file to verify format
|
|
const fileContent = fs.readFileSync(authFile, 'utf-8');
|
|
const parsed = JSON.parse(fileContent);
|
|
|
|
// Should be stored as ISO string
|
|
expect(typeof parsed.expiresAt).toBe('string');
|
|
expect(parsed.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO format
|
|
});
|
|
|
|
it('should normalize timestamp on retrieval', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(credentials);
|
|
|
|
const retrieved = credentialStore.getCredentials({ allowExpired: false });
|
|
|
|
// Should be normalized to number for runtime use
|
|
expect(typeof retrieved?.expiresAt).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('hasCredentials', () => {
|
|
it('should return true for expired credentials', () => {
|
|
const expiredCredentials: AuthCredentials = {
|
|
token: 'expired-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() - 60000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(expiredCredentials);
|
|
|
|
expect(credentialStore.hasCredentials()).toBe(true);
|
|
});
|
|
|
|
it('should return true for valid credentials', () => {
|
|
const validCredentials: AuthCredentials = {
|
|
token: 'valid-token',
|
|
refreshToken: 'refresh-token',
|
|
userId: 'test-user',
|
|
email: 'test@example.com',
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
credentialStore.saveCredentials(validCredentials);
|
|
|
|
expect(credentialStore.hasCredentials()).toBe(true);
|
|
});
|
|
|
|
it('should return false when no credentials exist', () => {
|
|
expect(credentialStore.hasCredentials()).toBe(false);
|
|
});
|
|
});
|
|
});
|