243 lines
8.6 KiB
JavaScript
243 lines
8.6 KiB
JavaScript
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;
|
||
|
|
}
|