import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserAuth, BrowserAuthResult } from '../../src/auth/browser-auth.js';
import { EventEmitter } from 'events';
import http from 'http';
// モックインスタンスを保持
const mockTokenStorage = {
saveToken: vi.fn().mockResolvedValue(undefined),
};
// openライブラリをモック
vi.mock('open', () => ({
default: vi.fn().mockResolvedValue(undefined),
}));
// TokenStorageをモック
vi.mock('../../src/auth/token-storage.js', () => ({
TokenStorage: vi.fn().mockImplementation(() => mockTokenStorage),
}));
// fetchをモック
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('BrowserAuth', () => {
let browserAuth: BrowserAuth;
let mockServer: EventEmitter & { listen: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
let requestHandler: any;
let createServerSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
// モック関数の呼び出し履歴をクリア
mockTokenStorage.saveToken.mockClear();
mockFetch.mockClear();
// openモックをクリア
const open = (await import('open')).default;
(open as any).mockClear();
// HTTPサーバーのモック
mockServer = Object.assign(new EventEmitter(), {
listen: vi.fn((port: number, callback: () => void) => {
setTimeout(callback, 0);
}),
close: vi.fn(),
});
// requestHandlerをリセット
requestHandler = null;
// 既存のスパイを復元してから新しいスパイを作成
if (createServerSpy) {
createServerSpy.mockRestore();
}
createServerSpy = vi.spyOn(http, 'createServer').mockImplementation((handler) => {
requestHandler = handler;
return mockServer as any;
});
// モックされたインスタンスをコンストラクタに渡す
browserAuth = new BrowserAuth(
'https://api.example.com',
34521,
mockTokenStorage as any
);
});
afterEach(() => {
// スパイを復元
if (createServerSpy) {
createServerSpy.mockRestore();
}
});
describe('authenticate', () => {
it('HTTPサーバーを正しいポートで起動する', async () => {
const authPromise = browserAuth.authenticate();
// サーバーがlistenされるまで待機
await vi.waitFor(() => {
expect(mockServer.listen).toHaveBeenCalledWith(
34521,
expect.any(Function)
);
});
// Promiseをクリーンアップ(エラーを発火してPromiseを終了させる)
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
it('ブラウザでPlume APIの認証ページを開く', async () => {
const open = (await import('open')).default;
// このテスト開始時点の呼び出し回数を記録
const callCountBefore = (open as any).mock.calls.length;
const authPromise = browserAuth.authenticate();
// サーバー起動後にopenが呼ばれるまで待機
await vi.waitFor(() => {
expect((open as any).mock.calls.length).toBeGreaterThan(callCountBefore);
});
// このテストでの呼び出しを検証
const calledUrl = (open as any).mock.calls[callCountBefore][0];
expect(calledUrl).toContain('https://api.example.com/api/auth/oauth/authorize');
expect(calledUrl).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A34521%2Fcallback');
expect(calledUrl).toContain('state=');
// Promiseをクリーンアップ
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
it('ブラウザの自動起動に失敗してもエラーにならない', async () => {
const open = (await import('open')).default;
(open as any).mockRejectedValueOnce(new Error('Browser not found'));
// コンソールエラーをモック
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('ブラウザを自動で開けませんでした'),
'Browser not found'
);
});
consoleErrorSpy.mockRestore();
// Promiseをクリーンアップ
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
it('タイムアウト時にエラーをスローする', async () => {
vi.useFakeTimers();
const authPromise = browserAuth.authenticate();
// 3分以上進める
vi.advanceTimersByTime(3 * 60 * 1000 + 1000);
await expect(authPromise).rejects.toThrow('認証がタイムアウトしました (3分)');
vi.useRealTimers();
});
});
describe('コールバック処理', () => {
it('/callback で認証コードとstateを受け取りトークンを取得する', async () => {
// openモックを取得し、呼び出し回数を記録
const open = (await import('open')).default;
const callCountBefore = (open as any).mock.calls.length;
// fetchモックを設定
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
token: 'mock-jwt-token',
email: 'test@example.com',
}),
});
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
// openの呼び出しを待ってstateを取得(このテストでの呼び出しを取得)
await vi.waitFor(() => {
expect((open as any).mock.calls.length).toBeGreaterThan(callCountBefore);
});
// URLからstateを抽出
const calledUrl = (open as any).mock.calls[callCountBefore][0];
const urlObj = new URL(calledUrl);
const state = urlObj.searchParams.get('state');
// コールバックリクエストをシミュレート
const mockReq: any = {
url: `/callback?code=auth-code-123&state=${state}`,
method: 'GET'
};
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
await requestHandler(mockReq, mockRes);
// fetchが正しく呼ばれたか確認
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/auth/oauth/token',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: 'auth-code-123',
redirect_uri: 'http://localhost:34521/callback',
}),
})
);
// TokenStorageのsaveTokenメソッドが呼ばれたか確認(デフォルト7日の有効期限)
expect(mockTokenStorage.saveToken).toHaveBeenCalledWith(
expect.objectContaining({
token: 'mock-jwt-token',
baseUrl: 'https://api.example.com',
email: 'test@example.com',
})
);
// expiresAtが設定されていることを確認(デフォルト7日 = 7 * 24 * 60 * 60 * 1000ms)
const savedTokenInfo = mockTokenStorage.saveToken.mock.calls[0][0];
expect(savedTokenInfo.expiresAt).toBeDefined();
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
const expectedMinExpiry = Date.now() + sevenDaysInMs - 1000; // 1秒の誤差を許容
const expectedMaxExpiry = Date.now() + sevenDaysInMs + 1000;
expect(savedTokenInfo.expiresAt).toBeGreaterThanOrEqual(expectedMinExpiry);
expect(savedTokenInfo.expiresAt).toBeLessThanOrEqual(expectedMaxExpiry);
// 成功ページが表示されたか確認
expect(mockRes.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'text/html; charset=utf-8',
});
expect(mockRes.end.mock.calls[0][0]).toContain('認証成功');
// Promiseが解決されたか確認
const result = await authPromise;
expect(result).toEqual({
token: 'mock-jwt-token',
email: 'test@example.com',
});
});
it('APIからexpires_inが返された場合はそれを使用してexpiresAtを設定する', async () => {
const open = (await import('open')).default;
const callCountBefore = (open as any).mock.calls.length;
// fetchモックを設定(expires_in付き)
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
token: 'mock-jwt-token',
email: 'test@example.com',
expires_in: 3600, // 1時間(秒)
}),
});
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
await vi.waitFor(() => {
expect((open as any).mock.calls.length).toBeGreaterThan(callCountBefore);
});
const calledUrl = (open as any).mock.calls[callCountBefore][0];
const urlObj = new URL(calledUrl);
const state = urlObj.searchParams.get('state');
const mockReq: any = {
url: `/callback?code=auth-code-123&state=${state}`,
method: 'GET'
};
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
await requestHandler(mockReq, mockRes);
// expiresAtが1時間後に設定されていることを確認
const savedTokenInfo = mockTokenStorage.saveToken.mock.calls[0][0];
expect(savedTokenInfo.expiresAt).toBeDefined();
const oneHourInMs = 3600 * 1000;
const expectedMinExpiry = Date.now() + oneHourInMs - 1000;
const expectedMaxExpiry = Date.now() + oneHourInMs + 1000;
expect(savedTokenInfo.expiresAt).toBeGreaterThanOrEqual(expectedMinExpiry);
expect(savedTokenInfo.expiresAt).toBeLessThanOrEqual(expectedMaxExpiry);
await authPromise;
});
it('state不一致の場合にエラーを返す', async () => {
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
// 異なるstateでコールバックをシミュレート
const mockReq: any = {
url: '/callback?code=auth-code-123&state=wrong-state',
method: 'GET'
};
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
await requestHandler(mockReq, mockRes);
// エラーページが表示されたか確認
expect(mockRes.writeHead).toHaveBeenCalledWith(400, {
'Content-Type': 'text/html; charset=utf-8',
});
expect(mockRes.end.mock.calls[0][0]).toContain('State mismatch');
// Promiseをクリーンアップ
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
it('認証コードがない場合にエラーを返す', async () => {
const open = (await import('open')).default;
// このテスト開始時点の呼び出し回数を記録
const callCountBefore = (open as any).mock.calls.length;
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
// openの呼び出しを待ってstateを取得(このテストでの呼び出しを取得)
await vi.waitFor(() => {
expect((open as any).mock.calls.length).toBeGreaterThan(callCountBefore);
});
const calledUrl = (open as any).mock.calls[callCountBefore][0];
const urlObj = new URL(calledUrl);
const state = urlObj.searchParams.get('state');
// codeなしでコールバックをシミュレート(正しいstateを使用)
const mockReq: any = {
url: `/callback?state=${state}`,
method: 'GET'
};
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
await requestHandler(mockReq, mockRes);
// エラーページが表示されたか確認
expect(mockRes.writeHead).toHaveBeenCalledWith(400, {
'Content-Type': 'text/html; charset=utf-8',
});
expect(mockRes.end.mock.calls[0][0]).toContain('認証コードが見つかりません');
// Promiseをクリーンアップ
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
it('トークン交換失敗時にエラーを返す', async () => {
const open = (await import('open')).default;
// このテスト開始時点の呼び出し回数を記録
const callCountBefore = (open as any).mock.calls.length;
// fetchモックをエラーレスポンスに設定
mockFetch.mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Invalid code' }),
});
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
// openの呼び出しを待ってstateを取得(このテストでの呼び出しを取得)
await vi.waitFor(() => {
expect((open as any).mock.calls.length).toBeGreaterThan(callCountBefore);
});
const calledUrl = (open as any).mock.calls[callCountBefore][0];
const urlObj = new URL(calledUrl);
const state = urlObj.searchParams.get('state');
const mockReq: any = {
url: `/callback?code=invalid-code&state=${state}`,
method: 'GET'
};
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
await requestHandler(mockReq, mockRes);
// エラーページが表示されたか確認
expect(mockRes.writeHead).toHaveBeenCalledWith(500, {
'Content-Type': 'text/html; charset=utf-8',
});
expect(mockRes.end.mock.calls[0][0]).toContain('Invalid code');
// authPromiseはrejectされるはず
await expect(authPromise).rejects.toThrow('Invalid code');
});
});
describe('ルーティング', () => {
it('存在しないパスにアクセスすると404を返す', async () => {
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
const mockReq: any = { url: '/invalid', method: 'GET' };
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
requestHandler(mockReq, mockRes);
expect(mockRes.writeHead).toHaveBeenCalledWith(404);
expect(mockRes.end).toHaveBeenCalledWith('Not Found');
// Promiseをクリーンアップ
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
it('/ にアクセスすると404を返す(ログインフォームはPlume APIにある)', async () => {
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
const mockReq: any = { url: '/', method: 'GET' };
const mockRes: any = {
writeHead: vi.fn(),
end: vi.fn(),
};
requestHandler(mockReq, mockRes);
expect(mockRes.writeHead).toHaveBeenCalledWith(404);
// Promiseをクリーンアップ
mockServer.emit('error', new Error('Test cleanup'));
await expect(authPromise).rejects.toThrow('Test cleanup');
});
});
describe('サーバーエラーハンドリング', () => {
it('サーバーエラー時にrejectする', async () => {
const authPromise = browserAuth.authenticate();
await vi.waitFor(() => {
expect(http.createServer).toHaveBeenCalled();
});
const serverError = new Error('Port already in use');
mockServer.emit('error', serverError);
await expect(authPromise).rejects.toThrow('Port already in use');
});
});
describe('generateSuccessPage', () => {
it('成功ページHTMLを正しく生成する', () => {
const html = (browserAuth as any).generateSuccessPage();
expect(html).toContain('認証成功');
expect(html).toContain('ターミナルに戻ってください');
expect(html).toContain('window.close()');
});
});
describe('generateErrorPage', () => {
it('エラーページHTMLを正しく生成する', () => {
const html = (browserAuth as any).generateErrorPage('テストエラー', 'エラーメッセージ');
expect(html).toContain('テストエラー');
expect(html).toContain('エラーメッセージ');
});
});
});