Files

243 lines
8.6 KiB
JavaScript
Raw Permalink Normal View History

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
getLinearAuth,
getLinearAuthWorkspaces,
setLinearAuth,
activateLinearAuth,
clearLinearAuth,
toLinearPublicStatus,
getLinearClientId,
getLinearRedirectUri,
isLinearAccessTokenStale,
getLinearAuthFilePath,
DEFAULT_LINEAR_CLIENT_ID_VALUE,
} from './auth.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-auth-'));
describe('Linear auth storage', () => {
let dataDir;
let previousDataDir;
let previousPort;
let previousClientId;
let previousRedirect;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
previousPort = process.env.OPENCHAMBER_PORT;
previousClientId = process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
delete process.env.OPENCHAMBER_LINEAR_SCOPES;
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
delete process.env.OPENCHAMBER_PORT;
});
afterEach(() => {
restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir);
restoreEnv('OPENCHAMBER_PORT', previousPort);
restoreEnv('OPENCHAMBER_LINEAR_CLIENT_ID', previousClientId);
restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect);
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('returns disconnected when no auth file exists', () => {
expect(getLinearAuth()).toBeNull();
expect(toLinearPublicStatus(null)).toEqual({ connected: false });
});
it('persists tokens without exposing them on the public status', () => {
setLinearAuth({
accessToken: 'lin_oauth_access',
refreshToken: 'lin_oauth_refresh',
expiresAt: Date.now() + 60_000,
scope: 'read,write',
user: { id: 'user-1', name: 'Ada', displayName: 'Ada Lovelace', email: 'ada@example.com', avatarUrl: 'https://example.com/a.png' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
});
const stored = getLinearAuth();
expect(stored.accessToken).toBe('lin_oauth_access');
expect(stored.refreshToken).toBe('lin_oauth_refresh');
expect(stored.workspaceId).toBe('org-1');
const publicStatus = toLinearPublicStatus(stored);
expect(publicStatus).toEqual({
connected: true,
user: {
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
},
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
scope: 'read,write',
workspaces: [{
id: 'org-1',
name: 'OpenChamber',
urlKey: 'openchamber',
current: true,
user: {
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
},
authorizedAt: stored.authorizedAt,
}],
});
expect(JSON.stringify(publicStatus)).not.toContain('lin_oauth');
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
expect(file.accessToken).toBeUndefined();
expect(file.workspaces).toHaveLength(1);
expect(file.workspaces[0].accessToken).toBe('lin_oauth_access');
});
it('keeps the previous refresh token when a later write omits it', () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresAt: 1,
});
setLinearAuth({
accessToken: 'access-2',
expiresAt: 2,
});
expect(getLinearAuth().refreshToken).toBe('refresh-1');
expect(getLinearAuth().accessToken).toBe('access-2');
});
it('rotates the refresh token when a new one is provided', () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
});
setLinearAuth({
accessToken: 'access-2',
refreshToken: 'refresh-2',
});
expect(getLinearAuth().refreshToken).toBe('refresh-2');
});
it('rejects a write without an access token', () => {
expect(() => setLinearAuth({ refreshToken: 'refresh-1' })).toThrow('accessToken is required');
});
it('treats a missing or past expiry as stale', () => {
expect(isLinearAccessTokenStale(null)).toBe(true);
expect(isLinearAccessTokenStale(Date.now() - 1)).toBe(true);
expect(isLinearAccessTokenStale(Date.now() + 10 * 60_000)).toBe(false);
});
it('uses the baked-in client id unless env or settings override it', () => {
expect(getLinearClientId()).toBe(DEFAULT_LINEAR_CLIENT_ID_VALUE);
process.env.OPENCHAMBER_LINEAR_CLIENT_ID = 'env-client';
expect(getLinearClientId()).toBe('env-client');
});
it('uses the stable public broker callback by default', () => {
process.env.OPENCHAMBER_PORT = '3001';
expect(getLinearRedirectUri()).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://localhost:3000/linear/oauth/callback';
expect(getLinearRedirectUri()).toBe('http://localhost:3000/linear/oauth/callback');
});
it('deletes the auth file on clear', () => {
setLinearAuth({ accessToken: 'access-1', refreshToken: 'refresh-1' });
expect(fs.existsSync(getLinearAuthFilePath())).toBe(true);
expect(clearLinearAuth()).toBe(true);
expect(fs.existsSync(getLinearAuthFilePath())).toBe(false);
expect(getLinearAuth()).toBeNull();
});
it('migrates a legacy single-workspace file', () => {
fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify({
accessToken: 'legacy-access',
refreshToken: 'legacy-refresh',
user: { id: 'user-1', name: 'Ada' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
}), 'utf8');
const stored = getLinearAuth();
expect(stored.accessToken).toBe('legacy-access');
expect(stored.workspaceId).toBe('org-1');
expect(stored.current).toBe(true);
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
expect(file.workspaces).toHaveLength(1);
expect(file.accessToken).toBeUndefined();
});
it('stores a second workspace and activates it without dropping the first', () => {
setLinearAuth({
accessToken: 'access-a',
refreshToken: 'refresh-a',
user: { id: 'user-a', name: 'Ada' },
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
});
setLinearAuth({
accessToken: 'access-b',
refreshToken: 'refresh-b',
user: { id: 'user-b', name: 'Ben' },
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
});
expect(getLinearAuth().workspaceId).toBe('org-b');
expect(getLinearAuthWorkspaces().map((entry) => entry.id).sort()).toEqual(['org-a', 'org-b']);
expect(activateLinearAuth('org-a')).toBe(true);
expect(getLinearAuth().workspaceId).toBe('org-a');
expect(getLinearAuth().accessToken).toBe('access-a');
expect(getLinearAuthWorkspaces().find((entry) => entry.id === 'org-b').current).toBe(false);
});
it('drops only the current workspace on unscoped clear', () => {
setLinearAuth({
accessToken: 'access-a',
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
user: { id: 'user-a', name: 'Ada' },
});
setLinearAuth({
accessToken: 'access-b',
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
user: { id: 'user-b', name: 'Ben' },
});
expect(clearLinearAuth()).toBe(true);
expect(getLinearAuth().workspaceId).toBe('org-a');
expect(getLinearAuth().accessToken).toBe('access-a');
expect(getLinearAuthWorkspaces()).toHaveLength(1);
});
it('does not bump authorizedAt when a later write opts out of activate', () => {
setLinearAuth({
accessToken: 'access-1',
user: { id: 'user-1', name: 'Ada' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
});
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
file.workspaces[0].authorizedAt = 111;
fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify(file, null, 2), 'utf8');
setLinearAuth({
accessToken: 'access-1',
user: { id: 'user-1', name: 'Ada' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
workspaceId: 'org-1',
}, { activate: false });
expect(getLinearAuth().authorizedAt).toBe(111);
expect(getLinearAuth().current).toBe(true);
});
});
function restoreEnv(name, previous) {
if (previous === undefined) {
delete process.env[name];
return;
}
process.env[name] = previous;
}