feat(cli): add gmail-auth command for OAuth2 token setup
Implements `flynn gmail-auth` to complete the OAuth2 flow that GmailWatcher references but was never built. Supports local callback server (default) and --manual paste mode. Adds Gmail health check to `flynn doctor`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { readCredentials, generateAuthUrl, saveToken } from './gmail-auth.js';
|
||||
import { writeFileSync, mkdirSync, rmSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('gmail-auth', () => {
|
||||
const testDir = join(tmpdir(), 'flynn-test-gmail-auth');
|
||||
|
||||
afterEach(() => {
|
||||
try { rmSync(testDir, { recursive: true }); } catch {}
|
||||
});
|
||||
|
||||
describe('readCredentials', () => {
|
||||
it('reads installed credentials', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const credPath = join(testDir, 'creds.json');
|
||||
writeFileSync(credPath, JSON.stringify({
|
||||
installed: {
|
||||
client_id: 'test-id',
|
||||
client_secret: 'test-secret',
|
||||
redirect_uris: ['http://localhost:3000'],
|
||||
},
|
||||
}));
|
||||
|
||||
const creds = readCredentials(credPath);
|
||||
expect(creds.client_id).toBe('test-id');
|
||||
expect(creds.client_secret).toBe('test-secret');
|
||||
expect(creds.redirect_uris).toEqual(['http://localhost:3000']);
|
||||
});
|
||||
|
||||
it('reads web credentials', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const credPath = join(testDir, 'creds.json');
|
||||
writeFileSync(credPath, JSON.stringify({
|
||||
web: {
|
||||
client_id: 'web-id',
|
||||
client_secret: 'web-secret',
|
||||
},
|
||||
}));
|
||||
|
||||
const creds = readCredentials(credPath);
|
||||
expect(creds.client_id).toBe('web-id');
|
||||
expect(creds.client_secret).toBe('web-secret');
|
||||
});
|
||||
|
||||
it('throws when file does not exist', () => {
|
||||
expect(() => readCredentials('/nonexistent/creds.json')).toThrow('not found');
|
||||
});
|
||||
|
||||
it('throws when client_id is missing', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const credPath = join(testDir, 'creds.json');
|
||||
writeFileSync(credPath, JSON.stringify({ installed: { client_secret: 's' } }));
|
||||
|
||||
expect(() => readCredentials(credPath)).toThrow('missing client_id or client_secret');
|
||||
});
|
||||
|
||||
it('throws when client_secret is missing', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const credPath = join(testDir, 'creds.json');
|
||||
writeFileSync(credPath, JSON.stringify({ installed: { client_id: 'id' } }));
|
||||
|
||||
expect(() => readCredentials(credPath)).toThrow('missing client_id or client_secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAuthUrl', () => {
|
||||
it('generates correct OAuth2 URL', () => {
|
||||
const url = generateAuthUrl('my-client-id', 'my-secret', 'http://localhost:3000');
|
||||
expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
expect(url).toContain('client_id=my-client-id');
|
||||
expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000');
|
||||
expect(url).toContain('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly');
|
||||
expect(url).toContain('access_type=offline');
|
||||
expect(url).toContain('prompt=consent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToken', () => {
|
||||
it('saves token as JSON with 0o600 permissions', () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const tokenPath = join(testDir, 'token.json');
|
||||
const token = { access_token: 'abc', refresh_token: 'def' };
|
||||
|
||||
saveToken(tokenPath, token);
|
||||
|
||||
const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
expect(saved).toEqual(token);
|
||||
|
||||
const stats = statSync(tokenPath);
|
||||
// Check owner-only read/write (0o600)
|
||||
expect(stats.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('creates parent directories if needed', () => {
|
||||
const tokenPath = join(testDir, 'nested', 'dir', 'token.json');
|
||||
saveToken(tokenPath, { token: 'value' });
|
||||
|
||||
const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
expect(saved).toEqual({ token: 'value' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user