diff --git a/README.md b/README.md index a304452d..849231f0 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,10 @@ pnpm exec vizzly project status pnpm exec vizzly run "pnpm test" ``` -`vizzly login` signs you in. Linking saves the project name in your config and -keeps the upload token in user storage. Run it again to reuse a saved token. -Replace `"pnpm test"` with your test command. +`vizzly login` opens browser consent so you can choose read, build/upload, review, +and comment permissions. Your login can perform those actions within your current +project access. Linking saves the destination in your checkout; it does not issue +another credential. Replace `"pnpm test"` with your test command. If you linked with an older CLI, run `project link` once in each repository to save its project selection. Handwritten config may need a manual edit; the @@ -118,7 +119,8 @@ Cloud read commands accept `--auth auto|user|token`. The default, `auto`, uses an explicit flag, environment, or config token first, then your saved login, then the checkout's linked credential. `user` selects only your saved login; `token` selects only an upload or explicit API credential. `--token` and -`--auth user` cannot be combined. User login and upload credentials stay separate. +`--auth user` cannot be combined. Your saved login works for uploads too; explicit +project credentials remain available for CI. Lists show a page of accessible results and tell you how to fetch the next one. Use `--limit` and `--offset` for scripts; JSON keeps paging details under @@ -128,9 +130,24 @@ now honors explicit token settings consistently with other cloud reads. Vizzly refreshes an expired or nearly expired selected login automatically. If the saved session is rejected, run `vizzly login` and retry your command. -Connection or server errors preserve your saved session and ask you to retry. +A definite rejected refresh preserves your saved credentials. If a refresh may have +completed but its response was lost, the CLI requires a new login rather than +reusing a possibly consumed refresh token. Submitted API writes are never automatically replayed after an auth failure. +Use `vizzly whoami` to see the selected API origin and granted permissions. For a +restricted session, use `vizzly login --scopes read` or +`vizzly login --scopes "read builds:write"`, then confirm the selection in your +browser. `--no-browser` prints the authorization URL for remote terminals. Missing +permissions produce `INSUFFICIENT_SCOPE` with an action to authorize the needed +scope; they do not trigger repeated refreshes or switch identities. + +User sessions are isolated by API origin and stored in the system credential store +when available, with an owner-only file fallback. Custom `VIZZLY_HOME` directories +use isolated files by default. Concurrent CLI processes coordinate refresh, and +long-running commands renew again as later access tokens expire. Existing generic +login credentials require fresh browser consent. + To upload screenshots you already have: ```bash diff --git a/skills/vizzly/references/cli-context.md b/skills/vizzly/references/cli-context.md index 50224eb0..ef35f5fd 100644 --- a/skills/vizzly/references/cli-context.md +++ b/skills/vizzly/references/cli-context.md @@ -65,12 +65,17 @@ returns one logical page; large limits can require several bounded API requests. If the next offset is null while more results exist, narrow your filters to stay within the API's offset limit. An expired selected login is refreshed automatically; a rejected session asks for login. Report the recovery action when interactive -login isn't authorized. Network failures preserve the session. +login isn't authorized. Network failures preserve the session. `whoami` reports +granted scopes: reads require `read`, uploads require `builds:write`, review +decisions require `reviews:write`, and comments require `comments:write`. A scope +denial requires explicit user consent for additional permissions, not repeated +refreshes or a different identity. `vizzly project status --json` describes configured and effective upload destinations separately. Use `project link` only when setup or changing the -upload destination is authorized. It issues or reuses a project credential and -binds the checkout. Browsing another project must not change that binding. +upload destination is authorized. It saves the destination without issuing a credential. +The selected login can upload when it has `builds:write`; explicit project tokens +remain the automation path. Browsing another project must not change that binding. Use the ID supplied by the task. If no cloud build is named, find recent builds and choose the one matching the branch, commit, or pull request: diff --git a/src/adapters/cli/cli.js b/src/adapters/cli/cli.js index f22106e3..f172e733 100644 --- a/src/adapters/cli/cli.js +++ b/src/adapters/cli/cli.js @@ -1519,7 +1519,8 @@ Workflow: 2. List projects: vizzly projects --org 3. Query builds: vizzly builds --project --org -Use --auth user to browse your account when VIZZLY_TOKEN selects a project. +VIZZLY_TOKEN takes precedence over a saved login. Otherwise, your login is used. +The output shows which credential was selected. Linking is needed only to choose a checkout upload destination. ` ) @@ -1539,7 +1540,7 @@ let projectCommand = program .description('Show or choose the upload destination') .addHelpText( 'after', - '\nDiscover: vizzly orgs, vizzly projects --auth user\nBrowse: vizzly builds --auth user --project --org \nChoose uploads: vizzly project link\nInspect uploads: vizzly project status\nBrowsing another project does not change this checkout’s upload destination.\n' + '\nDiscover: vizzly orgs, vizzly projects\nBrowse: vizzly builds --project --org \nChoose uploads: vizzly project link\nInspect uploads: vizzly project status\nBrowsing another project does not change this checkout’s upload destination.\n' ) .showHelpAfterError('(Run vizzly project --help for available commands)') .showSuggestionAfterError(); @@ -1554,8 +1555,6 @@ projectCommand .description('Link a Vizzly project for cloud uploads') .option('--org ', 'Organization slug') .option('--project ', 'Project slug') - .option('--name ', 'Credential name shown in Vizzly') - .option('--expires-at ', 'Optional token expiration timestamp') .addHelpText( 'after', ` @@ -1564,8 +1563,9 @@ Examples: $ vizzly project link vizzly/storybook $ vizzly project link --org vizzly --project storybook -Note: run "vizzly login" first. The linked credential is project-scoped and is -used for cloud uploads; your user login remains separate for review actions. +Sign in to use the picker. An explicit org/project can be saved before login. +Linking saves the upload destination for this checkout. Your selected login +permissions control which actions are allowed. ` ) .action(async (selector, options) => { @@ -1651,8 +1651,16 @@ program program .command('login') - .description('Authenticate with your Vizzly account') + .description('Log in and choose CLI permissions in your browser') .option('--api-url ', 'API URL override') + .option( + '--no-browser', + 'Print the authorization URL without opening a browser' + ) + .option( + '--scopes ', + 'Permissions to request, separated by spaces or commas' + ) .action(async options => { let globalOptions = getGlobalOptions(); diff --git a/src/adapters/http/cloud-proxy.js b/src/adapters/http/cloud-proxy.js index 528a523d..8d3501fa 100644 --- a/src/adapters/http/cloud-proxy.js +++ b/src/adapters/http/cloud-proxy.js @@ -3,16 +3,16 @@ import { createCloudBuildsRouter } from '../../domains/builds/cloud-builds.route import { createCloudProjectsRouter } from '../../domains/projects/cloud-projects.routes.js'; import { sendError, sendServiceUnavailable } from './middleware/response.js'; -export function createCloudProxyRouter({ authService }) { - let projects = createCloudProjectsRouter({ authService }); - let builds = createCloudBuildsRouter({ authService }); - let baselines = createCloudBaselinesRouter({ authService }); +export function createCloudProxyRouter({ projectService }) { + let projects = createCloudProjectsRouter({ projectService }); + let builds = createCloudBuildsRouter({ projectService }); + let baselines = createCloudBaselinesRouter({ projectService }); return async function handleCloudProxyRoute(req, res, pathname, parsedUrl) { if (!pathname.startsWith('/api/cloud')) return false; - if (!authService) { - sendServiceUnavailable(res, 'Auth service'); + if (!projectService) { + sendServiceUnavailable(res, 'Project service'); return true; } diff --git a/src/adapters/http/cloud-proxy.test.js b/src/adapters/http/cloud-proxy.test.js index 512852d6..1f6bcd3b 100644 --- a/src/adapters/http/cloud-proxy.test.js +++ b/src/adapters/http/cloud-proxy.test.js @@ -5,12 +5,12 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { createCloudServer } from '../../../tests/helpers/cloud-server.js'; -import { createAuthService } from '../../domains/auth/auth.service.js'; import { - clearAuthTokens, - saveAuthTokens, -} from '../../domains/auth/token-store.js'; + fixtureAuthorization, + saveFixtureAuth, +} from '../../../tests/helpers/auth-session.js'; +import { createCloudServer } from '../../../tests/helpers/cloud-server.js'; +import { createProjectService } from '../../domains/projects/project.service.js'; import { createCloudProxyRouter } from './cloud-proxy.js'; async function isolateHome(t) { @@ -22,10 +22,11 @@ async function isolateHome(t) { else process.env.VIZZLY_HOME = originalHome; await rm(directory, { recursive: true, force: true }); }); + return directory; } -async function serveProxy(t, authService) { - let router = createCloudProxyRouter({ authService }); +async function serveProxy(t, projectService) { + let router = createCloudProxyRouter({ projectService }); let handlerError; let server = createServer(async (req, res) => { try { @@ -51,6 +52,21 @@ async function serveProxy(t, authService) { return (path, options) => fetch(`${baseUrl}${path}`, options); } +function page(items, query) { + let limit = Number(query.get('limit')); + let offset = Number(query.get('offset')); + let values = items.slice(offset, offset + limit); + return { + values, + meta: { + total: items.length, + limit, + offset, + hasMore: offset + values.length < items.length, + }, + }; +} + function downloadRequest(body) { return { method: 'POST', @@ -59,91 +75,100 @@ function downloadRequest(body) { }; } -test('saved authentication lets the browser browse projects/builds and download selected baselines', async t => { - await isolateHome(t); - await saveAuthTokens({ accessToken: 'saved-cloud-token' }); +test('cloud reads use the selected user session and SDK contracts', async t => { + let home = await isolateHome(t); + let projects = Array.from({ length: 61 }, (_, index) => ({ + id: `project-${index}`, + slug: `project-${index}`, + organizationSlug: 'my org', + })); let { client, requests } = await createCloudServer(t, request => { - if (request.url === '/api/cli/projects') { - return { body: { projects: [{ slug: 'my-project' }] } }; + let url = new URL(request.url, 'http://cloud'); + if (url.pathname === '/api/sdk/projects') { + let { values, meta } = page(projects, url.searchParams); + return { body: { projects: values, meta } }; } - if (request.method === 'GET') { - return { body: { builds: [{ id: 'build-123' }] } }; + if (url.pathname === '/api/sdk/builds') { + let { values, meta } = page( + [{ id: 'build-1' }, { id: 'build-2' }], + url.searchParams + ); + return { body: { builds: values, meta } }; + } + if (url.pathname === '/api/sdk/builds/build-1/tdd-baselines') { + return { + body: { + build: { id: 'build-1' }, + screenshots: [{ name: 'home' }, { name: 'cart' }], + signatureProperties: ['browser'], + }, + }; } return { - body: { - downloaded: 2, - success: false, - message: 'Cloud download response', - }, + status: 404, + body: { error: `Unexpected cloud request: ${url.pathname}` }, }; }); + await saveFixtureAuth(home, client.getBaseUrl()); let request = await serveProxy( t, - createAuthService({ apiUrl: client.getBaseUrl() }) + createProjectService({ apiUrl: client.getBaseUrl() }) ); - let projects = await request('/api/cloud/projects'); - assert.equal(projects.status, 200); - assert.deepEqual(await projects.json(), { - projects: [{ slug: 'my-project' }], - }); - let builds = await request( - '/api/cloud/organizations/my%20org/projects/my%20project/builds?limit=10&branch=feature%2Fnew+page' + let projectResponse = await request('/api/cloud/projects'); + assert.equal(projectResponse.status, 200); + assert.deepEqual((await projectResponse.json()).projects, projects); + let buildsResponse = await request( + '/api/cloud/organizations/my%20org/projects/project-1/builds?limit=2&branch=feature%2Fnew+page' ); - assert.equal(builds.status, 200); - assert.deepEqual(await builds.json(), { builds: [{ id: 'build-123' }] }); - await request('/api/cloud/organizations/org/projects/project/builds'); - await request('/api/cloud/organizations/org/projects/project/builds?limit='); - let download = await request( + assert.equal(buildsResponse.status, 200); + assert.deepEqual(await buildsResponse.json(), { + builds: [{ id: 'build-1' }, { id: 'build-2' }], + }); + let baselineResponse = await request( '/api/cloud/baselines/download', - downloadRequest({ buildId: 'build-123', screenshotNames: ['home', 'cart'] }) + downloadRequest({ buildId: 'build-1', screenshotNames: ['cart'] }) ); - assert.equal(download.status, 200); - assert.deepEqual(await download.json(), { - success: false, - message: 'Cloud download response', - downloaded: 2, + assert.equal(baselineResponse.status, 200); + assert.deepEqual(await baselineResponse.json(), { + success: true, + message: 'Baselines downloaded from build build-1', + build: { id: 'build-1' }, + screenshots: [{ name: 'cart' }], + signatureProperties: ['browser'], }); assert.deepEqual( - requests.map(({ method, url }) => ({ method, url })), + requests.map(({ method, url }) => [method, url]), [ - { method: 'GET', url: '/api/cli/projects' }, - { - method: 'GET', - url: '/api/cli/organizations/my%20org/projects/my%20project/builds?limit=10&branch=feature%2Fnew+page', - }, - { - method: 'GET', - url: '/api/cli/organizations/org/projects/project/builds?limit=20', - }, - { - method: 'GET', - url: '/api/cli/organizations/org/projects/project/builds?limit=20', - }, - { method: 'POST', url: '/api/cli/baselines/download' }, + ['GET', '/api/sdk/projects?limit=50&offset=0'], + ['GET', '/api/sdk/projects?limit=50&offset=50'], + [ + 'GET', + '/api/sdk/builds?organization=my+org&project=project-1&branch=feature%2Fnew+page&limit=2&offset=0', + ], + ['GET', '/api/sdk/builds/build-1/tdd-baselines'], ] ); assert( - requests.every( - item => item.headers.authorization === 'Bearer saved-cloud-token' - ) + requests.every(item => item.headers.authorization === 'Bearer fixture-user') + ); + assert( + requests + .slice(0, 3) + .every(item => item.headers['vizzly-api-version'] === '2026-09-12') ); - assert.equal(requests.at(-1).headers['content-type'], 'application/json'); - assert.deepEqual(requests.at(-1).body, { - buildId: 'build-123', - screenshotNames: ['home', 'cart'], - }); }); -test('cloud routing and input errors are handled locally without making cloud requests', async t => { - await isolateHome(t); - await saveAuthTokens({ accessToken: 'saved-cloud-token' }); +test('cloud route validation and unavailable-service responses stay local', async t => { + let home = await isolateHome(t); let { client, requests } = await createCloudServer(t, () => ({ body: {} })); + await saveFixtureAuth(home, client.getBaseUrl()); let request = await serveProxy( t, - createAuthService({ apiUrl: client.getBaseUrl() }) + createProjectService({ apiUrl: client.getBaseUrl() }) ); + let outside = await request('/other'); assert.equal(outside.status, 418); for (let path of ['/api/cloud/unknown', '/api/cloud/projects']) { @@ -176,85 +201,91 @@ test('cloud routing and input errors are handled locally without making cloud re let response = await unavailable('/api/cloud/projects'); assert.equal(response.status, 503); assert.deepEqual(await response.json(), { - error: 'Auth service not available', + error: 'Project service not available', }); }); -test('missing or rejected saved authentication produces an empty project list for the browser', async t => { - await isolateHome(t); - await clearAuthTokens(); - let { client, requests } = await createCloudServer(t, () => ({ - status: 401, - body: { error: 'Expired access token' }, - })); +test('each project read uses the currently saved user session', async t => { + let home = await isolateHome(t); + let { client, requests } = await createCloudServer(t, request => { + if (request.url.startsWith('/api/sdk/projects?')) { + let project = + request.headers.authorization === 'Bearer session-a' + ? { id: 'project-a', slug: 'project-a' } + : { id: 'project-b', slug: 'project-b' }; + return { + body: { + projects: [project], + meta: { total: 1, limit: 50, offset: 0, hasMore: false }, + }, + }; + } + return { status: 404, body: { error: 'Unexpected cloud request' } }; + }); + let sessionA = { + ...fixtureAuthorization, + sessionId: '8bfb8060-ef8e-4c3e-b58b-ffb826ba8991', + }; + let sessionB = { + ...fixtureAuthorization, + sessionId: '8bfb8060-ef8e-4c3e-b58b-ffb826ba8992', + }; + await saveFixtureAuth(home, client.getBaseUrl(), { + accessToken: 'session-a', + authorization: sessionA, + }); let request = await serveProxy( t, - createAuthService({ apiUrl: client.getBaseUrl() }) + createProjectService({ apiUrl: client.getBaseUrl() }) ); - let missing = await request('/api/cloud/projects'); - assert.equal(missing.status, 200); - assert.deepEqual(await missing.json(), { - projects: [], - authenticated: false, - }); - assert.equal(requests.length, 0); - await saveAuthTokens({ accessToken: 'rejected-token' }); - let rejected = await request('/api/cloud/projects'); - assert.equal(rejected.status, 200); - assert.deepEqual(await rejected.json(), { - projects: [], - authenticated: false, + let firstRead = await request('/api/cloud/projects'); + assert.deepEqual((await firstRead.json()).projects, [ + { id: 'project-a', slug: 'project-a' }, + ]); + await saveFixtureAuth(home, client.getBaseUrl(), { + accessToken: 'session-b', + authorization: sessionB, }); - assert.equal(requests.length, 1); - assert.equal(requests[0].headers.authorization, 'Bearer rejected-token'); + let secondRead = await request('/api/cloud/projects'); + assert.deepEqual((await secondRead.json()).projects, [ + { id: 'project-b', slug: 'project-b' }, + ]); + assert.deepEqual( + requests.map(item => item.headers.authorization), + ['Bearer session-a', 'Bearer session-b'] + ); }); -test('cloud response defaults and failures remain visible through each product route', async t => { - await isolateHome(t); - await saveAuthTokens({ accessToken: 'saved-cloud-token' }); - let failing = false; - let { client } = await createCloudServer(t, () => ({ - status: failing ? 503 : 200, - body: failing ? { error: 'Cloud is unavailable' } : {}, - })); +test('signed-out project reads stay empty without hiding a saved-session error', async t => { + let home = await isolateHome(t); + let { client, requests } = await createCloudServer(t, request => { + if (request.url === '/api/auth/cli/refresh') { + return { status: 401, body: { error: 'Refresh rejected' } }; + } + return { status: 401, body: { error: 'Expired session' } }; + }); let request = await serveProxy( t, - createAuthService({ apiUrl: client.getBaseUrl() }) - ); - let projects = await request('/api/cloud/projects'); - assert.deepEqual(await projects.json(), { projects: [] }); - let builds = await request( - '/api/cloud/organizations/org/projects/proj/builds' + createProjectService({ apiUrl: client.getBaseUrl() }) ); - assert.deepEqual(await builds.json(), { builds: [] }); - let download = await request( - '/api/cloud/baselines/download', - downloadRequest({ buildId: 'build-123' }) - ); - assert.deepEqual(await download.json(), { - success: true, - message: 'Baselines downloaded from build build-123', + + let signedOut = await request('/api/cloud/projects'); + assert.equal(signedOut.status, 200); + assert.deepEqual(await signedOut.json(), { + projects: [], + authenticated: false, }); + assert.equal(requests.length, 0); - failing = true; - for (let [path, options, endpoint] of [ - ['/api/cloud/projects', undefined, '/api/cli/projects'], - [ - '/api/cloud/organizations/org/projects/proj/builds', - undefined, - '/api/cli/organizations/org/projects/proj/builds?limit=20', - ], - [ - '/api/cloud/baselines/download', - downloadRequest({ buildId: 'build-123' }), - '/api/cli/baselines/download', - ], - ]) { - let response = await request(path, options); - assert.equal(response.status, 500); - assert.deepEqual(await response.json(), { - error: `API request failed: 503 - Cloud is unavailable (${endpoint})`, - }); - } + await saveFixtureAuth(home, client.getBaseUrl(), { + expiresAt: '2000-01-01T00:00:00.000Z', + }); + let expired = await request('/api/cloud/projects'); + assert.equal(expired.status, 401); + assert.match((await expired.json()).error, /saved login has expired/i); + assert.deepEqual( + requests.map(({ method, url }) => [method, url]), + [['POST', '/api/auth/cli/refresh']] + ); }); diff --git a/src/adapters/http/server-options.js b/src/adapters/http/server-options.js index 55e5595f..e7e6367a 100644 --- a/src/adapters/http/server-options.js +++ b/src/adapters/http/server-options.js @@ -106,6 +106,7 @@ export function buildClientOptions(config) { return { baseUrl: config.apiUrl, token: config.apiKey, + ...(config.credential ? { credential: config.credential } : {}), command: 'run', }; } diff --git a/src/adapters/sdk/index.js b/src/adapters/sdk/index.js index d169b41e..9ff31c47 100644 --- a/src/adapters/sdk/index.js +++ b/src/adapters/sdk/index.js @@ -78,6 +78,12 @@ export async function createVizzly(config = {}, options = {}) { { apiKey: activeConfig.apiKey, apiUrl: activeConfig.apiUrl, + ...(activeConfig.credential + ? { credential: activeConfig.credential } + : {}), + ...(activeConfig.projectQuery + ? { projectQuery: activeConfig.projectQuery } + : {}), upload: uploadConfig, }, { ...options, ...serviceOptions } @@ -240,6 +246,12 @@ export class VizzlySDK extends EventEmitter { { apiKey: activeConfig.apiKey, apiUrl: activeConfig.apiUrl, + ...(activeConfig.credential + ? { credential: activeConfig.credential } + : {}), + ...(activeConfig.projectQuery + ? { projectQuery: activeConfig.projectQuery } + : {}), upload: resolvedUploadConfig, }, resolvedServiceOptions diff --git a/src/domains/api/api.commands.js b/src/domains/api/api.commands.js index 714e0ba3..947fa972 100644 --- a/src/domains/api/api.commands.js +++ b/src/domains/api/api.commands.js @@ -9,6 +9,7 @@ import { pipeline } from 'node:stream/promises'; import * as defaultOutput from '../../shared/output.js'; import { createApiClient as defaultCreateApiClient } from '../auth/cloud-client.js'; import { resolveCredential } from '../auth/credential.service.js'; +import { scopeErrorDetails } from '../auth/credential-error.serializers.js'; import { showCredential } from '../auth/credential-presentation.js'; import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; @@ -237,7 +238,7 @@ export async function apiCommand( // Pretty print response for humans output.header('api'); - showCredential(output, credential, 'projects'); + showCredential(output, credential); output.labelValue('Endpoint', normalizedEndpoint); output.labelValue('Method', method); output.blank(); @@ -261,6 +262,7 @@ export async function apiCommand( message: error.message, code: error.code, status: error.context?.status, + ...scopeErrorDetails(error), }, }); output.cleanup(); diff --git a/src/domains/auth/auth-client.js b/src/domains/auth/auth-client.js index 76d49527..69d62b6a 100644 --- a/src/domains/auth/auth-client.js +++ b/src/domains/auth/auth-client.js @@ -35,6 +35,33 @@ async function parseResponseBody(response) { */ export function createAuthClient(options = {}) { let { baseUrl, userAgent } = options; + let requestTimeoutMs = options.requestTimeoutMs ?? 30000; + + function requestSignal(signal) { + let deadline = AbortSignal.timeout(requestTimeoutMs); + return signal ? AbortSignal.any([signal, deadline]) : deadline; + } + + function transportError(signal) { + return signal.reason?.name === 'TimeoutError' + ? new VizzlyError( + 'The Vizzly authentication request timed out. Try this command again.', + 'AUTH_REQUEST_TIMEOUT' + ) + : new VizzlyError( + `Unable to reach Vizzly at ${new URL(baseUrl).origin}. Try this command again when connected.`, + 'NETWORK_ERROR' + ); + } + + async function readSuccess(response, signal) { + try { + return await response.json(); + } catch (error) { + if (signal.aborted) throw transportError(signal); + throw error; + } + } if (!userAgent) { userAgent = buildAuthUserAgent(getPackageVersion()); @@ -44,6 +71,7 @@ export function createAuthClient(options = {}) { * Make an unauthenticated request */ async function request(endpoint, fetchOptions = {}) { + let signal = requestSignal(fetchOptions.signal); let url = `${baseUrl}${endpoint}`; let headers = buildRequestHeaders({ userAgent, @@ -57,12 +85,10 @@ export function createAuthClient(options = {}) { redirect: 'error', ...fetchOptions, headers, + signal, }); } catch { - throw new VizzlyError( - `Unable to reach Vizzly at ${new URL(baseUrl).origin}. Try this command again when connected.`, - 'NETWORK_ERROR' - ); + throw transportError(signal); } if (!response.ok) { @@ -73,7 +99,7 @@ export function createAuthClient(options = {}) { throw error; } - return response.json(); + return readSuccess(response, signal); } /** @@ -84,6 +110,7 @@ export function createAuthClient(options = {}) { accessToken, fetchOptions = {} ) { + let signal = requestSignal(fetchOptions.signal); let url = `${baseUrl}${endpoint}`; let headers = buildRequestHeaders({ userAgent, @@ -92,17 +119,23 @@ export function createAuthClient(options = {}) { extra: fetchOptions.headers, }); - let response = await fetch(url, { - ...fetchOptions, - headers, - }); + let response; + try { + response = await fetch(url, { + ...fetchOptions, + headers, + signal, + }); + } catch { + throw transportError(signal); + } if (!response.ok) { let body = await parseResponseBody(response); throw parseAuthenticatedError(response.status, body, endpoint); } - return response.json(); + return readSuccess(response, signal); } return { diff --git a/src/domains/auth/auth-client.test.js b/src/domains/auth/auth-client.test.js index 8e0d1503..9c9ab9f1 100644 --- a/src/domains/auth/auth-client.test.js +++ b/src/domains/auth/auth-client.test.js @@ -52,6 +52,50 @@ describe('auth/client', () => { }); }); + for (let authenticated of [false, true]) { + it(`bounds ${authenticated ? 'authenticated' : 'unauthenticated'} network waits with a deadline`, async t => { + let deadline = new AbortController(); + t.mock.method(AbortSignal, 'timeout', duration => { + assert.strictEqual(duration, 30000); + return deadline.signal; + }); + mockFetch.mock.mockImplementation( + (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }) + ); + let client = createAuthClient({ baseUrl: 'https://api.test' }); + let result = authenticated + ? client.authenticatedRequest('/api/auth/cli/whoami', 'token') + : client.request('/api/auth/cli/refresh'); + deadline.abort( + new DOMException('Request deadline reached', 'TimeoutError') + ); + await assert.rejects(() => result, { code: 'AUTH_REQUEST_TIMEOUT' }); + }); + } + + it('retains caller cancellation while applying the request deadline', async () => { + let caller = new AbortController(); + mockFetch.mock.mockImplementation( + (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }) + ); + let client = createAuthClient({ baseUrl: 'https://api.test' }); + let result = client.request('/api/auth/cli/device/poll', { + signal: caller.signal, + }); + caller.abort(); + await assert.rejects(() => result, { code: 'NETWORK_ERROR' }); + }); + describe('request', () => { it('makes unauthenticated request to endpoint', async () => { let client = createAuthClient({ baseUrl: 'https://api.test' }); diff --git a/src/domains/auth/auth-contracts.test.js b/src/domains/auth/auth-contracts.test.js index cc71cbc7..7639cfca 100644 --- a/src/domains/auth/auth-contracts.test.js +++ b/src/domains/auth/auth-contracts.test.js @@ -4,6 +4,7 @@ import { AuthError, VizzlyError } from '../../shared/vizzly-error.js'; import { buildTokenData, buildTokens, + normalizeAuthorization, normalizeDeviceFlowResponse, } from './auth.normalizers.js'; import { resolveAuthorizedTokenData } from './auth.rules.js'; @@ -348,3 +349,46 @@ describe('auth/core', () => { }); }); }); + +describe('CLI authorization metadata', () => { + it('preserves the granted scope subset through exchange and refresh projections', () => { + let authorization = Object.freeze({ + sessionId: 'session-1', + clientId: 'vizzly-cli', + scopes: Object.freeze(['read']), + }); + let tokens = { + accessToken: 'access', + refreshToken: 'refresh', + expiresIn: 900, + }; + assert.deepStrictEqual( + buildTokens({ tokens, authorization }).authorization, + authorization + ); + assert.deepStrictEqual( + buildTokenData({ ...tokens, authorization }).authorization, + authorization + ); + }); + + it('never invents permissions when the server has no grant metadata', () => { + assert.strictEqual(normalizeAuthorization(null), undefined); + assert.strictEqual( + buildTokens({ tokens: { accessToken: 'access' } }).authorization, + undefined + ); + }); + + it('rejects malformed scope metadata instead of storing a guessed grant', () => { + for (let authorization of [ + { sessionId: 'session-1', clientId: 'other-client', scopes: ['read'] }, + { sessionId: 'session-1', clientId: 'vizzly-cli', scopes: 'read' }, + { clientId: 'vizzly-cli', scopes: ['read'] }, + ]) { + assert.throws(() => normalizeAuthorization(authorization), { + code: 'INVALID_AUTH_RESPONSE', + }); + } + }); +}); diff --git a/src/domains/auth/auth-errors.js b/src/domains/auth/auth-errors.js index 7267e0a1..5ea5aa91 100644 --- a/src/domains/auth/auth-errors.js +++ b/src/domains/auth/auth-errors.js @@ -75,3 +75,26 @@ export function parseAuthenticatedError(status, body, endpoint) { { status } ); } + +export function createInsufficientScopeError({ + requiredScopes = [], + grantedScopes = [], +} = {}) { + let required = Array.isArray(requiredScopes) + ? requiredScopes.filter( + scope => typeof scope === 'string' && /^[a-z][a-z0-9:._-]*$/.test(scope) + ) + : []; + let granted = Array.isArray(grantedScopes) + ? grantedScopes.filter( + scope => typeof scope === 'string' && /^[a-z][a-z0-9:._-]*$/.test(scope) + ) + : []; + let requested = [...new Set([...granted, ...required])]; + let permission = required.join(', ') || 'the required'; + return new VizzlyError( + `This credential does not include ${permission} permission. Run vizzly login${requested.length ? ` --scopes "${requested.join(' ')}"` : ''} and authorize the required permissions in your browser.`, + 'INSUFFICIENT_SCOPE', + { status: 403, requiredScopes: required, grantedScopes: granted } + ); +} diff --git a/src/domains/auth/auth-integration.routes.test.js b/src/domains/auth/auth-integration.routes.test.js index 2c189979..e26980a5 100644 --- a/src/domains/auth/auth-integration.routes.test.js +++ b/src/domains/auth/auth-integration.routes.test.js @@ -5,17 +5,18 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, before, beforeEach, test } from 'node:test'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../../tests/helpers/auth-session.js'; import { createAuthRouter } from './auth.routes.js'; import { createAuthService } from './auth.service.js'; -import { - clearAuthTokens, - getAuthTokens, - saveAuthTokens, -} from './token-store.js'; +import { clearAuthTokens, getAuthTokens } from './token-store.js'; let upstream; let local; let base; +let apiUrl; let response; let testHome; let originalHome = process.env.VIZZLY_HOME; @@ -36,9 +37,8 @@ before(async () => { }); upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); - let service = createAuthService({ - apiUrl: `http://127.0.0.1:${upstream.address().port}`, - }); + apiUrl = `http://127.0.0.1:${upstream.address().port}`; + let service = createAuthService({ apiUrl }); let router = createAuthRouter({ authService: service }); local = createServer(async (req, res) => { if (!(await router(req, res, new URL(req.url, 'http://local').pathname))) { @@ -50,7 +50,7 @@ before(async () => { base = `http://127.0.0.1:${local.address().port}`; }); beforeEach(async () => { - await clearAuthTokens(); + await clearAuthTokens(apiUrl); paths = []; authorizations = []; response = { status: 200, body: {} }; @@ -138,7 +138,7 @@ test('expected outcomes use protocol codes rather than the error wording', async let result = await post('poll', { deviceCode: 'device' }); assert.equal(result.status, 200); assert.deepEqual(result.body, { status: expected }); - assert.equal(await getAuthTokens(), null); + assert.equal(await getAuthTokens(apiUrl), null); } assert.ok(paths.every(path => path === '/api/auth/cli/device/poll')); }); @@ -156,7 +156,7 @@ test('upstream throttling retains status, code and retry timing through both end assert.equal(result.body.retryAfter, 73); assert.equal(result.headers.get('retry-after'), '73'); } - assert.equal(await getAuthTokens(), null); + assert.equal(await getAuthTokens(apiUrl), null); }); test('token aliases save normalized credentials without returning them to the browser', async () => { @@ -164,14 +164,18 @@ test('token aliases save normalized credentials without returning them to the br { accessToken: 'access', refreshToken: 'refresh', expiresIn: 900 }, { access_token: 'access', refresh_token: 'refresh', expires_in: 900 }, ]) { - response.body = { tokens, user: { username: 'test-user' } }; + response.body = { + tokens, + user: { username: 'test-user' }, + authorization: fixtureAuthorization, + }; let result = await post('poll', { deviceCode: 'device' }); assert.equal(result.status, 200); assert.deepEqual(result.body, { status: 'complete', user: { username: 'test-user' }, }); - let saved = await getAuthTokens(); + let saved = await getAuthTokens(apiUrl); assert.equal(saved.accessToken, 'access'); assert.equal(saved.refreshToken, 'refresh'); assert.ok(Date.parse(saved.expiresAt) > Date.now()); @@ -183,15 +187,18 @@ test('unknown successful authorization payloads fail instead of silently staying let result = await post('poll', { deviceCode: 'device' }); assert.equal(result.status, 500); assert.match(result.body.error, /Unexpected response/); - assert.equal(await getAuthTokens(), null); + assert.equal(await getAuthTokens(apiUrl), null); }); -test('sign-out authenticates revocation and clears the local session', async () => { - await saveAuthTokens({ accessToken: 'access', refreshToken: 'refresh' }); +test('sign-out uses the refresh credential and clears the local session', async () => { + await saveFixtureAuth(testHome, apiUrl, { + accessToken: 'access', + refreshToken: 'refresh', + }); response.body = { message: 'CLI logout successful' }; let result = await post('logout'); assert.equal(result.status, 200); assert.deepEqual(paths, ['/api/auth/cli/logout']); - assert.deepEqual(authorizations, ['Bearer access']); - assert.equal(await getAuthTokens(), null); + assert.deepEqual(authorizations, [undefined]); + assert.equal(await getAuthTokens(apiUrl), null); }); diff --git a/src/domains/auth/auth.normalizers.js b/src/domains/auth/auth.normalizers.js index 38ecb39f..184695c4 100644 --- a/src/domains/auth/auth.normalizers.js +++ b/src/domains/auth/auth.normalizers.js @@ -1,3 +1,5 @@ +import { VizzlyError } from '../../shared/vizzly-error.js'; + /** * Build token data for storage from API response * @param {Object} response - API response with tokens @@ -18,6 +20,9 @@ export function buildTokenData( ? new Date(now + response.expiresIn * 1000).toISOString() : undefined), user: response.user || existingUser, + ...(response.authorization + ? { authorization: normalizeAuthorization(response.authorization) } + : {}), }; } @@ -55,6 +60,9 @@ export function buildTokens(tokenData, now = Date.now()) { expiresAt: tokenExpiresAt, user: tokenData.user, organizations: tokenData.organizations, + ...(tokenData.authorization + ? { authorization: normalizeAuthorization(tokenData.authorization) } + : {}), }; } @@ -67,3 +75,28 @@ export function deviceAuthorizationStatus(error) { INVALID_DEVICE_CODE: 'invalid', }[error.context?.serverCode]; } + +/** Keep only the server's explicit CLI grant; missing metadata grants nothing. */ +export function normalizeAuthorization(value) { + if (value == null) return undefined; + if ( + typeof value !== 'object' || + typeof value.sessionId !== 'string' || + !value.sessionId || + value.clientId !== 'vizzly-cli' || + !Array.isArray(value.scopes) || + value.scopes.some( + scope => typeof scope !== 'string' || !/^[a-z][a-z0-9:._-]*$/.test(scope) + ) + ) { + throw new VizzlyError( + 'Vizzly returned invalid CLI permission information. Your saved login was preserved.', + 'INVALID_AUTH_RESPONSE' + ); + } + return { + sessionId: value.sessionId, + clientId: value.clientId, + scopes: [...new Set(value.scopes)], + }; +} diff --git a/src/domains/auth/auth.rules.js b/src/domains/auth/auth.rules.js index 3df96c7c..ffe94aa0 100644 --- a/src/domains/auth/auth.rules.js +++ b/src/domains/auth/auth.rules.js @@ -1,3 +1,5 @@ +import { VizzlyError } from '../../shared/vizzly-error.js'; + export function resolveAuthorizedTokenData(pollResponse) { let accessToken = pollResponse.tokens?.accessToken || pollResponse.tokens?.access_token; @@ -27,3 +29,11 @@ export function resolveAuthorizedTokenData(pollResponse) { 'Unexpected response from authorization server. Please try logging in again.' ); } + +export function requireScopedAuthorization(authorization) { + if (authorization) return; + throw new VizzlyError( + 'This login has no CLI permission grant. Run vizzly login again and choose permissions in your browser.', + 'LOGIN_REQUIRED' + ); +} diff --git a/src/domains/auth/auth.service.js b/src/domains/auth/auth.service.js index 02244622..7011df10 100644 --- a/src/domains/auth/auth.service.js +++ b/src/domains/auth/auth.service.js @@ -4,6 +4,7 @@ import { getApiUrl } from '../config/environment.js'; import { buildTokenData, deviceAuthorizationStatus, + normalizeAuthorization, } from './auth.normalizers.js'; import { buildDevicePollPayload, @@ -61,9 +62,12 @@ export function createAuthService(options = {}) { // Create token store adapter for global config // Allow injection for testing - let tokenStore = options.tokenStore || createTokenStore(); + let tokenStore = options.tokenStore || createTokenStore({ apiUrl }); return { + async hasSavedSession() { + return Boolean(await tokenStore.getTokens()); + }, /** * Check if user is authenticated * @returns {Promise} @@ -84,8 +88,8 @@ export function createAuthService(options = {}) { * Initiate OAuth device flow * @returns {Promise} Device code info */ - async initiateDeviceFlow() { - return initiateDeviceFlow(httpClient); + async initiateDeviceFlow(flowOptions) { + return initiateDeviceFlow(httpClient, flowOptions); }, /** @@ -130,15 +134,23 @@ export function createAuthService(options = {}) { * @returns {Promise} Response data */ async authenticatedRequest(endpoint, options = {}) { - let auth = await tokenStore.getTokens(); - if (!auth?.accessToken) { - throw new Error('Not authenticated'); + let session = createUserSession({ + httpClient, + tokenStore, + baseUrl: apiUrl, + command: 'api', + }); + let token = await session.prepare(); + try { + return await httpClient.authenticatedRequest(endpoint, token, options); + } catch (error) { + let method = (options.method || 'GET').toUpperCase(); + if (error.context?.status !== 401 || !['GET', 'HEAD'].includes(method)) + throw error; + if (!session.canRefresh()) throw session.loginRequired(); + token = await session.renew(); + return await httpClient.authenticatedRequest(endpoint, token, options); } - return httpClient.authenticatedRequest( - endpoint, - auth.accessToken, - options - ); }, }; } @@ -148,10 +160,14 @@ export function createAuthService(options = {}) { * @param {Object} httpClient - HTTP client with request method * @returns {Promise} Device code, user code, verification URL */ -export async function initiateDeviceFlow(httpClient) { +export async function initiateDeviceFlow(httpClient, { scope } = {}) { return httpClient.request('/api/auth/cli/device/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: 'vizzly-cli', + ...(scope ? { scope } : {}), + }), }); } @@ -187,7 +203,10 @@ export async function pollDeviceAuthorization( * @returns {Promise} Token data */ export async function completeDeviceFlow(tokenStore, tokenData) { - await tokenStore.saveTokens(buildTokenData(tokenData)); + requireSessionMetadata(tokenData); + await coordinate(tokenStore, () => + tokenStore.saveTokens(buildTokenData(tokenData)) + ); return tokenData; } @@ -197,47 +216,126 @@ export async function completeDeviceFlow(tokenStore, tokenData) { * @param {Object} tokenStore - Token storage * @returns {Promise} New tokens */ -export async function refresh(httpClient, tokenStore) { - let auth = await tokenStore.getTokens(); - let validation = validateTokens(auth, 'refreshToken'); - - if (!validation.valid) { - throw validation.error; - } - - let response = await httpClient.request('/api/auth/cli/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildRefreshPayload(auth.refreshToken)), - }); +function coordinate(tokenStore, operation) { + return tokenStore.withRefreshLock + ? tokenStore.withRefreshLock(operation) + : operation(); +} - if ( - typeof response.accessToken !== 'string' || - !response.accessToken || - typeof response.refreshToken !== 'string' || - !response.refreshToken - ) { +function requireSessionMetadata(auth) { + if (!auth?.authorization) { throw new VizzlyError( - 'Vizzly returned an invalid login refresh response. Your saved session was preserved.', - 'INVALID_AUTH_RESPONSE' + 'Run vizzly login to authorize this CLI and choose its permissions.', + 'LOGIN_REQUIRED' ); } + return normalizeAuthorization(auth.authorization); +} + +function refreshUncertainError(cause) { + let error = new VizzlyError( + 'Vizzly may have renewed your saved login, but the new credentials were not safely saved. Run vizzly login to authorize a new session. The CLI will not retry the old refresh token.', + 'AUTH_REFRESH_UNCERTAIN', + cause?.context || {} + ); + if (cause) error.cause = cause; + return error; +} + +function isDefiniteRefreshRejection(error) { + let status = error.context?.status; + return ( + status >= 400 && + status < 500 && + error.context?.serverCode !== 'REFRESH_TOKEN_REUSED' + ); +} + +export async function refresh(httpClient, tokenStore, { expectedAuth } = {}) { + let observed = expectedAuth || (await tokenStore.getTokens()); + requireSessionMetadata(observed); + return await coordinate(tokenStore, async () => { + let auth = await tokenStore.getTokens(); + let validation = validateTokens(auth, 'refreshToken'); + + if (!validation.valid) { + throw validation.error; + } + requireSessionMetadata(auth); + if (auth.refreshUncertain) throw refreshUncertainError(); + if (auth.authorization.sessionId !== observed.authorization.sessionId) { + throw new VizzlyError( + 'Your saved login changed during this command. Run the command again.', + 'AUTH_SESSION_CHANGED' + ); + } + if (auth.refreshToken !== observed.refreshToken) return auth; + + await tokenStore.markRefreshUncertain(); + let response; + try { + response = await httpClient.request('/api/auth/cli/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildRefreshPayload(auth.refreshToken)), + }); + } catch (error) { + if (isDefiniteRefreshRejection(error)) { + try { + await tokenStore.clearRefreshUncertain(); + } catch { + throw refreshUncertainError(error); + } + throw error; + } + throw refreshUncertainError(error); + } + + try { + if ( + typeof response.accessToken !== 'string' || + !response.accessToken || + typeof response.refreshToken !== 'string' || + !response.refreshToken + ) { + throw new VizzlyError( + 'Vizzly returned an invalid login refresh response.', + 'INVALID_AUTH_RESPONSE' + ); + } + let authorization = normalizeAuthorization(response.authorization); + if ( + !authorization || + authorization.sessionId !== auth.authorization.sessionId + ) { + throw new VizzlyError( + 'Vizzly returned an invalid session refresh response.', + 'INVALID_AUTH_RESPONSE' + ); + } - // Preserve existing user data when refreshing - await tokenStore.saveTokens(buildTokenData(response, auth.user)); + // Preserve existing user data when refreshing. Saving replaces the + // marker with the new pair, so a crash cannot replay the old token. + await tokenStore.saveTokens(buildTokenData(response, auth.user)); + } catch (error) { + throw refreshUncertainError(error); + } - return response; + return response; + }); } -/** One selected login and one refresh budget for a command's requests. */ +/** One selected login and one refresh attempt for each access-token window. */ export function createUserSession({ baseUrl, command = 'projects', httpClient = createAuthClient({ baseUrl }), - tokenStore = createTokenStore(), + tokenStore = createTokenStore({ apiUrl: baseUrl }), } = {}) { let auth; let refreshAttempt; + let renewedAccessToken; + let renewedOutsideExpiryBuffer = false; let loginRequired = () => { let error = new AuthError( `Your saved login has expired. Run vizzly login, then rerun vizzly ${command}.`, @@ -249,11 +347,12 @@ export function createUserSession({ async function renew() { if (refreshAttempt) return refreshAttempt; refreshAttempt = (async () => { - auth = await tokenStore.getTokens(); + auth ||= await tokenStore.getTokens(); if (!auth?.refreshToken) throw loginRequired(); try { - await refresh(httpClient, tokenStore); + await refresh(httpClient, tokenStore, { expectedAuth: auth }); } catch (error) { + if (error.code === 'AUTH_REFRESH_UNCERTAIN') throw error; if ([400, 401, 403].includes(error.context?.status)) { throw loginRequired(); } @@ -267,6 +366,9 @@ export function createUserSession({ throw error; } auth = await tokenStore.getTokens(); + renewedAccessToken = auth.accessToken; + renewedOutsideExpiryBuffer = + Date.parse(auth.expiresAt) > Date.now() + 5 * 60 * 1000; return auth.accessToken; })(); return refreshAttempt; @@ -274,10 +376,18 @@ export function createUserSession({ async function prepare() { auth ||= await tokenStore.getTokens(); if (!auth?.accessToken) throw loginRequired(); + requireSessionMetadata(auth); if ( auth.expiresAt && !(Date.parse(auth.expiresAt) > Date.now() + 5 * 60 * 1000) ) { + if ( + renewedOutsideExpiryBuffer && + auth.accessToken === renewedAccessToken + ) { + refreshAttempt = undefined; + renewedOutsideExpiryBuffer = false; + } await renew(); } return auth.accessToken; @@ -292,26 +402,24 @@ export function createUserSession({ * @returns {Promise} */ export async function logout(httpClient, tokenStore) { - let auth = await tokenStore.getTokens(); + return await coordinate(tokenStore, async () => { + let auth = await tokenStore.getTokens(); - if (auth?.refreshToken) { - try { - await httpClient.authenticatedRequest( - '/api/auth/cli/logout', - auth.accessToken, - { + if (auth?.refreshToken) { + try { + await httpClient.request('/api/auth/cli/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildLogoutPayload(auth.refreshToken)), - } - ); - } catch (error) { - // If server request fails, still clear local tokens - output.warn(`Failed to revoke tokens on server: ${error.message}`); + }); + } catch (error) { + // If server request fails, still clear local tokens + output.warn(`Failed to revoke tokens on server: ${error.message}`); + } } - } - await tokenStore.clearTokens(); + await tokenStore.clearTokens(); + }); } /** diff --git a/src/domains/auth/auth.service.test.js b/src/domains/auth/auth.service.test.js index 9fa717bc..b1c40722 100644 --- a/src/domains/auth/auth.service.test.js +++ b/src/domains/auth/auth.service.test.js @@ -1,280 +1,375 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fixtureAuthorization } from '../../../tests/helpers/auth-session.js'; import { - createInMemoryTokenStore, - createMockHttpClient, -} from '../../../tests/helpers/auth.js'; -import { createAuthService } from './auth.service.js'; - -describe('services/auth-service', () => { - describe('isAuthenticated', () => { - it('returns true when authenticated', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'valid-token', - refreshToken: 'refresh-token', - }); - let httpClient = createMockHttpClient({ - '/api/auth/cli/whoami': { user: { email: 'test@example.com' } }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.isAuthenticated(); - - assert.strictEqual(result, true); + createAuthService, + createUserSession, + refresh, +} from './auth.service.js'; +import { createAuthClient } from './auth-client.js'; +import { createApiClient } from './cloud-client.js'; +import { createTokenStore } from './token-store.js'; + +async function fixture(t, handle) { + let home = await mkdtemp(join(tmpdir(), 'vizzly-auth-session-')); + let requests = []; + let server = createServer(async (request, response) => { + let chunks = []; + for await (let chunk of request) chunks.push(chunk); + let body = chunks.length + ? JSON.parse(Buffer.concat(chunks).toString()) + : null; + requests.push({ + path: request.url, + method: request.method, + authorization: request.headers.authorization, + body, }); - - it('returns false when no tokens exist', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({}); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.isAuthenticated(); - - assert.strictEqual(result, false); - }); - - it('returns false when API call fails', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'expired-token', - refreshToken: 'refresh-token', - }); - let httpClient = createMockHttpClient({ - '/api/auth/cli/whoami': new Error('Token expired'), - }); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.isAuthenticated(); - - assert.strictEqual(result, false); + let result = await handle(requests.at(-1), requests); + if (result.dropConnection) { + response.destroy(); + return; + } + response.writeHead(result.status || 200, { + 'Content-Type': 'application/json', }); + response.end(JSON.stringify(result.body)); }); - - describe('whoami', () => { - it('returns user data when authenticated', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'valid-token', - refreshToken: 'refresh-token', - }); - let httpClient = createMockHttpClient({ - '/api/auth/cli/whoami': { - user: { email: 'user@example.com', name: 'Test User' }, - organizations: [{ slug: 'test-org' }], - }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.whoami(); - - assert.strictEqual(result.user.email, 'user@example.com'); - assert.strictEqual(result.organizations[0].slug, 'test-org'); - }); - - it('throws when no access token exists', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({}); - let service = createAuthService({ httpClient, tokenStore }); - - await assert.rejects(() => service.whoami(), /authentication|login/i); - }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + let apiUrl = `http://127.0.0.1:${server.address().port}`; + let tokenStore = createTokenStore({ apiUrl, home }); + let service = createAuthService({ apiUrl, tokenStore }); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await rm(home, { recursive: true, force: true }); }); - - describe('initiateDeviceFlow', () => { - it('returns device flow data', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({ - '/api/auth/cli/device/initiate': { - device_code: 'device-123', - user_code: 'ABCD-1234', - verification_uri: 'https://auth.example.com/device', - expires_in: 600, - interval: 5, - }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.initiateDeviceFlow(); - - assert.strictEqual(result.device_code, 'device-123'); - assert.strictEqual(result.user_code, 'ABCD-1234'); - }); - - it('throws on API error', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({ - '/api/auth/cli/device/initiate': new Error('Service unavailable'), - }); - let service = createAuthService({ httpClient, tokenStore }); - - await assert.rejects( - () => service.initiateDeviceFlow(), - /Service unavailable/ - ); + let auth = { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: '2999-01-01T00:00:00.000Z', + user: { email: 'person@example.test' }, + authorization: fixtureAuthorization, + }; + return { home, apiUrl, tokenStore, service, requests, auth }; +} +function renewed() { + return { + accessToken: 'new-access', + refreshToken: 'new-refresh', + expiresIn: 900, + authorization: fixtureAuthorization, + }; +} + +for (let trigger of ['expiry', 'unauthorized']) { + test(`user requests renew on ${trigger} and keep the consented scope metadata`, async t => { + let f = await fixture(t, request => + request.path.endsWith('/refresh') + ? { body: renewed() } + : request.authorization === 'Bearer new-access' + ? { + body: { + user: { email: 'person@example.test' }, + authorization: fixtureAuthorization, + }, + } + : { status: 401, body: { error: 'expired' } } + ); + await f.tokenStore.saveTokens({ + ...f.auth, + ...(trigger === 'expiry' + ? { expiresAt: '2000-01-01T00:00:00.000Z' } + : {}), }); + assert.equal(await f.service.hasSavedSession(), true); + let identity = await f.service.whoami(); + assert.equal(identity.user.email, 'person@example.test'); + assert.equal( + f.requests.filter(request => request.path.endsWith('/refresh')).length, + 1 + ); + let saved = await f.tokenStore.getTokens(); + assert.equal(saved.accessToken, 'new-access'); + assert.deepEqual(saved.authorization, fixtureAuthorization); + assert.deepEqual(saved.user, f.auth.user); }); +} + +test('authenticated reads share refresh and write requests are never replayed', async t => { + let f = await fixture(t, request => + request.path.endsWith('/refresh') + ? { body: renewed() } + : request.path === '/read' && + request.authorization === 'Bearer new-access' + ? { body: { success: true } } + : { status: 401, body: { error: 'expired' } } + ); + await f.tokenStore.saveTokens(f.auth); + assert.deepEqual(await f.service.authenticatedRequest('/read'), { + success: true, + }); + await assert.rejects(() => + f.service.authenticatedRequest('/write', { method: 'POST', body: '{}' }) + ); + assert.equal( + f.requests.filter(request => request.path === '/write').length, + 1 + ); + assert.equal( + f.requests.filter(request => request.path.endsWith('/refresh')).length, + 1 + ); +}); - describe('pollDeviceAuthorization', () => { - it('returns pending status', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({ - '/api/auth/cli/device/poll': { status: 'pending' }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.pollDeviceAuthorization('device-123'); - - assert.strictEqual(result.status, 'pending'); - }); - - it('returns tokens when authorized', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({ - '/api/auth/cli/device/poll': { - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - expiresIn: 3600, +test('a long-lived client renews across expiry windows without retrying repeated unauthorized responses', async t => { + let now = Date.UTC(2026, 10, 3); + t.mock.timers.enable({ apis: ['Date'], now }); + let rotations = 0; + let f = await fixture(t, request => { + if (request.path.endsWith('/refresh')) { + rotations++; + return { + body: { + ...renewed(), + accessToken: `access-${rotations}`, + refreshToken: `refresh-${rotations}`, }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - let result = await service.pollDeviceAuthorization('device-123'); - - assert.strictEqual(result.accessToken, 'new-access-token'); - }); + }; + } + return request.path === '/denied' + ? { status: 401, body: { error: 'unauthorized' } } + : { body: { success: true } }; }); - - describe('completeDeviceFlow', () => { - it('saves tokens to store', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({}); - let service = createAuthService({ httpClient, tokenStore }); - - await service.completeDeviceFlow({ - accessToken: 'new-access', - refreshToken: 'new-refresh', - expiresIn: 3600, - }); - - let stored = tokenStore._getState(); - assert.strictEqual(stored.accessToken, 'new-access'); - assert.strictEqual(stored.refreshToken, 'new-refresh'); - }); + await f.tokenStore.saveTokens({ + ...f.auth, + expiresAt: '2000-01-01T00:00:00.000Z', }); + let session = createUserSession({ + baseUrl: f.apiUrl, + tokenStore: f.tokenStore, + }); + let client = createApiClient({ + baseUrl: f.apiUrl, + token: f.auth.accessToken, + credential: { session }, + }); + assert.deepEqual(await client.request('/read'), { success: true }); + t.mock.timers.setTime(now + 16 * 60 * 1000); + assert.deepEqual(await client.request('/read'), { success: true }); + assert.equal(rotations, 2); + assert.deepEqual( + f.requests + .filter(request => request.path === '/read') + .map(request => request.authorization), + ['Bearer access-1', 'Bearer access-2'] + ); + assert.deepEqual( + (await f.tokenStore.getTokens()).authorization, + fixtureAuthorization + ); + await assert.rejects(() => client.request('/denied'), { + code: 'LOGIN_REQUIRED', + }); + assert.equal(rotations, 2); + assert.equal( + f.requests.filter(request => request.path === '/denied').length, + 1 + ); +}); - describe('logout', () => { - it('clears tokens from store', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'access', - refreshToken: 'refresh', - }); - let httpClient = createMockHttpClient({ - '/api/auth/cli/logout': { success: true }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - await service.logout(); - - assert.strictEqual(tokenStore._getState(), null); +for (let status of [401, 500, 429]) { + test(`refresh HTTP ${status} preserves saved state and reports the right recovery`, async t => { + let f = await fixture(t, () => ({ + status, + body: { error: 'refresh failed' }, + })); + let expired = { ...f.auth, expiresAt: '2000-01-01T00:00:00.000Z' }; + await f.tokenStore.saveTokens(expired); + await assert.rejects(() => f.service.whoami(), { + code: { + 401: 'LOGIN_REQUIRED', + 500: 'AUTH_REFRESH_UNCERTAIN', + 429: 'AUTH_REFRESH_UNAVAILABLE', + }[status], }); - - it('clears tokens even when server revocation fails', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'access', - refreshToken: 'refresh', - }); - let httpClient = createMockHttpClient({ - '/api/auth/cli/logout': new Error('Server error'), - }); - let service = createAuthService({ httpClient, tokenStore }); - - // Should not throw - logs warning but still clears tokens - await service.logout(); - - assert.strictEqual(tokenStore._getState(), null); + assert.deepEqual(await f.tokenStore.getTokens(), { + ...expired, + ...(status === 500 ? { refreshUncertain: true } : {}), }); + assert.equal(f.requests.length, 1); + }); +} + +test('missing refresh authorization metadata is rejected without guessing permissions', async t => { + let f = await fixture(t, () => ({ + body: { accessToken: 'new', refreshToken: 'next', expiresIn: 900 }, + })); + let expired = { ...f.auth, expiresAt: '2000-01-01T00:00:00.000Z' }; + await f.tokenStore.saveTokens(expired); + await assert.rejects(() => f.service.whoami(), { + code: 'AUTH_REFRESH_UNCERTAIN', }); + assert.deepEqual(await f.tokenStore.getTokens(), { + ...expired, + refreshUncertain: true, + }); +}); - describe('refresh', () => { - it('refreshes tokens and saves new ones', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'old-access', - refreshToken: 'old-refresh', - user: { email: 'user@example.com' }, - }); - let httpClient = createMockHttpClient({ - '/api/auth/cli/refresh': { - accessToken: 'new-access', - refreshToken: 'new-refresh', - expiresIn: 3600, +test('a dropped committed refresh response is never replayed by a fresh service', async t => { + let currentRefreshToken = 'old-refresh'; + let f = await fixture(t, request => { + assert.equal(request.path, '/api/auth/cli/refresh'); + if (request.body.refreshToken !== currentRefreshToken) { + return { + status: 401, + body: { + error: 'Refresh token was reused.', + code: 'REFRESH_TOKEN_REUSED', }, - }); - let service = createAuthService({ httpClient, tokenStore }); - - await service.refresh(); - - let stored = tokenStore._getState(); - assert.strictEqual(stored.accessToken, 'new-access'); - assert.strictEqual(stored.refreshToken, 'new-refresh'); - // Should preserve user data - assert.strictEqual(stored.user.email, 'user@example.com'); - }); - - it('throws when no refresh token exists', async () => { - let tokenStore = createInMemoryTokenStore({ accessToken: 'access' }); - let httpClient = createMockHttpClient({}); - let service = createAuthService({ httpClient, tokenStore }); + }; + } + currentRefreshToken = 'new-refresh'; + return { dropConnection: true }; + }); + let expired = { ...f.auth, expiresAt: '2000-01-01T00:00:00.000Z' }; + await f.tokenStore.saveTokens(expired); - await assert.rejects(() => service.refresh(), /refresh token/i); - }); + await assert.rejects(() => f.service.whoami(), { + code: 'AUTH_REFRESH_UNCERTAIN', }); + assert.equal(currentRefreshToken, 'new-refresh'); + assert.equal((await f.tokenStore.getTokens()).refreshUncertain, true); - describe('authenticatedRequest', () => { - it('makes request with access token', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'valid-token', - refreshToken: 'refresh', - }); - let httpClient = createMockHttpClient({ - '/api/custom/endpoint': { data: 'result' }, - }); - let service = createAuthService({ httpClient, tokenStore }); + let freshStore = createTokenStore({ apiUrl: f.apiUrl, home: f.home }); + let freshService = createAuthService({ + apiUrl: f.apiUrl, + tokenStore: freshStore, + }); + await assert.rejects(() => freshService.whoami(), { + code: 'AUTH_REFRESH_UNCERTAIN', + }); + assert.equal(f.requests.length, 1); + await assert.rejects( + () => freshService.whoami(), + error => { + assert.equal(error.code, 'AUTH_REFRESH_UNCERTAIN'); + assert.match(error.message, /Run vizzly login/); + return true; + } + ); +}); - let result = await service.authenticatedRequest('/api/custom/endpoint'); +test('a rate-limited refresh clears uncertainty and can be retried', async t => { + let attempts = 0; + let f = await fixture(t, request => { + assert.equal(request.path, '/api/auth/cli/refresh'); + assert.equal(request.body.refreshToken, 'old-refresh'); + attempts++; + return attempts === 1 + ? { + status: 429, + body: { + error: 'Too many authentication attempts.', + retryAfter: 1, + }, + } + : { body: renewed() }; + }); + await f.tokenStore.saveTokens(f.auth); + let client = createAuthClient({ baseUrl: f.apiUrl }); - assert.strictEqual(result.data, 'result'); - // Verify the token was passed - let lastCall = httpClient._getLastCall(); - assert.strictEqual(lastCall.accessToken, 'valid-token'); - }); + await assert.rejects(() => refresh(client, f.tokenStore), { + code: 'RATE_LIMIT_ERROR', + }); + assert.equal((await f.tokenStore.getTokens()).refreshUncertain, undefined); + await refresh(client, f.tokenStore); - it('throws when not authenticated', async () => { - let tokenStore = createInMemoryTokenStore(null); - let httpClient = createMockHttpClient({}); - let service = createAuthService({ httpClient, tokenStore }); + assert.equal(attempts, 2); + assert.equal((await f.tokenStore.getTokens()).refreshToken, 'new-refresh'); + assert.equal((await f.tokenStore.getTokens()).refreshUncertain, undefined); +}); - await assert.rejects( - () => service.authenticatedRequest('/api/endpoint'), - /Not authenticated/ - ); - }); +test('unscoped legacy records require fresh browser consent before any request', async t => { + let f = await fixture(t, () => { + assert.fail('legacy session sent a request'); + }); + await f.tokenStore.saveTokens({ + accessToken: 'legacy', + refreshToken: 'legacy-refresh', + }); + assert.equal(await f.service.hasSavedSession(), true); + await assert.rejects(() => f.service.whoami(), { code: 'LOGIN_REQUIRED' }); + assert.equal(f.requests.length, 0); +}); - it('passes custom options to request', async () => { - let tokenStore = createInMemoryTokenStore({ - accessToken: 'token', - refreshToken: 'refresh', - }); - let httpClient = createMockHttpClient({ - '/api/endpoint': { success: true }, - }); - let service = createAuthService({ httpClient, tokenStore }); +test('logout revokes with the refresh credential and clears the selected origin even after failure', async t => { + let f = await fixture(t, () => ({ status: 500, body: { error: 'offline' } })); + await f.tokenStore.saveTokens(f.auth); + await f.service.logout(); + assert.equal(await f.tokenStore.getTokens(), null); + assert.equal(f.requests[0].authorization, undefined); + assert.deepEqual(f.requests[0].body, { refreshToken: f.auth.refreshToken }); +}); - await service.authenticatedRequest('/api/endpoint', { - method: 'POST', - body: JSON.stringify({ key: 'value' }), - }); +test('device login sends the requested scope and stores the server authorization', async t => { + let f = await fixture(t, () => ({ body: { device_code: 'device' } })); + await f.service.initiateDeviceFlow({ scope: 'read' }); + assert.deepEqual(f.requests[0].body, { + client_id: 'vizzly-cli', + scope: 'read', + }); + await f.service.completeDeviceFlow({ ...renewed(), user: f.auth.user }); + assert.deepEqual( + (await f.tokenStore.getTokens()).authorization, + fixtureAuthorization + ); +}); - let lastCall = httpClient._getLastCall(); - assert.strictEqual(lastCall.options.method, 'POST'); - assert.ok(lastCall.options.body.includes('key')); - }); +test('independent CLI processes rotate once and reread the same successor', async t => { + let f = await fixture(t, request => { + assert.equal(request.path, '/api/auth/cli/refresh'); + assert.equal(request.body.refreshToken, 'old-refresh'); + return { body: renewed() }; + }); + await f.tokenStore.saveTokens(f.auth); + let script = join(f.home, 'refresh.js'); + let serviceUrl = new URL('./auth.service.js', import.meta.url).href; + let clientUrl = new URL('./auth-client.js', import.meta.url).href; + let storeUrl = new URL('./token-store.js', import.meta.url).href; + await writeFile( + script, + `import { once } from 'node:events';\nimport { refresh } from ${JSON.stringify(serviceUrl)};\nimport { createAuthClient } from ${JSON.stringify(clientUrl)};\nimport { createTokenStore } from ${JSON.stringify(storeUrl)};\nlet [apiUrl,home]=process.argv.slice(2); let store=createTokenStore({apiUrl,home}); let observed=await store.getTokens(); let go=once(process,'message'); process.send('ready'); await go; await refresh(createAuthClient({baseUrl:apiUrl}),store,{expectedAuth:observed}); process.send('done'); process.disconnect();` + ); + let children = [0, 1].map(() => + fork(script, [f.apiUrl, f.home], { execArgv: [], silent: true }) + ); + t.after(() => { + for (let child of children) child.kill(); }); + await Promise.all( + children.map(async child => { + assert.equal((await once(child, 'message'))[0], 'ready'); + }) + ); + let done = children.map(child => once(child, 'message')); + let exits = children.map(child => once(child, 'exit')); + for (let child of children) child.send('go'); + assert.deepEqual( + (await Promise.all(done)).map(([message]) => message), + ['done', 'done'] + ); + assert.deepEqual( + (await Promise.all(exits)).map(([code]) => code), + [0, 0] + ); + assert.equal(f.requests.length, 1); + assert.equal((await f.tokenStore.getTokens()).refreshToken, 'new-refresh'); }); diff --git a/src/domains/auth/auth.validations.js b/src/domains/auth/auth.validations.js index 1c4f7869..aa51ca8e 100644 --- a/src/domains/auth/auth.validations.js +++ b/src/domains/auth/auth.validations.js @@ -1,4 +1,4 @@ -import { AuthError } from '../../shared/vizzly-error.js'; +import { AuthError, VizzlyError } from '../../shared/vizzly-error.js'; /** * Validate that tokens exist and have required fields @@ -18,3 +18,20 @@ export function validateTokens(auth, requiredField = 'accessToken') { return { valid: true, error: null }; } + +let cliScopes = ['read', 'builds:write', 'reviews:write', 'comments:write']; + +export function parseLoginScopes(value) { + if (value === undefined) return undefined; + let scopes = + typeof value === 'string' + ? [...new Set(value.split(/[\s,]+/).filter(Boolean))] + : []; + if (!scopes.length || scopes.some(scope => !cliScopes.includes(scope))) { + throw new VizzlyError( + `--scopes must contain one or more of: ${cliScopes.join(', ')}.`, + 'INVALID_SCOPE' + ); + } + return scopes.join(' '); +} diff --git a/src/domains/auth/cloud-client.js b/src/domains/auth/cloud-client.js index 4e89446f..972aa434 100644 --- a/src/domains/auth/cloud-client.js +++ b/src/domains/auth/cloud-client.js @@ -14,6 +14,7 @@ import { } from '../../shared/cloud-protocol.js'; import { getPackageVersion } from '../../shared/package-info.js'; import { AuthError, VizzlyError } from '../../shared/vizzly-error.js'; +import { createInsufficientScopeError } from './auth-errors.js'; /** * Default API URL @@ -118,7 +119,21 @@ export function createApiClient(options = {}) { ); } - // Other errors + // Permission denials keep the selected identity and never trigger refresh. + if (response.status === 403) { + let body; + try { + body = JSON.parse(errorBody); + } catch { + // Preserve ordinary non-JSON errors through the shared parser. + } + if ((body?.code || body?.details?.code) === 'INSUFFICIENT_SCOPE') { + throw createInsufficientScopeError({ + requiredScopes: body.requiredScopes || body.details?.requiredScopes, + grantedScopes: body.grantedScopes || body.details?.grantedScopes, + }); + } + } let error = parseApiError(response.status, errorBody, url); throw new VizzlyError(error.message, error.code, { status: error.status, diff --git a/src/domains/auth/cloud-client.test.js b/src/domains/auth/cloud-client.test.js index 7c627bb1..60fbee53 100644 --- a/src/domains/auth/cloud-client.test.js +++ b/src/domains/auth/cloud-client.test.js @@ -5,6 +5,10 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, before, describe, it } from 'node:test'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../../tests/helpers/auth-session.js'; import { createApiClient } from './cloud-client.js'; import { resolveCredential } from './credential.service.js'; import { getAuthTokens, saveAuthTokens } from './token-store.js'; @@ -47,12 +51,7 @@ async function readJson(request) { describe('saved-token API refresh', () => { it('refreshes a saved user token once and retries the request with rotated credentials', async () => { let requests = []; - await saveAuthTokens({ - accessToken: 'expired-access', - refreshToken: 'saved-refresh', - expiresAt: '2999-01-01T00:00:00.000Z', - user: { id: 'user-1' }, - }); + let selectedOrigin; await withApiServer( async (request, response) => { @@ -71,6 +70,7 @@ describe('saved-token API refresh', () => { accessToken: 'new-access', refreshToken: 'new-refresh', expiresIn: 3600, + authorization: fixtureAuthorization, }) ); return; @@ -85,6 +85,12 @@ describe('saved-token API refresh', () => { response.end(JSON.stringify({ projects: [] })); }, async apiUrl => { + selectedOrigin = apiUrl; + await saveFixtureAuth(home, apiUrl, { + accessToken: 'expired-access', + refreshToken: 'saved-refresh', + user: { id: 'user-1' }, + }); let client = createApiClient({ baseUrl: apiUrl, token: 'expired-access', @@ -126,7 +132,7 @@ describe('saved-token API refresh', () => { ); assert.deepEqual(requests[1].body, { refreshToken: 'saved-refresh' }); - let saved = await getAuthTokens(); + let saved = await getAuthTokens(selectedOrigin); assert.equal(saved.accessToken, 'new-access'); assert.equal(saved.refreshToken, 'new-refresh'); assert.equal(saved.user.id, 'user-1'); @@ -217,10 +223,6 @@ describe('saved-token API refresh', () => { }); it('retries a saved session only once and honors requests that disable authentication retries', async () => { - await saveAuthTokens({ - accessToken: 'old-access', - refreshToken: 'old-refresh', - }); let requests = []; let refreshes = 0; await withApiServer( @@ -236,6 +238,7 @@ describe('saved-token API refresh', () => { accessToken: 'new-access', refreshToken: 'new-refresh', expiresIn: 3600, + authorization: fixtureAuthorization, }) ); return; @@ -244,6 +247,10 @@ describe('saved-token API refresh', () => { response.end(JSON.stringify({ error: 'unauthorized' })); }, async apiUrl => { + await saveFixtureAuth(home, apiUrl, { + accessToken: 'old-access', + refreshToken: 'old-refresh', + }); let client = createApiClient({ baseUrl: apiUrl, token: 'old-access', @@ -257,7 +264,7 @@ describe('saved-token API refresh', () => { { url: '/api/auth/cli/refresh', authorization: undefined }, { url: '/api/sdk/projects', authorization: 'Bearer new-access' }, ]); - assert.equal((await getAuthTokens()).accessToken, 'new-access'); + assert.equal((await getAuthTokens(apiUrl)).accessToken, 'new-access'); requests.length = 0; await assert.rejects( diff --git a/src/domains/auth/credential-error.serializers.js b/src/domains/auth/credential-error.serializers.js new file mode 100644 index 00000000..80f3ffae --- /dev/null +++ b/src/domains/auth/credential-error.serializers.js @@ -0,0 +1,12 @@ +/** Permission metadata is safe to include alongside a command's JSON error. */ +export function scopeErrorDetails(error) { + if (error.code !== 'INSUFFICIENT_SCOPE') return {}; + let names = value => + Array.isArray(value) + ? value.filter(scope => typeof scope === 'string') + : []; + return { + requiredScopes: names(error.context?.requiredScopes), + grantedScopes: names(error.context?.grantedScopes), + }; +} diff --git a/src/domains/auth/credential-presentation.js b/src/domains/auth/credential-presentation.js index a09e7747..cdb36eae 100644 --- a/src/domains/auth/credential-presentation.js +++ b/src/domains/auth/credential-presentation.js @@ -11,10 +11,24 @@ export function credentialSourceLabel(source) { return sources[source] || source; } -export function showCredential(output, credential, command) { - output.hint(`Using ${credentialSourceLabel(credential.source)}`); +export function showCredential(output, credential) { + output.labelValue('Credential', credentialSourceLabel(credential.source)); + if (credential.authorization && output.isVerbose()) { + output.hint( + `Permissions: ${credential.authorization.scopes.join(', ') || 'None'}` + ); + } if (credential.kind === 'project') { output.hint('This project token can access only its project.'); - output.hint(`Browse your account: vizzly ${command} --auth user`); + if (credential.source === 'environment') { + output.hint('VIZZLY_TOKEN takes precedence over your saved login.'); + output.hint('Unset VIZZLY_TOKEN to use your saved login.'); + } else if (credential.source === 'flag') { + output.hint('Omit --token to use your saved login.'); + } else if (credential.source === 'config') { + output.hint('Remove the configured token to use your saved login.'); + } else { + output.hint('Run vizzly login to browse your account.'); + } } } diff --git a/src/domains/auth/credential.rules.js b/src/domains/auth/credential.rules.js index 782875bd..da6c79c2 100644 --- a/src/domains/auth/credential.rules.js +++ b/src/domains/auth/credential.rules.js @@ -1,4 +1,6 @@ import { VizzlyError } from '../../shared/vizzly-error.js'; +import { normalizeAuthorization } from './auth.normalizers.js'; +import { createInsufficientScopeError } from './auth-errors.js'; export function selectCredential(config, options = {}) { let mode = options.auth || 'auto'; @@ -20,6 +22,14 @@ export function selectCredential(config, options = {}) { let source = config.credentialSource || (config.linkedProject ? 'linked-project' : 'config'); + let explicitToken = + config.apiKey && ['flag', 'environment', 'config'].includes(source); + if ( + config.userAuthError && + (mode === 'user' || (mode === 'auto' && !explicitToken)) + ) { + throw config.userAuthError; + } let useUser = mode === 'user' || (mode === 'auto' && @@ -38,9 +48,28 @@ export function selectCredential(config, options = {}) { token: token || null, source: token ? (useUser ? 'user' : source) : 'anonymous', kind: useUser ? 'user' : token?.startsWith('vzt_') ? 'project' : 'token', + ...(useUser && config.userAuthorization + ? { authorization: normalizeAuthorization(config.userAuthorization) } + : {}), }; } export function credentialInfo(credential) { - return { source: credential.source, kind: credential.kind }; + return { + source: credential.source, + kind: credential.kind, + ...(credential.authorization + ? { authorization: normalizeAuthorization(credential.authorization) } + : {}), + }; +} + +/** Explicit token overrides are authorized by the API, never by inferred scopes. */ +export function requireCredentialScope(credential, scope) { + if (credential.kind !== 'user' || !credential.authorization) return; + if (credential.authorization.scopes.includes(scope)) return; + throw createInsufficientScopeError({ + requiredScopes: [scope], + grantedScopes: credential.authorization.scopes, + }); } diff --git a/src/domains/auth/credential.rules.test.js b/src/domains/auth/credential.rules.test.js index 3c69dd76..cc8fddf9 100644 --- a/src/domains/auth/credential.rules.test.js +++ b/src/domains/auth/credential.rules.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { credentialInfo, selectCredential } from './credential.rules.js'; +import { + credentialInfo, + requireCredentialScope, + selectCredential, +} from './credential.rules.js'; test('credential selection honors deliberate sources and keeps account reads separate from uploads', () => { let linked = { @@ -39,3 +43,92 @@ test('credential selection honors deliberate sources and keeps account reads sep 'anonymous' ); }); + +test('known user grants restrict actions without inferring permissions for token overrides', () => { + let user = selectCredential({ + userToken: 'user-access', + userAuthorization: { + sessionId: 'session-1', + clientId: 'vizzly-cli', + scopes: ['read'], + }, + }); + assert.doesNotThrow(() => requireCredentialScope(user, 'read')); + assert.throws( + () => requireCredentialScope(user, 'builds:write'), + error => { + assert.equal(error.code, 'INSUFFICIENT_SCOPE'); + assert.deepEqual(error.context.requiredScopes, ['builds:write']); + assert.deepEqual(error.context.grantedScopes, ['read']); + return true; + } + ); + assert.deepEqual(credentialInfo(user), { + source: 'user', + kind: 'user', + authorization: { + sessionId: 'session-1', + clientId: 'vizzly-cli', + scopes: ['read'], + }, + }); + assert.doesNotThrow(() => + requireCredentialScope( + selectCredential({ apiKey: 'external-token' }), + 'builds:write' + ) + ); + assert.doesNotThrow(() => + requireCredentialScope( + selectCredential({ userToken: 'legacy-access' }), + 'builds:write' + ) + ); +}); + +test('an unavailable user store blocks only commands that select that login', () => { + let storageError = Object.assign(new Error('Unlock the credential store.'), { + code: 'AUTH_STORAGE_UNAVAILABLE', + }); + for (let source of ['flag', 'environment', 'config']) { + let config = { + apiKey: 'vzt_explicit', + credentialSource: source, + userAuthError: storageError, + }; + assert.equal(selectCredential(config).token, 'vzt_explicit'); + assert.equal( + selectCredential(config, { auth: 'token' }).token, + 'vzt_explicit' + ); + if (source === 'flag') { + assert.throws(() => selectCredential(config, { auth: 'user' }), { + code: 'INVALID_AUTH', + }); + } else { + assert.throws( + () => selectCredential(config, { auth: 'user' }), + error => error === storageError + ); + } + } + let linked = { + apiKey: 'vzt_cached', + credentialSource: 'linked-project', + userAuthError: storageError, + }; + assert.throws( + () => selectCredential(linked), + error => error === storageError + ); + assert.throws( + () => selectCredential(linked, { auth: 'user' }), + error => error === storageError + ); + assert.equal(selectCredential(linked, { auth: 'token' }).token, 'vzt_cached'); + assert.throws( + () => + selectCredential({ userAuthError: storageError }, { allowNoToken: true }), + error => error === storageError + ); +}); diff --git a/src/domains/auth/login.commands.js b/src/domains/auth/login.commands.js index 1394de92..446a56e2 100644 --- a/src/domains/auth/login.commands.js +++ b/src/domains/auth/login.commands.js @@ -6,12 +6,16 @@ import { buildTokens, normalizeDeviceFlowResponse, } from './auth.normalizers.js'; -import { resolveAuthorizedTokenData } from './auth.rules.js'; +import { + requireScopedAuthorization, + resolveAuthorizedTokenData, +} from './auth.rules.js'; import { completeDeviceFlow, initiateDeviceFlow, pollDeviceAuthorization, } from './auth.service.js'; +import { parseLoginScopes } from './auth.validations.js'; import { createAuthClient } from './auth-client.js'; import { waitForLoginPoll } from './login-wait.js'; import { createTokenStore } from './token-store.js'; @@ -49,14 +53,14 @@ export async function loginCommand( output.header('login'); // Create auth client and token store - let client = createAuthClient({ - baseUrl: options.apiUrl || getApiUrl(), - }); - let tokenStore = createTokenStore(); + let apiUrl = options.apiUrl || getApiUrl(); + let scope = parseLoginScopes(options.scopes); + let client = createAuthClient({ baseUrl: apiUrl }); + let tokenStore = createTokenStore({ apiUrl }); // Initiate device flow output.startSpinner('Connecting to Vizzly...'); - let deviceFlow = await initiateDeviceFlow(client); + let deviceFlow = await initiateDeviceFlow(client, { scope }); output.stopSpinner(); let expiresIn = Number(deviceFlow.expires_in ?? deviceFlow.expiresIn); let lifetime = @@ -92,12 +96,14 @@ export async function loginCommand( output.blank(); // Try to open browser with pre-filled code - let browserOpened = await openBrowser(urlWithCode); - if (browserOpened) { - output.complete('Browser opened'); - } else { - output.warn('Could not open browser automatically'); - output.hint('Please open the URL manually'); + if (options.browser !== false) { + let browserOpened = await openBrowser(urlWithCode); + if (browserOpened) { + output.complete('Browser opened'); + } else { + output.warn('Could not open browser automatically'); + output.hint('Please open the URL manually'); + } } output.blank(); @@ -163,10 +169,25 @@ export async function loginCommand( // Complete device flow and save tokens // Handle both snake_case and camelCase for token data, and nested tokens object let tokens = buildTokens(tokenData, now()); + requireScopedAuthorization(tokens.authorization); await completeDeviceFlow(tokenStore, tokens); // Display success + if (globalOptions.json) { + output.data({ + authenticated: true, + apiUrl: new URL(apiUrl).origin, + credential: { source: 'user', kind: 'user' }, + authorization: tokens.authorization, + user: tokens.user, + organizations: tokens.organizations || [], + }); + } output.complete('Authenticated'); + output.keyValue({ + API: new URL(apiUrl).origin, + Permissions: tokens.authorization.scopes.join(', ') || 'None', + }); output.blank(); // Show user info diff --git a/src/domains/auth/logout.commands.js b/src/domains/auth/logout.commands.js index 618218fa..283a5e43 100644 --- a/src/domains/auth/logout.commands.js +++ b/src/domains/auth/logout.commands.js @@ -41,7 +41,8 @@ export async function logoutCommand( try { // Check if user is logged in - let auth = await getAuthTokens(); + let apiUrl = options.apiUrl || getApiUrl(); + let auth = await getAuthTokens(apiUrl); if (!auth?.accessToken) { if (globalOptions.json) { @@ -58,9 +59,9 @@ export async function logoutCommand( output.startSpinner('Logging out...'); let client = createAuthClient({ - baseUrl: options.apiUrl || getApiUrl(), + baseUrl: apiUrl, }); - let tokenStore = createTokenStore(); + let tokenStore = createTokenStore({ apiUrl }); await logout(client, tokenStore); diff --git a/src/domains/auth/token-store.js b/src/domains/auth/token-store.js index bef28bce..d217f8a7 100644 --- a/src/domains/auth/token-store.js +++ b/src/domains/auth/token-store.js @@ -1,91 +1,233 @@ +import { createHash, randomUUID } from 'node:crypto'; import { - loadGlobalConfig, - saveGlobalConfig, -} from '../../shared/global-config.js'; - -/** - * Get authentication tokens from global config - * @returns {Promise} Token object with accessToken, refreshToken, expiresAt, user, or null if not found - */ -export async function getAuthTokens() { - let config = await loadGlobalConfig(); - - if (!config.auth?.accessToken) { - return null; + chmod, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { getGlobalConfigDir } from '../../shared/global-config.js'; +import { withLocalLock } from '../../shared/local-lock.js'; +import * as output from '../../shared/output.js'; +import { + canUseKeychain, + deleteSecret, + getSecret, + saveSecret, +} from '../../shared/secret-store.js'; +import { VizzlyError } from '../../shared/vizzly-error.js'; +import { getApiUrl } from '../config/environment.js'; + +export function authOrigin(apiUrl = getApiUrl()) { + let url = new URL(apiUrl); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password + ) { + throw new VizzlyError( + 'Use an HTTP or HTTPS API origin without credentials.', + 'INVALID_API_URL' + ); } + return url.origin; +} - return config.auth; +export function getAuthStoragePath( + apiUrl = getApiUrl(), + home = getGlobalConfigDir() +) { + let key = createHash('sha256').update(authOrigin(apiUrl)).digest('hex'); + return join(resolve(home), 'auth', `${key}.json`); } -/** - * Save authentication tokens to global config - * @param {Object} auth - Auth object with accessToken, refreshToken, expiresAt, user - * @returns {Promise} - */ -export async function saveAuthTokens(auth) { - let config = await loadGlobalConfig(); - - config.auth = { - accessToken: auth.accessToken, - refreshToken: auth.refreshToken, - expiresAt: auth.expiresAt, - user: auth.user, - }; +function storageError() { + return new VizzlyError( + 'Unable to read your saved login. Unlock the system credential store or run vizzly login to save a new session.', + 'AUTH_STORAGE_UNAVAILABLE' + ); +} - await saveGlobalConfig(config); +function tokenData(auth) { + return Object.fromEntries( + ['accessToken', 'refreshToken', 'expiresAt', 'user', 'authorization'] + .filter(key => auth[key] !== undefined) + .map(key => [key, auth[key]]) + ); } -/** - * Clear authentication tokens from global config - * @returns {Promise} - */ -export async function clearAuthTokens() { - let config = await loadGlobalConfig(); - delete config.auth; - await saveGlobalConfig(config); +async function readRecord(filename, apiUrl) { + let record; + try { + record = JSON.parse(await readFile(filename, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw storageError(); + } + if (record.apiUrl !== apiUrl) throw storageError(); + return record; } -function hasValidAccessToken(auth, now = Date.now()) { - if (!auth?.accessToken) { - return false; +async function writeRecord(filename, record) { + let directory = dirname(filename); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + let temporary = `${filename}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify(record, null, 2), { + flag: 'wx', + mode: 0o600, + }); + await rename(temporary, filename); + } finally { + await rm(temporary, { force: true }); + } +} + +export function createTokenStore(options = {}) { + let apiUrl = authOrigin(options.apiUrl); + let home = resolve(options.home || getGlobalConfigDir()); + let filename = getAuthStoragePath(apiUrl, home); + let env = options.env || process.env; + let platform = options.platform || process.platform; + let storage = + options.storage || + env.VIZZLY_AUTH_STORAGE || + (env.VIZZLY_HOME || options.home ? 'file' : 'auto'); + if (!['auto', 'file', 'keychain'].includes(storage)) { + throw new VizzlyError( + 'VIZZLY_AUTH_STORAGE must be file, keychain, or auto.', + 'INVALID_AUTH_STORAGE' + ); + } + let secretOptions = { + service: 'vizzly-cli-user-session', + platform, + env, + ...(options.execFileFn ? { execFileFn: options.execFileFn } : {}), + }; + let account = createHash('sha256').update(`${home}|${apiUrl}`).digest('hex'); + let lockKey = `vizzly-auth|${process.getuid?.() ?? 'user'}|${home}|${apiUrl}`; + + async function getTokens() { + let record = await readRecord(filename, apiUrl); + if (!record) return null; + if (record.storage === 'keychain') { + let secret = await getSecret(account, secretOptions); + if (!secret) throw storageError(); + try { + let auth = JSON.parse(secret); + if (!auth.accessToken) throw storageError(); + return { + ...tokenData(auth), + ...(record.refreshUncertain ? { refreshUncertain: true } : {}), + }; + } catch { + throw storageError(); + } + } + if (record.storage !== 'file') throw storageError(); + return record.accessToken + ? { + ...tokenData(record), + ...(record.refreshUncertain ? { refreshUncertain: true } : {}), + } + : null; + } + + async function saveTokens(auth) { + let tokens = tokenData(auth); + let useKeychain = storage !== 'file' && canUseKeychain(platform, env); + let saved = + useKeychain && + (await saveSecret(account, JSON.stringify(tokens), secretOptions)); + if (storage === 'keychain' && !saved) throw storageError(); + if (storage === 'auto' && useKeychain && !saved) { + output.warn( + 'System credential storage is unavailable. Saving this login in an owner-only credential file.' + ); + } + let metadata = { + version: 1, + apiUrl, + storage: saved ? 'keychain' : 'file', + expiresAt: tokens.expiresAt, + user: tokens.user, + authorization: tokens.authorization, + }; + await writeRecord(filename, saved ? metadata : { ...metadata, ...tokens }); + } + + async function markRefreshUncertain() { + let record = await readRecord(filename, apiUrl); + if (!record || (record.storage === 'file' && !record.refreshToken)) { + throw storageError(); + } + if (record.refreshUncertain) return; + await writeRecord(filename, { ...record, refreshUncertain: true }); } - if (!auth.expiresAt) { - return true; + async function clearRefreshUncertain() { + let record = await readRecord(filename, apiUrl); + if (!record?.refreshUncertain) return; + let { refreshUncertain, ...rest } = record; + await writeRecord(filename, rest); } - let expiresAt = new Date(auth.expiresAt).getTime(); - // Treat tokens expiring within five minutes as unavailable. - let bufferMs = 5 * 60 * 1000; - return now < expiresAt - bufferMs; + async function clearTokens() { + await writeRecord(filename, { version: 1, apiUrl, storage: 'file' }); + if (storage !== 'file') await deleteSecret(account, secretOptions); + } + + async function withRefreshLock(operation, lockOptions) { + try { + return await withLocalLock(lockKey, operation, lockOptions); + } catch (error) { + if (error.code === 'LOCAL_LOCK_UNAVAILABLE') { + throw new VizzlyError( + 'Another CLI operation or local service is using the session lock. Retry this command after it finishes.', + 'AUTH_SESSION_BUSY' + ); + } + throw error; + } + } + return { + apiUrl, + getTokens, + saveTokens, + clearTokens, + markRefreshUncertain, + clearRefreshUncertain, + withRefreshLock, + }; } -/** - * Check if authentication tokens exist and are not expired - * @returns {Promise} True if valid tokens exist - */ -export async function hasValidTokens() { - let auth = await getAuthTokens(); - return hasValidAccessToken(auth); +export async function getAuthTokens(apiUrl) { + return await createTokenStore({ apiUrl }).getTokens(); } -/** - * Get the access token from global config if valid and not expired - * @returns {Promise} Access token or null - */ -export async function getAccessToken() { - let auth = await getAuthTokens(); - return hasValidAccessToken(auth) ? auth.accessToken : null; +export async function saveAuthTokens(auth, apiUrl) { + await createTokenStore({ apiUrl }).saveTokens(auth); } -/** - * Create a token store adapter from global-config functions - * Used by auth operations that need tokenStore parameter - */ -export function createTokenStore() { - return { - getTokens: getAuthTokens, - saveTokens: saveAuthTokens, - clearTokens: clearAuthTokens, - }; +export async function clearAuthTokens(apiUrl) { + await createTokenStore({ apiUrl }).clearTokens(); +} + +function hasValidAccessToken(auth, now = Date.now()) { + if (!auth?.accessToken || !auth.authorization) return false; + if (!auth.expiresAt) return true; + return now < Date.parse(auth.expiresAt) - 5 * 60 * 1000; +} + +export async function hasValidTokens(apiUrl) { + return hasValidAccessToken(await getAuthTokens(apiUrl)); +} + +export async function getAccessToken(apiUrl) { + let auth = await getAuthTokens(apiUrl); + return hasValidAccessToken(auth) ? auth.accessToken : null; } diff --git a/src/domains/auth/token-store.test.js b/src/domains/auth/token-store.test.js index c3c5ee19..41a7f524 100644 --- a/src/domains/auth/token-store.test.js +++ b/src/domains/auth/token-store.test.js @@ -1,91 +1,222 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, test } from 'node:test'; -import { - loadGlobalConfig, - saveGlobalConfig, -} from '../../shared/global-config.js'; -import { - clearAuthTokens, - getAccessToken, - getAuthTokens, - hasValidTokens, - saveAuthTokens, -} from './token-store.js'; +import { fixtureAuthorization } from '../../../tests/helpers/auth-session.js'; +import { createTokenStore, getAuthStoragePath } from './token-store.js'; -let originalHome = process.env.VIZZLY_HOME; let home; +let apiUrl = 'https://app.vizzly.dev'; +let auth = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: '2099-01-01T00:00:00.000Z', + user: { email: 'tester@example.com' }, + authorization: fixtureAuthorization, +}; beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'vizzly-token-store-')); - process.env.VIZZLY_HOME = home; + home = await mkdtemp(join(tmpdir(), 'vizzly-auth-store-')); }); afterEach(async () => { - if (originalHome === undefined) delete process.env.VIZZLY_HOME; - else process.env.VIZZLY_HOME = originalHome; await rm(home, { recursive: true, force: true }); }); -test('missing access credentials are unavailable even when a refresh token exists', async () => { - for (let config of [{}, { auth: { refreshToken: 'refresh' } }]) { - await saveGlobalConfig(config); - assert.equal(await getAuthTokens(), null); - assert.equal(await hasValidTokens(), false); - assert.equal(await getAccessToken(), null); - } +function store(options = {}) { + return createTokenStore({ apiUrl, home, ...options }); +} + +test('isolated homes save complete credentials in owner-only files without accessing Keychain', async () => { + await writeFile( + join(home, 'config.json'), + JSON.stringify({ preference: 'kept' }) + ); + let tokens = store({ + platform: 'darwin', + execFileFn: () => { + assert.fail('isolated home accessed Keychain'); + }, + }); + await tokens.saveTokens(auth); + assert.deepEqual(await tokens.getTokens(), auth); + let filename = getAuthStoragePath(apiUrl, home); + assert.equal((await stat(filename)).mode & 0o777, 0o600); + assert.equal((await stat(join(home, 'auth'))).mode & 0o777, 0o700); + assert.equal(JSON.parse(await readFile(filename, 'utf8')).storage, 'file'); + await tokens.clearTokens(); + assert.equal(await tokens.getTokens(), null); + assert.deepEqual( + JSON.parse(await readFile(join(home, 'config.json'), 'utf8')), + { preference: 'kept' } + ); }); -test('saving credentials preserves preferences and round-trips the token contract', async () => { - await saveGlobalConfig({ preference: 'kept' }); - let auth = { - accessToken: 'access', - refreshToken: 'refresh', - expiresAt: '2099-01-01T00:00:00.000Z', - user: { email: 'tester@example.com' }, - }; - await saveAuthTokens({ ...auth, ignored: 'not part of the token contract' }); - assert.deepEqual(await getAuthTokens(), auth); - assert.equal(await getAccessToken(), 'access'); - assert.deepEqual(await loadGlobalConfig(), { preference: 'kept', auth }); +test('sessions belong to a normalized API origin and one Vizzly home', async () => { + await store().saveTokens(auth); + assert.deepEqual(await store({ apiUrl: `${apiUrl}/api/` }).getTokens(), auth); + assert.equal( + await store({ apiUrl: 'https://staging.vizzly.dev' }).getTokens(), + null + ); + assert.equal(await store({ home: join(home, 'other') }).getTokens(), null); + await store({ apiUrl: 'https://staging.vizzly.dev' }).saveTokens({ + ...auth, + accessToken: 'staging', + }); + assert.equal((await store().getTokens()).accessToken, 'access'); + await store().clearTokens(); + assert.equal( + (await store({ apiUrl: 'https://staging.vizzly.dev' }).getTokens()) + .accessToken, + 'staging' + ); +}); + +test('old global credentials are preserved but never inferred to belong to an origin', async () => { + let legacy = { preference: 'kept', auth: { ...auth, apiUrl } }; + await writeFile(join(home, 'config.json'), JSON.stringify(legacy)); + assert.equal(await store().getTokens(), null); + assert.equal( + await store({ apiUrl: 'https://staging.vizzly.dev' }).getTokens(), + null + ); + assert.deepEqual( + JSON.parse(await readFile(join(home, 'config.json'), 'utf8')), + legacy + ); +}); + +test('atomic replacements never expose partial credential JSON', async () => { + let tokens = store(); + await tokens.saveTokens(auth); + await Promise.all( + Array.from({ length: 24 }, async (_, index) => { + await tokens.saveTokens({ ...auth, accessToken: `access-${index}` }); + let saved = await tokens.getTokens(); + assert.match(saved.accessToken, /^access-/); + assert.deepEqual(saved.authorization, fixtureAuthorization); + }) + ); + assert.equal( + (await stat(getAuthStoragePath(apiUrl, home))).mode & 0o777, + 0o600 + ); }); -test('clearing credentials preserves other settings and makes the token unavailable', async () => { - await saveGlobalConfig({ - preference: 'kept', - auth: { accessToken: 'access' }, +test('refresh uncertainty survives reload and a successful replacement clears it', async () => { + let tokens = store(); + await tokens.saveTokens(auth); + await tokens.markRefreshUncertain(); + + let reloaded = store(); + assert.deepEqual(await reloaded.getTokens(), { + ...auth, + refreshUncertain: true, + }); + assert.equal( + JSON.parse(await readFile(getAuthStoragePath(apiUrl, home), 'utf8')) + .refreshUncertain, + true + ); + + await reloaded.saveTokens({ + ...auth, + accessToken: 'new-access', + refreshToken: 'new-refresh', }); - await clearAuthTokens(); - assert.deepEqual(await loadGlobalConfig(), { preference: 'kept' }); - assert.equal(await getAuthTokens(), null); - assert.equal(await getAccessToken(), null); + assert.equal((await tokens.getTokens()).refreshUncertain, undefined); + assert.equal( + JSON.parse(await readFile(getAuthStoragePath(apiUrl, home), 'utf8')) + .refreshUncertain, + undefined + ); }); -test('saved tokens become unavailable exactly five minutes before expiry', async t => { - let now = Date.UTC(2026, 0, 1); - t.mock.timers.enable({ apis: ['Date'], now }); - for (let [remaining, valid] of [ - [-1, false], - [5 * 60 * 1000, false], - [5 * 60 * 1000 + 1, true], +test('corrupt or origin-mismatched credential files fail without another identity fallback', async () => { + await store().saveTokens(auth); + let filename = getAuthStoragePath(apiUrl, home); + for (let content of [ + '{broken', + JSON.stringify({ + apiUrl: 'https://other.vizzly.dev', + storage: 'file', + ...auth, + }), ]) { - await saveAuthTokens({ - accessToken: 'access', - expiresAt: new Date(now + remaining).toISOString(), + await writeFile(filename, content); + await assert.rejects(() => store().getTokens(), { + code: 'AUTH_STORAGE_UNAVAILABLE', }); - assert.equal(await hasValidTokens(), valid); - assert.equal(await getAccessToken(), valid ? 'access' : null); } }); -test('tokens without an expiry remain available', async () => { - await saveAuthTokens({ accessToken: 'access' }); - assert.equal(await hasValidTokens(), true); - assert.equal(await getAccessToken(), 'access'); +test('Keychain credentials leave only metadata on disk and unavailable native storage fails closed', async () => { + let secret; + let locked = false; + let commands = []; + let execFileFn = async (_command, args, options) => { + assert.equal(options.timeout, 30000); + commands.push(args[0]); + if (locked) throw new Error('locked'); + if (args[0] === 'add-generic-password') + secret = args[args.indexOf('-w') + 1]; + if (args[0] === 'delete-generic-password') secret = undefined; + return { stdout: args[0] === 'find-generic-password' ? secret : '' }; + }; + let tokens = store({ + platform: 'darwin', + storage: 'keychain', + env: {}, + execFileFn, + }); + await tokens.saveTokens(auth); + assert.deepEqual(await tokens.getTokens(), auth); + await tokens.markRefreshUncertain(); + assert.equal((await tokens.getTokens()).refreshUncertain, true); + let disk = JSON.parse( + await readFile(getAuthStoragePath(apiUrl, home), 'utf8') + ); + assert.equal(disk.storage, 'keychain'); + assert.equal(disk.refreshUncertain, true); + assert.equal(disk.accessToken, undefined); + assert.equal(disk.refreshToken, undefined); + assert.deepEqual(disk.authorization, fixtureAuthorization); + assert.equal(commands.includes('delete-generic-password'), false); + await tokens.saveTokens({ + ...auth, + accessToken: 'renewed-access', + refreshToken: 'renewed-refresh', + }); + assert.equal((await tokens.getTokens()).refreshUncertain, undefined); + locked = true; + await assert.rejects(() => tokens.getTokens(), { + code: 'AUTH_STORAGE_UNAVAILABLE', + }); + assert.equal( + JSON.parse(await readFile(getAuthStoragePath(apiUrl, home), 'utf8')) + .storage, + 'keychain' + ); }); -test('an invalid saved expiry does not authorize a request', async () => { - await saveAuthTokens({ accessToken: 'access', expiresAt: 'invalid' }); - assert.equal(await hasValidTokens(), false); - assert.equal(await getAccessToken(), null); +test('automatic native-storage failure uses an explicit owner-only file record', async () => { + let tokens = store({ + platform: 'darwin', + storage: 'auto', + env: {}, + execFileFn: async () => { + throw new Error('unavailable'); + }, + }); + await tokens.saveTokens(auth); + assert.deepEqual(await tokens.getTokens(), auth); + assert.equal( + JSON.parse(await readFile(getAuthStoragePath(apiUrl, home), 'utf8')) + .storage, + 'file' + ); + assert.equal( + (await stat(getAuthStoragePath(apiUrl, home))).mode & 0o777, + 0o600 + ); }); diff --git a/src/domains/auth/whoami.commands.js b/src/domains/auth/whoami.commands.js index 7e166003..6ee9c39b 100644 --- a/src/domains/auth/whoami.commands.js +++ b/src/domains/auth/whoami.commands.js @@ -1,5 +1,7 @@ import * as defaultOutput from '../../shared/output.js'; import { getApiUrl as defaultGetApiUrl } from '../config/environment.js'; +import { normalizeAuthorization } from './auth.normalizers.js'; +import { requireScopedAuthorization } from './auth.rules.js'; import { whoami as defaultWhoami } from './auth.service.js'; import { createAuthClient as defaultCreateAuthClient } from './auth-client.js'; import { @@ -23,7 +25,7 @@ export function getTokenExpiryStatus(expiresAt, now = new Date()) { return { level: 'warn', message: 'Token has expired', - refreshHint: 'Run "vizzly login" to refresh your authentication', + refreshHint: 'Access tokens renew automatically when needed.', }; } @@ -51,7 +53,7 @@ export function getTokenExpiryStatus(expiresAt, now = new Date()) { return { level: 'warn', message: 'Token expires in less than a minute', - refreshHint: 'Run "vizzly login" to refresh your authentication', + refreshHint: 'Access tokens renew automatically when needed.', }; } @@ -84,11 +86,13 @@ export async function whoamiCommand( try { // Check if user is logged in - let auth = await getAuthTokens(); + let apiUrl = options.apiUrl || getApiUrl(); + let apiOrigin = new URL(apiUrl).origin; + let auth = await getAuthTokens(apiUrl); if (!auth?.accessToken) { if (globalOptions.json) { - output.data({ authenticated: false }); + output.data({ authenticated: false, apiUrl: apiOrigin }); } else { output.header('whoami'); output.print(' Not logged in'); @@ -103,12 +107,14 @@ export async function whoamiCommand( output.startSpinner('Fetching user information...'); let client = createAuthClient({ - baseUrl: options.apiUrl || getApiUrl(), + baseUrl: apiUrl, }); - let tokenStore = createTokenStore(); + let tokenStore = createTokenStore({ apiUrl }); let response = await whoami(client, tokenStore); - auth = await getAuthTokens(); + auth = await getAuthTokens(apiUrl); + let authorization = normalizeAuthorization(response.authorization); + requireScopedAuthorization(authorization); output.stopSpinner(); @@ -116,9 +122,12 @@ export async function whoamiCommand( if (globalOptions.json) { output.data({ authenticated: true, + apiUrl: apiOrigin, + credential: { source: 'user', kind: 'user' }, + authorization, user: response.user, organizations: response.organizations || [], - tokenExpiresAt: auth.expiresAt, + tokenExpiresAt: auth?.expiresAt, }); output.cleanup(); return; @@ -132,6 +141,9 @@ export async function whoamiCommand( let userInfo = { User: response.user.name || response.user.username, Email: response.user.email, + API: apiOrigin, + Credential: 'saved user login', + Permissions: authorization.scopes.join(', ') || 'None', }; if (response.user.username) { @@ -167,7 +179,7 @@ export async function whoamiCommand( } // Show token expiry info - if (auth.expiresAt) { + if (auth?.expiresAt) { output.blank(); let expiry = getTokenExpiryStatus(auth.expiresAt); if (expiry.level === 'warn') { diff --git a/src/domains/auth/whoami.commands.test.js b/src/domains/auth/whoami.commands.test.js index f7f4717c..54c21b44 100644 --- a/src/domains/auth/whoami.commands.test.js +++ b/src/domains/auth/whoami.commands.test.js @@ -1,57 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getTokenExpiryStatus, whoamiCommand } from './whoami.commands.js'; +import { getTokenExpiryStatus } from './whoami.commands.js'; -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - data: obj => calls.push({ method: 'data', args: [obj] }), - header: command => calls.push({ method: 'header', args: [command] }), - print: message => calls.push({ method: 'print', args: [message] }), - blank: () => calls.push({ method: 'blank', args: [] }), - hint: message => calls.push({ method: 'hint', args: [message] }), - warn: message => calls.push({ method: 'warn', args: [message] }), - error: (message, error) => - calls.push({ method: 'error', args: [message, error] }), - keyValue: values => calls.push({ method: 'keyValue', args: [values] }), - labelValue: (label, value) => - calls.push({ method: 'labelValue', args: [label, value] }), - list: items => calls.push({ method: 'list', args: [items] }), - startSpinner: message => - calls.push({ method: 'startSpinner', args: [message] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - }; -} - -let auth = { - accessToken: 'access-token', - refreshToken: 'refresh-token', - expiresAt: '2026-05-19T12:00:00.000Z', -}; - -let response = { - user: { - id: 'user-1', - name: 'Robert', - username: 'rob', - email: 'rob@example.com', - }, - organizations: [ - { - id: 'org-1', - name: 'Vizzly', - slug: 'vizzly', - role: 'owner', - }, - ], -}; - -describe('commands/whoami', () => { +describe('whoami access-token expiry', () => { describe('getTokenExpiryStatus', () => { - it('formats expired tokens with a refresh hint', () => { + it('explains automatic renewal for expired access tokens', () => { let result = getTokenExpiryStatus( '2026-05-18T11:59:00.000Z', new Date('2026-05-18T12:00:00.000Z') @@ -60,7 +13,7 @@ describe('commands/whoami', () => { assert.deepStrictEqual(result, { level: 'warn', message: 'Token has expired', - refreshHint: 'Run "vizzly login" to refresh your authentication', + refreshHint: 'Access tokens renew automatically when needed.', }); }); @@ -76,7 +29,7 @@ describe('commands/whoami', () => { }); }); - it('formats less-than-minute expiry with a refresh hint', () => { + it('explains automatic renewal near access-token expiry', () => { let result = getTokenExpiryStatus( '2026-05-18T12:00:30.000Z', new Date('2026-05-18T12:00:00.000Z') @@ -85,115 +38,8 @@ describe('commands/whoami', () => { assert.deepStrictEqual(result, { level: 'warn', message: 'Token expires in less than a minute', - refreshHint: 'Run "vizzly login" to refresh your authentication', - }); - }); - }); - - describe('whoamiCommand', () => { - it('returns unauthenticated JSON when no token is stored', async () => { - let output = createMockOutput(); - - await whoamiCommand( - {}, - { json: true }, - { - getAuthTokens: async () => null, - output, - } - ); - - let dataCall = output.calls.find(call => call.method === 'data'); - assert.deepStrictEqual(dataCall.args[0], { authenticated: false }); - assert.ok(output.calls.some(call => call.method === 'cleanup')); - }); - - it('fetches user info with the configured auth client', async () => { - let output = createMockOutput(); - - await whoamiCommand( - { apiUrl: 'https://api.example.test' }, - { json: true }, - { - getAuthTokens: async () => auth, - createAuthClient: () => ({ kind: 'client' }), - createTokenStore: () => ({ kind: 'store' }), - whoami: async () => response, - output, - } - ); - - let dataCall = output.calls.find(call => call.method === 'data'); - assert.strictEqual(dataCall.args[0].authenticated, true); - assert.deepStrictEqual(dataCall.args[0].user, response.user); - assert.deepStrictEqual( - dataCall.args[0].organizations, - response.organizations - ); - assert.strictEqual(dataCall.args[0].tokenExpiresAt, auth.expiresAt); - }); - - it('prints human-readable user and organization information', async () => { - let output = createMockOutput(); - - await whoamiCommand( - {}, - { verbose: true }, - { - getAuthTokens: async () => auth, - getApiUrl: () => 'https://api.test', - createAuthClient: () => ({}), - createTokenStore: () => ({}), - whoami: async () => response, - output, - } - ); - - let keyValueCall = output.calls.find(call => call.method === 'keyValue'); - assert.strictEqual(keyValueCall.args[0].User, 'Robert'); - assert.strictEqual(keyValueCall.args[0].Email, 'rob@example.com'); - assert.strictEqual(keyValueCall.args[0]['User ID'], 'user-1'); - - let listCall = output.calls.find(call => call.method === 'list'); - assert.deepStrictEqual(listCall.args[0], ['Vizzly @vizzly [owner]']); - assert.ok( - output.calls.some( - call => - call.method === 'hint' && call.args[0].includes('Token expires') - ) - ); - }); - - it('reports auth errors and exits with status 1', async () => { - let output = createMockOutput(); - let exitCode = null; - let error = new Error('expired'); - error.name = 'AuthError'; - - await whoamiCommand( - {}, - { json: true }, - { - getAuthTokens: async () => auth, - createAuthClient: () => ({}), - createTokenStore: () => ({}), - whoami: async () => { - throw error; - }, - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - let dataCall = output.calls.find(call => call.method === 'data'); - assert.deepStrictEqual(dataCall.args[0], { - authenticated: false, - error: 'expired', + refreshHint: 'Access tokens renew automatically when needed.', }); - assert.ok(output.calls.some(call => call.method === 'cleanup')); }); }); }); diff --git a/src/domains/baselines/baseline.routes.js b/src/domains/baselines/baseline.routes.js index 4f826ff7..b029b4f9 100644 --- a/src/domains/baselines/baseline.routes.js +++ b/src/domains/baselines/baseline.routes.js @@ -105,32 +105,15 @@ async function handleResetBaselines(res, screenshotHandler) { } } -function shouldFallbackFromOAuth(error) { - return ( - error.message?.includes('Not authenticated') || - error.message?.includes('401') - ); -} - async function downloadBaselinesWithOAuth({ authService, tddService, buildId, - organizationSlug, - projectSlug, }) { output.info(`Downloading baselines from build ${buildId}...`); - output.debug( - 'baseline', - `Using OAuth for ${organizationSlug}/${projectSlug}` - ); - let apiResponse = await authService.authenticatedRequest( - `/api/cli/${projectSlug}/builds/${buildId}/tdd-baselines`, - { - method: 'GET', - headers: { 'X-Organization': organizationSlug }, - } + `/api/sdk/builds/${buildId}/tdd-baselines`, + { method: 'GET' } ); if (!apiResponse) { @@ -157,38 +140,17 @@ async function handleBaselineDownload({ req, res, tddService, authService }) { try { let body = await parseJsonBody(req); - let { buildId, organizationSlug, projectSlug } = body; + let { buildId } = body; if (!buildId) { sendError(res, 400, 'buildId is required'); return true; } - let result; - - if (authService && organizationSlug && projectSlug) { - try { - result = await downloadBaselinesWithOAuth({ - authService, - tddService, - buildId, - organizationSlug, - projectSlug, - }); - } catch (oauthError) { - if (!shouldFallbackFromOAuth(oauthError)) { - throw oauthError; - } - output.debug( - 'baseline', - `OAuth failed, trying other auth methods: ${oauthError.message}` - ); - } - } - - if (!result) { - result = await downloadBaselinesWithToken(tddService, buildId); - } + let result = + authService && (await authService.hasSavedSession()) + ? await downloadBaselinesWithOAuth({ authService, tddService, buildId }) + : await downloadBaselinesWithToken(tddService, buildId); sendSuccess(res, { success: true, @@ -198,7 +160,7 @@ async function handleBaselineDownload({ req, res, tddService, authService }) { return true; } catch (error) { output.error('Error downloading baselines:', error); - sendError(res, 500, error.message); + sendError(res, error.context?.status || 500, error.message); return true; } } diff --git a/src/domains/baselines/baseline.routes.test.js b/src/domains/baselines/baseline.routes.test.js index 343cefce..fe66ee58 100644 --- a/src/domains/baselines/baseline.routes.test.js +++ b/src/domains/baselines/baseline.routes.test.js @@ -477,13 +477,13 @@ describe('server/routers/baseline', () => { assert.ok(body.error.includes('Download failed')); }); - it('uses OAuth when organizationSlug and projectSlug provided', async () => { + it('uses the selected user session with the SDK build endpoint', async () => { let authRequestPath = null; - let authRequestHeaders = null; let mockAuthService = { + hasSavedSession: async () => true, authenticatedRequest: async (path, options) => { authRequestPath = path; - authRequestHeaders = options.headers; + assert.strictEqual(options.method, 'GET'); return { build: { id: 'build-456', status: 'completed' }, screenshots: [], @@ -507,18 +507,18 @@ describe('server/routers/baseline', () => { await handler(req, res, '/api/baselines/download'); assert.strictEqual(res.statusCode, 200); - assert.ok( - authRequestPath.includes('/api/cli/my-project/builds/build-456'), - `Expected OAuth path, got: ${authRequestPath}` + assert.strictEqual( + authRequestPath, + '/api/sdk/builds/build-456/tdd-baselines' ); - assert.strictEqual(authRequestHeaders['X-Organization'], 'my-org'); }); - it('falls back to tddService when OAuth fails with auth error', async () => { + it('uses project-token access only when no user session is saved', async () => { let tddServiceCalled = false; let mockAuthService = { + hasSavedSession: async () => false, authenticatedRequest: async () => { - throw new Error('Not authenticated'); + assert.fail('Should not use the user session'); }, }; let mockTddService = { @@ -546,13 +546,16 @@ describe('server/routers/baseline', () => { await handler(req, res, '/api/baselines/download'); assert.strictEqual(res.statusCode, 200); - assert.ok(tddServiceCalled, 'Should fall back to tddService'); + assert.ok(tddServiceCalled, 'Should use the project-token path'); }); - it('throws non-auth OAuth errors without fallback', async () => { + it('surfaces selected-user auth and scope failures without project-token fallback', async () => { let mockAuthService = { + hasSavedSession: async () => true, authenticatedRequest: async () => { - throw new Error('Build not found'); + let error = new Error('Read permission required'); + error.context = { status: 403 }; + throw error; }, }; let tddServiceCalled = false; @@ -580,19 +583,20 @@ describe('server/routers/baseline', () => { await handler(req, res, '/api/baselines/download'); - assert.strictEqual(res.statusCode, 500); + assert.strictEqual(res.statusCode, 403); assert.ok( !tddServiceCalled, - 'Should NOT fall back for non-auth errors' + 'Should not switch identities after a selected-user denial' ); let body = res.getParsedBody(); - assert.ok(body.error.includes('Build not found')); + assert.ok(body.error.includes('Read permission required')); }); - it('skips OAuth when organizationSlug or projectSlug missing', async () => { + it('does not need project and organization slugs for SDK baseline reads', async () => { let authServiceCalled = false; let tddServiceCalled = false; let mockAuthService = { + hasSavedSession: async () => true, authenticatedRequest: async () => { authServiceCalled = true; return { build: {}, screenshots: [] }; @@ -620,8 +624,8 @@ describe('server/routers/baseline', () => { await handler(req, res, '/api/baselines/download'); assert.strictEqual(res.statusCode, 200); - assert.ok(!authServiceCalled, 'Should NOT use OAuth without slugs'); - assert.ok(tddServiceCalled, 'Should use tddService directly'); + assert.ok(authServiceCalled, 'Should use the selected user session'); + assert.ok(!tddServiceCalled, 'Should not switch to project-token auth'); }); }); }); diff --git a/src/domains/baselines/cloud-baselines.routes.js b/src/domains/baselines/cloud-baselines.routes.js index e1ea0891..8c0e5b6c 100644 --- a/src/domains/baselines/cloud-baselines.routes.js +++ b/src/domains/baselines/cloud-baselines.routes.js @@ -5,7 +5,7 @@ import { } from '../../adapters/http/middleware/response.js'; import * as output from '../../shared/output.js'; -export function createCloudBaselinesRouter({ authService }) { +export function createCloudBaselinesRouter({ projectService }) { return async function handleCloudBaselinesRoute(req, res, pathname) { if (req.method !== 'POST' || pathname !== '/api/cloud/baselines/download') { return false; @@ -19,14 +19,18 @@ export function createCloudBaselinesRouter({ authService }) { return true; } - let response = await authService.authenticatedRequest( - '/api/cli/baselines/download', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ buildId, screenshotNames }), - } - ); + let response = await projectService.getTddBaselines(buildId); + if ( + Array.isArray(screenshotNames) && + Array.isArray(response.screenshots) + ) { + response = { + ...response, + screenshots: response.screenshots.filter(screenshot => + screenshotNames.includes(screenshot.name) + ), + }; + } sendSuccess(res, { success: true, message: `Baselines downloaded from build ${buildId}`, @@ -34,7 +38,7 @@ export function createCloudBaselinesRouter({ authService }) { }); } catch (error) { output.error('Error downloading baselines from cloud:', error); - sendError(res, 500, error.message); + sendError(res, error.context?.status || 500, error.message); } return true; }; diff --git a/src/domains/builds/build.service.js b/src/domains/builds/build.service.js index 1aecabfc..2752e1db 100644 --- a/src/domains/builds/build.service.js +++ b/src/domains/builds/build.service.js @@ -47,13 +47,16 @@ export async function getBuilds(client, filters = {}) { * @param {Object} metadata - Build metadata * @returns {Promise} Created build data */ -export async function createBuild(client, metadata) { +export async function createBuild(client, metadata, projectQuery = {}) { let payload = buildBuildPayload(metadata); - return client.request('/api/sdk/builds', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ build: payload }), - }); + return client.request( + buildEndpointWithParams('/api/sdk/builds', projectQuery), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ build: payload }), + } + ); } /** @@ -117,9 +120,19 @@ export async function finalizeBuild( * @param {string} parallelId - Parallel ID to finalize * @returns {Promise} Finalization result */ -export async function finalizeParallelBuild(client, parallelId) { - return client.request(`/api/sdk/parallel/${parallelId}/finalize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); +export async function finalizeParallelBuild( + client, + parallelId, + projectQuery = {} +) { + return client.request( + buildEndpointWithParams( + `/api/sdk/parallel/${parallelId}/finalize`, + projectQuery + ), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + } + ); } diff --git a/src/domains/builds/builds.commands.js b/src/domains/builds/builds.commands.js index 3b28d9ce..25128783 100644 --- a/src/domains/builds/builds.commands.js +++ b/src/domains/builds/builds.commands.js @@ -56,7 +56,7 @@ export async function buildsCommand( credential, command: 'builds', }); - if (!globalOptions.json) showCredential(output, credential, 'builds'); + if (!globalOptions.json) showCredential(output, credential); // If a specific build ID is provided, get that build if (options.build) { diff --git a/src/domains/builds/cloud-builds.routes.js b/src/domains/builds/cloud-builds.routes.js index ed6973ec..3017295d 100644 --- a/src/domains/builds/cloud-builds.routes.js +++ b/src/domains/builds/cloud-builds.routes.js @@ -4,7 +4,7 @@ import { } from '../../adapters/http/middleware/response.js'; import * as output from '../../shared/output.js'; -export function createCloudBuildsRouter({ authService }) { +export function createCloudBuildsRouter({ projectService }) { return async function handleCloudBuildsRoute(req, res, pathname, parsedUrl) { let buildsMatch = pathname.match( /^\/api\/cloud\/organizations\/([^/]+)\/projects\/([^/]+)\/builds$/ @@ -16,19 +16,15 @@ export function createCloudBuildsRouter({ authService }) { let projectSlug = decodeURIComponent(buildsMatch[2]); let limit = parsedUrl.searchParams.get('limit') || '20'; let branch = parsedUrl.searchParams.get('branch'); - let queryParams = new URLSearchParams(); - if (limit) queryParams.append('limit', limit); - if (branch) queryParams.append('branch', branch); - - let query = queryParams.toString(); - let endpoint = `/api/cli/organizations/${organizationSlug}/projects/${projectSlug}/builds${query ? `?${query}` : ''}`; - let response = await authService.authenticatedRequest(endpoint, { - method: 'GET', - }); - sendSuccess(res, { builds: response.builds || [] }); + let builds = await projectService.getRecentBuilds( + projectSlug, + organizationSlug, + { limit: Number(limit), branch: branch || undefined } + ); + sendSuccess(res, { builds }); } catch (error) { output.error('Error fetching builds from cloud:', error); - sendError(res, 500, error.message); + sendError(res, error.context?.status || 500, error.message); } return true; }; diff --git a/src/domains/builds/finalize.commands.js b/src/domains/builds/finalize.commands.js index d5e0798a..a7bd3001 100644 --- a/src/domains/builds/finalize.commands.js +++ b/src/domains/builds/finalize.commands.js @@ -5,7 +5,9 @@ import * as defaultOutput from '../../shared/output.js'; import { createApiClient as defaultCreateApiClient } from '../auth/cloud-client.js'; +import { resolveCredential } from '../auth/credential.service.js'; import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; +import { requireUploadDestination } from '../projects/upload-destination.service.js'; import { writeSession as defaultWriteSession } from '../runs/session-store.js'; import { finalizeParallelBuild as defaultFinalizeParallelBuild } from './build.service.js'; @@ -48,15 +50,21 @@ export async function finalizeCommand( let allOptions = { ...globalOptions, ...options }; let config = await loadConfig(globalOptions.config, allOptions); - // Validate API token - if (!config.apiKey) { + if (!config.apiKey && !config.userToken) { output.error( - 'API token required. Use --token or set VIZZLY_TOKEN environment variable' + 'Login or API token required. Run vizzly login, use --token, or set VIZZLY_TOKEN.' ); exit(1); return { success: false, reason: 'no-api-key' }; } + let credential = resolveCredential(config, { command: 'finalize' }); + let destination = + credential.kind === 'user' + ? await requireUploadDestination(config) + : null; + credential = destination?.credential || credential; + if (globalOptions.verbose) { output.info('Configuration loaded'); output.debug('Config details', { @@ -69,10 +77,15 @@ export async function finalizeCommand( output.startSpinner('Finalizing parallel build...'); let client = createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token: credential.token, + credential, command: 'finalize', }); - let result = await finalizeParallelBuild(client, parallelId); + let result = await finalizeParallelBuild( + client, + parallelId, + destination?.projectQuery + ); output.stopSpinner(); // Write session for subsequent commands (like preview) diff --git a/src/domains/config/config-loader.js b/src/domains/config/config-loader.js index 836136ea..2265d46f 100644 --- a/src/domains/config/config-loader.js +++ b/src/domains/config/config-loader.js @@ -115,10 +115,19 @@ export async function loadConfig(configPath = null, cliOverrides = {}) { } // 6. Keep user auth separate from upload credentials. - let auth = await getAuthTokens(); - if (auth) { - config.userToken = auth.accessToken; - output.debug('config', 'using user login for user-authenticated commands'); + try { + let auth = await getAuthTokens(config.apiUrl); + if (auth) { + config.userToken = auth.accessToken; + config.userAuthorization = auth.authorization; + output.debug( + 'config', + 'using user login for user-authenticated commands' + ); + } + } catch (error) { + if (error.code !== 'AUTH_STORAGE_UNAVAILABLE') throw error; + config.userAuthError = error; } config._configPath = result?.filepath || null; diff --git a/src/domains/config/config-loader.test.js b/src/domains/config/config-loader.test.js index a8a4858b..b613a01a 100644 --- a/src/domains/config/config-loader.test.js +++ b/src/domains/config/config-loader.test.js @@ -8,7 +8,12 @@ import { } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../../tests/helpers/auth-session.js'; import { saveGlobalConfig } from '../../shared/global-config.js'; +import { getAuthStoragePath } from '../auth/token-store.js'; import { getScreenshotPaths, loadConfig } from './config-loader.js'; describe('utils/config-loader', () => { @@ -67,6 +72,7 @@ describe('utils/config-loader', () => { // Clean env delete process.env.VIZZLY_TOKEN; + delete process.env.VIZZLY_API_URL; delete process.env.VIZZLY_BUILD_NAME; delete process.env.VIZZLY_PARALLEL_ID; delete process.env.VIZZLY_THRESHOLD; @@ -189,18 +195,28 @@ export default { comparison: { threshold: Threshold.Normal } };` }); it('keeps user login separate from cloud upload credentials', async () => { - await saveGlobalConfig({ - auth: { - accessToken: 'user-access-token', - refreshToken: 'refresh-token', - expiresAt: '2999-01-01T00:00:00.000Z', - }, + await saveFixtureAuth(process.env.VIZZLY_HOME, 'https://app.vizzly.dev', { + accessToken: 'user-access-token', + refreshToken: 'refresh-token', + expiresAt: '2999-01-01T00:00:00.000Z', }); let config = await loadConfig(); assert.strictEqual(config.apiKey, undefined); assert.strictEqual(config.userToken, 'user-access-token'); + assert.deepStrictEqual(config.userAuthorization, fixtureAuthorization); + }); + + it('retains unavailable user storage errors for credential selection while loading explicit project tokens', async () => { + await saveFixtureAuth(process.env.VIZZLY_HOME, 'https://app.vizzly.dev'); + writeFileSync(getAuthStoragePath('https://app.vizzly.dev'), '{corrupt'); + let config = await loadConfig(null, { token: 'explicit-project-token' }); + assert.strictEqual(config.apiKey, 'explicit-project-token'); + assert.strictEqual(config.credentialSource, 'flag'); + assert.strictEqual(config.userToken, undefined); + assert.ok(config.userAuthError instanceof Error); + assert.strictEqual(config.userAuthError.code, 'AUTH_STORAGE_UNAVAILABLE'); }); it('uses the repository-linked project token for cloud upload credentials', async () => { diff --git a/src/domains/config/config.commands.js b/src/domains/config/config.commands.js index 607e1616..56f5259d 100644 --- a/src/domains/config/config.commands.js +++ b/src/domains/config/config.commands.js @@ -46,6 +46,7 @@ export async function configCommand( upload: config.upload || {}, comparison: config.comparison || CONFIG_DEFAULTS.comparison, tdd: config.tdd || {}, + ...(config.project ? { project: config.project } : {}), }; // Add API config (without exposing full token) @@ -118,6 +119,7 @@ export async function configCommand( displaySection(output, 'Server', displayConfig.server); displaySection(output, 'Comparison', displayConfig.comparison); displaySection(output, 'TDD', displayConfig.tdd); + displaySection(output, 'Project', displayConfig.project); if (globalOptions.verbose) { displaySection(output, 'Build', displayConfig.build); diff --git a/src/domains/context/context.commands.js b/src/domains/context/context.commands.js index 77b7fc39..83894383 100644 --- a/src/domains/context/context.commands.js +++ b/src/domains/context/context.commands.js @@ -201,8 +201,7 @@ async function loadContextRuntime( ...options, command: 'context', }); - if (!globalOptions.json) - showCredential(output, config.readCredential, 'projects'); + if (!globalOptions.json) showCredential(output, config.readCredential); } let provider = diff --git a/src/domains/previews/preview.commands.js b/src/domains/previews/preview.commands.js index 5f9deef6..3e69c02a 100644 --- a/src/domains/previews/preview.commands.js +++ b/src/domains/previews/preview.commands.js @@ -17,6 +17,8 @@ import { detectBranch as defaultDetectBranch } from '../../shared/git.js'; import * as defaultOutput from '../../shared/output.js'; import { createWildcardMatcher } from '../../shared/patterns.js'; import { createApiClient as defaultCreateApiClient } from '../auth/cloud-client.js'; +import { requireCredentialScope } from '../auth/credential.rules.js'; +import { resolveCredential } from '../auth/credential.service.js'; import { getBuild as defaultGetBuild } from '../builds/build.service.js'; import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; import { @@ -357,9 +359,9 @@ export async function previewCommand( let config = await loadConfig(globalOptions.config, allOptions); // Validate API token (skip for dry-run) - if (!options.dryRun && !config.apiKey) { + if (!options.dryRun && !config.apiKey && !config.userToken) { output.error( - 'API token required. Use --token or set VIZZLY_TOKEN environment variable' + 'Login or API token required. Run vizzly login, use --token, or set VIZZLY_TOKEN.' ); exit(1); return { success: false, reason: 'no-api-key' }; @@ -416,31 +418,45 @@ export async function previewCommand( // Create API client for non-dry-run operations (reused for visibility check and upload) let client; if (!options.dryRun) { + let credential = resolveCredential(config, { command: 'preview' }); client = createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token: credential.token, + credential, command: 'preview', }); + if (credential.kind === 'user') { + let identity = await client.request('/api/auth/cli/whoami'); + requireCredentialScope( + { + ...credential, + authorization: identity.authorization || credential.authorization, + }, + 'builds:write' + ); + } // Check project visibility for private projects let buildResponse; - try { - buildResponse = await getBuild(client, buildId); - } catch (error) { - if (error.status === 404) { - output.error(`Build not found: ${buildId}`); - } else { - output.error('Failed to verify project visibility', error); + if (!options.publicLink) { + try { + buildResponse = await getBuild(client, buildId); + } catch (error) { + if (error.status === 404) { + output.error(`Build not found: ${buildId}`); + } else { + output.error('Failed to verify project visibility', error); + } + exit(1); + // Return is for testing (exit is mocked in tests) + return { success: false, reason: 'build-fetch-failed', error }; } - exit(1); - // Return is for testing (exit is mocked in tests) - return { success: false, reason: 'build-fetch-failed', error }; } // Check if project is private and user hasn't acknowledged public link access // Note: API returns { build, project } at top level, not nested // Use === false to handle undefined/missing isPublic defensively - let project = buildResponse.project; + let project = buildResponse?.project; let isPrivate = project && project.isPublic === false; if (isPrivate && !options.publicLink) { output.error('This project is private.'); diff --git a/src/domains/projects/cloud-projects.routes.js b/src/domains/projects/cloud-projects.routes.js index 0eac3f9f..d3d01367 100644 --- a/src/domains/projects/cloud-projects.routes.js +++ b/src/domains/projects/cloud-projects.routes.js @@ -4,29 +4,21 @@ import { } from '../../adapters/http/middleware/response.js'; import * as output from '../../shared/output.js'; -export function createCloudProjectsRouter({ authService }) { +export function createCloudProjectsRouter({ projectService }) { return async function handleCloudProjectsRoute(req, res, pathname) { if (req.method !== 'GET' || pathname !== '/api/cloud/projects') return false; try { - let response = await authService.authenticatedRequest( - '/api/cli/projects', - { - method: 'GET', - } - ); - sendSuccess(res, { projects: response.projects || [] }); - } catch (error) { - output.error('Error fetching projects from cloud:', error); - if ( - error.message?.toLowerCase().includes('not authenticated') || - error.code === 'AUTH_ERROR' - ) { - sendSuccess(res, { projects: [], authenticated: false }); + let projects = await projectService.listProjects(); + if (projects.length === 0 && !(await projectService.hasSavedSession())) { + sendSuccess(res, { projects, authenticated: false }); } else { - sendError(res, 500, error.message); + sendSuccess(res, { projects }); } + } catch (error) { + output.error('Error fetching projects from cloud:', error); + sendError(res, error.context?.status || 500, error.message); } return true; }; diff --git a/src/domains/projects/orgs.commands.js b/src/domains/projects/orgs.commands.js index 85027f56..ba4e5486 100644 --- a/src/domains/projects/orgs.commands.js +++ b/src/domains/projects/orgs.commands.js @@ -65,7 +65,7 @@ export async function orgsCommand(options = {}, globalOptions = {}, deps = {}) { }); } else { output.header('orgs'); - showCredential(output, credential, 'orgs'); + showCredential(output, credential); let colors = output.getColors(); diff --git a/src/domains/projects/project-contracts.test.js b/src/domains/projects/project-contracts.test.js deleted file mode 100644 index 0e502aef..00000000 --- a/src/domains/projects/project-contracts.test.js +++ /dev/null @@ -1,252 +0,0 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { VizzlyError } from '../../shared/vizzly-error.js'; -import { - enrichProjectsWithOrg, - extractBuilds, - extractOrganizations, - extractProject, - extractProjects, - extractToken, - extractTokens, -} from './project.normalizers.js'; -import { - buildNoApiServiceError, - buildNoAuthError, - buildProjectFetchError, - buildTokenCreateError, - buildTokenRevokeError, - buildTokensFetchError, -} from './project-errors.js'; -import { - buildBuildsQueryParams, - buildBuildsUrl, - buildOrgHeader, - buildProjectUrl, - buildTokensUrl, -} from './project-requests.js'; - -describe('project/core', () => { - describe('buildBuildsQueryParams', () => { - it('returns empty string for no options', () => { - assert.strictEqual(buildBuildsQueryParams(), ''); - assert.strictEqual(buildBuildsQueryParams({}), ''); - }); - - it('builds query string with limit', () => { - assert.strictEqual(buildBuildsQueryParams({ limit: 10 }), '?limit=10'); - }); - - it('builds query string with branch', () => { - assert.strictEqual( - buildBuildsQueryParams({ branch: 'main' }), - '?branch=main' - ); - }); - - it('builds query string with both params', () => { - let result = buildBuildsQueryParams({ limit: 5, branch: 'develop' }); - assert.ok(result.includes('limit=5')); - assert.ok(result.includes('branch=develop')); - assert.ok(result.includes('&')); - }); - }); - - describe('buildOrgHeader', () => { - it('builds X-Organization header', () => { - assert.deepStrictEqual(buildOrgHeader('my-org'), { - 'X-Organization': 'my-org', - }); - }); - }); - - describe('buildProjectUrl', () => { - it('builds project API URL', () => { - assert.strictEqual( - buildProjectUrl('my-project'), - '/api/project/my-project' - ); - }); - }); - - describe('buildBuildsUrl', () => { - it('builds builds API URL without query params', () => { - assert.strictEqual(buildBuildsUrl('my-project'), '/api/build/my-project'); - }); - - it('builds builds API URL with query params', () => { - assert.strictEqual( - buildBuildsUrl('my-project', { limit: 5 }), - '/api/build/my-project?limit=5' - ); - }); - }); - - describe('buildTokensUrl', () => { - it('builds tokens API URL without token ID', () => { - assert.strictEqual( - buildTokensUrl('my-org', 'my-project'), - '/api/project/my-project/tokens' - ); - }); - - it('builds tokens API URL with token ID', () => { - assert.strictEqual( - buildTokensUrl('my-org', 'my-project', 'tok_123'), - '/api/project/my-project/tokens/tok_123' - ); - }); - }); - - describe('extractProjects', () => { - it('extracts projects array from response', () => { - let response = { projects: [{ id: 1 }, { id: 2 }] }; - assert.deepStrictEqual(extractProjects(response), [{ id: 1 }, { id: 2 }]); - }); - - it('returns empty array for missing projects', () => { - assert.deepStrictEqual(extractProjects({}), []); - assert.deepStrictEqual(extractProjects(null), []); - assert.deepStrictEqual(extractProjects(undefined), []); - }); - }); - - describe('extractProject', () => { - it('extracts project from response.project', () => { - let response = { project: { id: 1, name: 'Test' } }; - assert.deepStrictEqual(extractProject(response), { id: 1, name: 'Test' }); - }); - - it('returns response directly if no project field', () => { - let response = { id: 1, name: 'Test' }; - assert.deepStrictEqual(extractProject(response), { id: 1, name: 'Test' }); - }); - }); - - describe('extractBuilds', () => { - it('extracts builds array from response', () => { - let response = { builds: [{ id: 'b1' }, { id: 'b2' }] }; - assert.deepStrictEqual(extractBuilds(response), [ - { id: 'b1' }, - { id: 'b2' }, - ]); - }); - - it('returns empty array for missing builds', () => { - assert.deepStrictEqual(extractBuilds({}), []); - }); - }); - - describe('extractToken', () => { - it('extracts token from response', () => { - let response = { token: { id: 't1', value: 'vzt_xxx' } }; - assert.deepStrictEqual(extractToken(response), { - id: 't1', - value: 'vzt_xxx', - }); - }); - - it('returns undefined for missing token', () => { - assert.strictEqual(extractToken({}), undefined); - }); - }); - - describe('extractTokens', () => { - it('extracts tokens array from response', () => { - let response = { tokens: [{ id: 't1' }, { id: 't2' }] }; - assert.deepStrictEqual(extractTokens(response), [ - { id: 't1' }, - { id: 't2' }, - ]); - }); - - it('returns empty array for missing tokens', () => { - assert.deepStrictEqual(extractTokens({}), []); - }); - }); - - describe('enrichProjectsWithOrg', () => { - it('adds organization info to each project', () => { - let projects = [{ id: 'p1', name: 'Project 1' }]; - let org = { slug: 'my-org', name: 'My Org' }; - - let result = enrichProjectsWithOrg(projects, org); - - assert.deepStrictEqual(result, [ - { - id: 'p1', - name: 'Project 1', - organizationSlug: 'my-org', - organizationName: 'My Org', - }, - ]); - }); - - it('handles empty projects array', () => { - assert.deepStrictEqual( - enrichProjectsWithOrg([], { slug: 'org', name: 'Org' }), - [] - ); - }); - }); - - describe('extractOrganizations', () => { - it('extracts organizations from whoami response', () => { - let response = { organizations: [{ id: 'o1' }, { id: 'o2' }] }; - assert.deepStrictEqual(extractOrganizations(response), [ - { id: 'o1' }, - { id: 'o2' }, - ]); - }); - - it('returns empty array for missing organizations', () => { - assert.deepStrictEqual(extractOrganizations({}), []); - assert.deepStrictEqual(extractOrganizations(null), []); - }); - }); - - describe('error builders', () => { - it('buildProjectFetchError wraps original error', () => { - let original = new Error('Network failed'); - let error = buildProjectFetchError(original); - - assert.ok(error instanceof VizzlyError); - assert.strictEqual(error.code, 'PROJECT_FETCH_FAILED'); - assert.ok(error.message.includes('Network failed')); - }); - - it('buildNoAuthError creates no auth error', () => { - let error = buildNoAuthError(); - assert.strictEqual(error.code, 'NO_AUTH_SERVICE'); - assert.ok(error.message.includes('No authentication available')); - }); - - it('buildNoApiServiceError creates no API service error', () => { - let error = buildNoApiServiceError(); - assert.strictEqual(error.code, 'NO_API_SERVICE'); - assert.ok(error.message.includes('API service not available')); - }); - - it('buildTokenCreateError wraps original error', () => { - let original = new Error('Token create failed'); - let error = buildTokenCreateError(original); - - assert.strictEqual(error.code, 'TOKEN_CREATE_FAILED'); - assert.ok(error.message.includes('Token create failed')); - }); - - it('buildTokensFetchError wraps original error', () => { - let original = new Error('Fetch failed'); - let error = buildTokensFetchError(original); - - assert.strictEqual(error.code, 'TOKENS_FETCH_FAILED'); - }); - - it('buildTokenRevokeError wraps original error', () => { - let original = new Error('Revoke failed'); - let error = buildTokenRevokeError(original); - - assert.strictEqual(error.code, 'TOKEN_REVOKE_FAILED'); - }); - }); -}); diff --git a/src/domains/projects/project-errors.js b/src/domains/projects/project-errors.js deleted file mode 100644 index f4764d5d..00000000 --- a/src/domains/projects/project-errors.js +++ /dev/null @@ -1,69 +0,0 @@ -import { VizzlyError } from '../../shared/vizzly-error.js'; - -/** - * Build error for project fetch failure - * @param {Error} originalError - Original error - * @returns {VizzlyError} Wrapped error - */ -export function buildProjectFetchError(originalError) { - return new VizzlyError( - `Failed to fetch project: ${originalError.message}`, - 'PROJECT_FETCH_FAILED', - { originalError } - ); -} - -/** - * Build error for no authentication available - * @returns {VizzlyError} No auth error - */ -export function buildNoAuthError() { - return new VizzlyError('No authentication available', 'NO_AUTH_SERVICE'); -} - -/** - * Build error for no API service available - * @returns {VizzlyError} No API service error - */ -export function buildNoApiServiceError() { - return new VizzlyError('API service not available', 'NO_API_SERVICE'); -} - -/** - * Build error for token creation failure - * @param {Error} originalError - Original error - * @returns {VizzlyError} Wrapped error - */ -export function buildTokenCreateError(originalError) { - return new VizzlyError( - `Failed to create project token: ${originalError.message}`, - 'TOKEN_CREATE_FAILED', - { originalError } - ); -} - -/** - * Build error for token fetch failure - * @param {Error} originalError - Original error - * @returns {VizzlyError} Wrapped error - */ -export function buildTokensFetchError(originalError) { - return new VizzlyError( - `Failed to fetch project tokens: ${originalError.message}`, - 'TOKENS_FETCH_FAILED', - { originalError } - ); -} - -/** - * Build error for token revoke failure - * @param {Error} originalError - Original error - * @returns {VizzlyError} Wrapped error - */ -export function buildTokenRevokeError(originalError) { - return new VizzlyError( - `Failed to revoke project token: ${originalError.message}`, - 'TOKEN_REVOKE_FAILED', - { originalError } - ); -} diff --git a/src/domains/projects/project-picker.service.js b/src/domains/projects/project-picker.service.js index 30ee18fa..35e97724 100644 --- a/src/domains/projects/project-picker.service.js +++ b/src/domains/projects/project-picker.service.js @@ -1,6 +1,7 @@ import * as output from '../../shared/output.js'; import { createPrompts } from '../../shared/terminal-prompts.js'; import { createApiClient } from '../auth/cloud-client.js'; +import { requireCredentialScope } from '../auth/credential.rules.js'; import { resolveCredential } from '../auth/credential.service.js'; import { discoverProjects } from './discovery.service.js'; @@ -11,6 +12,7 @@ export async function pickProject(config, options = {}, prompts = null) { auth: options.token ? 'auto' : 'user', command: 'project link', }); + requireCredentialScope(credential, 'read'); let client = createApiClient({ baseUrl: config.apiUrl, token: credential.token, diff --git a/src/domains/projects/project-presentation.js b/src/domains/projects/project-presentation.js new file mode 100644 index 00000000..d0d82021 --- /dev/null +++ b/src/domains/projects/project-presentation.js @@ -0,0 +1,27 @@ +/** Keep project names, qualified selectors, and organizations distinct. */ +export function showProjectTable(output, projects) { + let showBuilds = projects.some(project => project.buildCount != null); + let headers = [ + 'Name', + 'Project', + 'Organization', + ...(showBuilds ? ['Builds'] : []), + ]; + let rows = projects.map(project => [ + project.name, + `${project.organizationSlug}/${project.slug}`, + project.organizationName || project.organizationSlug, + ...(showBuilds ? [String(project.buildCount ?? '—')] : []), + ]); + let widths = headers.map((header, index) => + Math.max(header.length, ...rows.map(row => row[index].length)) + ); + let line = row => + row + .map((value, index) => value.padEnd(widths[index])) + .join(' ') + .trimEnd(); + let colors = output.getColors(); + output.print(` ${colors.dim(line(headers))}`); + for (let row of rows) output.print(` ${line(row)}`); +} diff --git a/src/domains/projects/project-requests.js b/src/domains/projects/project-requests.js deleted file mode 100644 index cd411806..00000000 --- a/src/domains/projects/project-requests.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Build query params for builds API request - * @param {Object} options - Query options - * @param {number} [options.limit] - Number of builds to fetch - * @param {string} [options.branch] - Filter by branch - * @returns {string} Query string (empty string if no params) - */ -export function buildBuildsQueryParams(options = {}) { - let params = new globalThis.URLSearchParams(); - - if (options.limit) { - params.append('limit', String(options.limit)); - } - - if (options.branch) { - params.append('branch', options.branch); - } - - let query = params.toString(); - return query ? `?${query}` : ''; -} - -/** - * Build organization header object - * @param {string} organizationSlug - Organization slug - * @returns {Object} Headers object with X-Organization header - */ -export function buildOrgHeader(organizationSlug) { - return { 'X-Organization': organizationSlug }; -} - -/** - * Build API URL for project endpoint - * @param {string} projectSlug - Project slug - * @returns {string} API URL path - */ -export function buildProjectUrl(projectSlug) { - return `/api/project/${projectSlug}`; -} - -/** - * Build API URL for builds endpoint - * @param {string} projectSlug - Project slug - * @param {Object} options - Query options - * @returns {string} Full API URL path with query params - */ -export function buildBuildsUrl(projectSlug, options = {}) { - let queryString = buildBuildsQueryParams(options); - return `/api/build/${projectSlug}${queryString}`; -} - -/** - * Build API URL for project tokens endpoint - * @param {string} organizationSlug - Organization slug - * @param {string} projectSlug - Project slug - * @param {string} [tokenId] - Optional token ID for specific token operations - * @returns {string} API URL path - */ -export function buildTokensUrl(_organizationSlug, projectSlug, tokenId) { - let base = `/api/project/${projectSlug}/tokens`; - return tokenId ? `${base}/${tokenId}` : base; -} diff --git a/src/domains/projects/project-status.commands.js b/src/domains/projects/project-status.commands.js index abfe4a63..e83bacbf 100644 --- a/src/domains/projects/project-status.commands.js +++ b/src/domains/projects/project-status.commands.js @@ -1,7 +1,11 @@ import * as output from '../../shared/output.js'; +import { resolveCredential } from '../auth/credential.service.js'; import { credentialSourceLabel } from '../auth/credential-presentation.js'; import { loadConfig } from '../config/config-loader.js'; -import { resolveUploadDestination } from './upload-destination.service.js'; +import { + resolveUploadDestination, + uploadDestinationInfo, +} from './upload-destination.service.js'; function projectLabel(project) { return project @@ -23,13 +27,13 @@ export async function projectStatusCommand(_options = {}, globalOptions = {}) { apiUrl: config.apiUrl, configured: config.project || null, effective: null, - credential: config.apiKey ? { source: config.credentialSource } : null, + credential: resolveCredential(config, { allowNoToken: true }), status: 'unverified', matches: null, error: { code: error.code, message: error.message }, }; } - if (globalOptions.json) output.data(destination); + if (globalOptions.json) output.data(uploadDestinationInfo(destination)); else { output.header('project'); output.labelValue('API', destination.apiUrl); @@ -40,9 +44,17 @@ export async function projectStatusCommand(_options = {}, globalOptions = {}) { output.labelValue( 'Upload destination', destination.status === 'unverified' - ? 'unverified (unable to reach Vizzly)' + ? destination.effective + ? `${projectLabel(destination.effective)} (access checked on upload)` + : 'unverified (unable to reach Vizzly)' : projectLabel(destination.effective) ); + if (destination.credential?.source === 'user') { + output.labelValue( + 'Build permission', + destination.uploadPermission || 'unknown' + ); + } output.labelValue( 'Credential', destination.credential diff --git a/src/domains/projects/project.commands.js b/src/domains/projects/project.commands.js index ab5c13f1..27aa4c00 100644 --- a/src/domains/projects/project.commands.js +++ b/src/domains/projects/project.commands.js @@ -1,8 +1,5 @@ -import { preflightChanges } from '../../shared/file-changes.js'; import * as defaultOutput from '../../shared/output.js'; import { VizzlyError } from '../../shared/vizzly-error.js'; -import { createApiClient as defaultCreateApiClient } from '../auth/cloud-client.js'; -import { resolveCredential } from '../auth/credential.service.js'; import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; import { getApiUrl as defaultGetApiUrl } from '../config/environment.js'; import { @@ -13,10 +10,6 @@ import { planProjectBinding as defaultPlanProjectBinding, writeProjectBinding as defaultWriteProjectBinding, } from './project-binding.js'; -import { - getProjectLink as defaultGetProjectLink, - saveProjectLink as defaultSaveProjectLink, -} from './project-link-store.js'; import { pickProject } from './project-picker.service.js'; export async function projectLinkCommand( @@ -26,13 +19,10 @@ export async function projectLinkCommand( deps = {} ) { let { - createApiClient = defaultCreateApiClient, getApiUrl = defaultGetApiUrl, loadConfig = defaultLoadConfig, output = defaultOutput, - getProjectLink = defaultGetProjectLink, planProjectBinding = defaultPlanProjectBinding, - saveProjectLink = defaultSaveProjectLink, writeProjectBinding = defaultWriteProjectBinding, exit = code => process.exit(code), } = deps; @@ -49,6 +39,12 @@ export async function projectLinkCommand( ); try { + if (options.name || options.expiresAt) { + throw new VizzlyError( + 'Project linking saves an upload destination. Create automation tokens in the project API token settings.', + 'PROJECT_TOKEN_OPTIONS_UNSUPPORTED' + ); + } let config; let configError; try { @@ -93,6 +89,8 @@ export async function projectLinkCommand( ); if (errors.length) throw new VizzlyError(errors.join(' '), 'PROJECT_INPUT_REQUIRED'); + organizationSlug = organizationSlug.toLowerCase(); + projectSlug = projectSlug.toLowerCase(); let apiUrl = config?.apiUrl || getApiUrl(); let project = { organizationSlug, projectSlug }; let binding = await planProjectBinding({ @@ -126,101 +124,12 @@ export async function projectLinkCommand( if (configError) throw configError; - if (binding.after !== null) { - await preflightChanges([ - { - path: binding.configPath, - before: binding.before, - after: binding.after, - }, - ]); - } - - let linkedProject = - !options.name && !options.expiresAt - ? await getProjectLink({ apiUrl, ...project }) - : null; - let reused = Boolean(linkedProject); - let userToken = linkedProject - ? null - : config.userToken || globalOptions.token; - - if (!linkedProject && !userToken) { - output.error('Login required before linking a project'); - output.hint('Run "vizzly login" first, then try project link again'); - output.cleanup(); - exit(1); - return { linked: false, reason: 'auth_required' }; - } - - if (!linkedProject) { - output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`); - let credential = resolveCredential(config, { - ...globalOptions, - auth: globalOptions.token ? 'auto' : 'user', - command: 'project link', - }); - let client = createApiClient({ - baseUrl: apiUrl, - token: credential.token, - credential, - command: 'project-link', - }); - let response = await client.request( - `/api/cli/${projectSlug}/link-token`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Organization': organizationSlug, - }, - body: JSON.stringify({ - name: options.name, - expiresAt: options.expiresAt, - }), - } - ); - linkedProject = await saveProjectLink({ - apiUrl, - organizationSlug: response.organization?.slug || organizationSlug, - organizationName: response.organization?.name, - projectSlug: response.project?.slug || projectSlug, - projectName: response.project?.name, - token: response.token.token, - tokenId: response.token.id, - tokenPrefix: response.token.token_prefix, - expiresAt: response.token.expires_at, - createdAt: response.token.created_at, - }); - } - - if ( - linkedProject.organizationSlug !== organizationSlug || - linkedProject.projectSlug !== projectSlug - ) { - binding = await planProjectBinding({ - configPath: binding.configPath, - project: { - organizationSlug: linkedProject.organizationSlug, - projectSlug: linkedProject.projectSlug, - }, - apiUrl, - currentProject: config?.project, - currentApiUrl: config?._configApiUrl, - }); - if (binding.status === 'manual') throw new Error(binding.instructions); - } await writeProjectBinding(binding); - output.stopSpinner(); - let result = { linked: true, - organizationSlug: linkedProject.organizationSlug, - projectSlug: linkedProject.projectSlug, - tokenPrefix: linkedProject.tokenPrefix, - storage: linkedProject.storage, - reused, + organizationSlug, + projectSlug, binding: { status: 'bound', configPath: binding.configPath }, }; if (globalOptions.json) { @@ -229,20 +138,14 @@ export async function projectLinkCommand( return result; } - output.complete( - `Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}` - ); - output.hint( - `Repository project: ${linkedProject.organizationSlug}/${linkedProject.projectSlug}` - ); + output.complete(`Linked ${organizationSlug}/${projectSlug}`); output.hint( - 'Cloud uploads use this project credential. Browse other projects with --auth user.' + 'Uploads use your saved login or an explicitly configured API token.' ); - output.hint(`Credential storage: ${linkedProject.storage}`); + output.hint('Vizzly checks project access when you upload.'); output.cleanup(); return result; } catch (error) { - output.stopSpinner(); if (error.code === 'PROMPT_CANCELLED') { output.hint('Project selection cancelled; no files changed.'); output.cleanup(); diff --git a/src/domains/projects/project.normalizers.js b/src/domains/projects/project.normalizers.js deleted file mode 100644 index 4469c294..00000000 --- a/src/domains/projects/project.normalizers.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Extract projects from API response - * @param {Object} response - API response - * @returns {Array} Array of projects - */ -export function extractProjects(response) { - return response?.projects || []; -} - -/** - * Extract project from API response - * @param {Object} response - API response - * @returns {Object} Project object - */ -export function extractProject(response) { - return response?.project || response; -} - -/** - * Extract builds from API response - * @param {Object} response - API response - * @returns {Array} Array of builds - */ -export function extractBuilds(response) { - return response?.builds || []; -} - -/** - * Extract token from API response - * @param {Object} response - API response - * @returns {Object} Token object - */ -export function extractToken(response) { - return response?.token; -} - -/** - * Extract tokens from API response - * @param {Object} response - API response - * @returns {Array} Array of tokens - */ -export function extractTokens(response) { - return response?.tokens || []; -} - -/** - * Enrich projects with organization info - * @param {Array} projects - Array of projects - * @param {Object} org - Organization object - * @param {string} org.slug - Organization slug - * @param {string} org.name - Organization name - * @returns {Array} Projects with organization info added - */ -export function enrichProjectsWithOrg(projects, org) { - return projects.map(project => ({ - ...project, - organizationSlug: org.slug, - organizationName: org.name, - })); -} - -/** - * Extract organizations from whoami response - * @param {Object} whoamiResponse - Whoami API response - * @returns {Array} Array of organizations - */ -export function extractOrganizations(whoamiResponse) { - return whoamiResponse?.organizations || []; -} diff --git a/src/domains/projects/project.service.js b/src/domains/projects/project.service.js index b48b8d64..487dfea5 100644 --- a/src/domains/projects/project.service.js +++ b/src/domains/projects/project.service.js @@ -1,482 +1,67 @@ -import { createAuthClient } from '../auth/auth-client.js'; -import { getAuthTokens } from '../auth/token-store.js'; +import { createUserSession } from '../auth/auth.service.js'; +import { createApiClient } from '../auth/cloud-client.js'; +import { createTokenStore } from '../auth/token-store.js'; +import { getTddBaselines } from '../baselines/cloud-baselines.js'; +import { getBuilds } from '../builds/build.service.js'; import { getApiUrl } from '../config/environment.js'; -import { - enrichProjectsWithOrg, - extractBuilds, - extractOrganizations, - extractProject, - extractProjects, - extractToken, - extractTokens, -} from './project.normalizers.js'; -import { - buildNoApiServiceError, - buildNoAuthError, - buildProjectFetchError, - buildTokenCreateError, - buildTokenRevokeError, - buildTokensFetchError, -} from './project-errors.js'; -import { - buildBuildsUrl, - buildOrgHeader, - buildProjectUrl, - buildTokensUrl, -} from './project-requests.js'; +import { discoverProjects } from './discovery.service.js'; -/** - * Project Operations - Project operations with dependency injection - * - * Each operation takes its dependencies as parameters: - * - httpClient: for making API requests (OAuth or API token based) - * - * This makes them trivially testable without mocking modules. - */ - -/** - * Project Service - * Wraps project operations for use by the HTTP server - * - * Provides the interface expected by src/domains/projects/projects.routes.js: - * - listProjects() - Returns [] if not authenticated - * - getRecentBuilds(projectSlug, organizationSlug, options) - Returns [] if not authenticated - * - * Error handling: - * - API methods (listProjects, getRecentBuilds) return empty arrays when not authenticated - */ - -/** - * Create a project service instance - * @param {Object} [options] - * @param {string} [options.apiUrl] - API base URL (defaults to VIZZLY_API_URL or https://app.vizzly.dev) - * @param {Object} [options.httpClient] - Injectable HTTP client (for testing) - * @param {Function} [options.getAuthTokens] - Injectable token getter (for testing) - * @returns {Object} Project service - */ +/** Read project, build, and baseline data using the selected user session. */ export function createProjectService(options = {}) { let apiUrl = options.apiUrl || getApiUrl(); + let tokenStore = createTokenStore({ apiUrl }); + + function createSession() { + return createUserSession({ + baseUrl: apiUrl, + command: 'projects', + tokenStore, + }); + } - // Create HTTP client once at service creation (not per-request) - // Allow injection for testing - let httpClient = options.httpClient || createAuthClient({ baseUrl: apiUrl }); - - // Allow injection of getAuthTokens for testing - let tokenGetter = options.getAuthTokens || getAuthTokens; - - /** - * Create an OAuth client with current access token - * @returns {Promise} OAuth client or null if not authenticated - */ - async function createOAuthClient() { - let auth = await tokenGetter(); - if (!auth?.accessToken) { - return null; - } + function createClient(session = createSession()) { + return createApiClient({ + baseUrl: apiUrl, + allowNoToken: true, + command: 'reporter', + credential: { session }, + }); + } - // Wrap authenticatedRequest to auto-inject the access token - return { - authenticatedRequest: (endpoint, fetchOptions = {}) => - httpClient.authenticatedRequest( - endpoint, - auth.accessToken, - fetchOptions - ), - }; + async function hasSavedSession() { + return Boolean((await tokenStore.getTokens())?.accessToken); } return { - /** - * List all projects from API - * Returns empty array if not authenticated (projectOps handles null oauthClient) - * @returns {Promise} Array of projects, empty if not authenticated - */ + hasSavedSession, + async listProjects() { - let oauthClient = await createOAuthClient(); - // listProjects handles null oauthClient by returning [] - return listProjects({ oauthClient, apiClient: null }); + if (!(await hasSavedSession())) return []; + let page = await discoverProjects(createClient(), { + limit: 250, + offset: 0, + }); + return page.projects; }, - /** - * Get recent builds for a project - * Returns empty array if not authenticated (projectOps handles null oauthClient) - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @param {Object} options - Query options - * @returns {Promise} Array of builds, empty if not authenticated - */ async getRecentBuilds(projectSlug, organizationSlug, options = {}) { - let oauthClient = await createOAuthClient(); - // getRecentBuilds handles null oauthClient by returning [] - return getRecentBuilds({ - oauthClient, - apiClient: null, - projectSlug, - organizationSlug, - limit: options.limit, + if (!(await hasSavedSession())) return []; + let page = await getBuilds(createClient(), { + organization: organizationSlug, + project: projectSlug, + limit: options.limit ?? 20, + offset: 0, branch: options.branch, }); + return page.builds; }, - }; -} - -/** - * List all projects from API using OAuth authentication - * @param {Object} oauthClient - OAuth HTTP client with authenticatedRequest method - * @returns {Promise} Array of projects with organization info - */ -export async function listProjectsWithOAuth(oauthClient) { - // First get the user's organizations via whoami - let whoami = await oauthClient.authenticatedRequest('/api/auth/cli/whoami', { - method: 'GET', - }); - - let organizations = extractOrganizations(whoami); - if (organizations.length === 0) { - return []; - } - - // Fetch projects for each organization - let allProjects = []; - - for (let org of organizations) { - try { - let response = await oauthClient.authenticatedRequest('/api/project', { - method: 'GET', - headers: buildOrgHeader(org.slug), - }); - - let projects = extractProjects(response); - let enriched = enrichProjectsWithOrg(projects, org); - allProjects.push(...enriched); - } catch { - // Silently skip failed orgs - } - } - - return allProjects; -} - -/** - * List all projects from API using API token authentication - * @param {Object} apiClient - API HTTP client with request method - * @returns {Promise} Array of projects - */ -export async function listProjectsWithApiToken(apiClient) { - let response = await apiClient.request('/api/project', { - method: 'GET', - }); - return extractProjects(response); -} - -/** - * List all projects, trying OAuth first then falling back to API token - * @param {Object} options - Options - * @param {Object} [options.oauthClient] - OAuth HTTP client - * @param {Object} [options.apiClient] - API token HTTP client - * @returns {Promise} Array of projects - */ -export async function listProjects({ oauthClient, apiClient }) { - // Try OAuth-based request first - if (oauthClient) { - try { - return await listProjectsWithOAuth(oauthClient); - } catch { - // Fall back to API token - } - } - - // Fall back to API token-based request - if (apiClient) { - try { - return await listProjectsWithApiToken(apiClient); - } catch { - return []; - } - } - - // No authentication available - return []; -} - -/** - * Get project details using OAuth authentication - * @param {Object} oauthClient - OAuth HTTP client - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @returns {Promise} Project details - */ -export async function getProjectWithOAuth( - oauthClient, - projectSlug, - organizationSlug -) { - let response = await oauthClient.authenticatedRequest( - buildProjectUrl(projectSlug), - { - method: 'GET', - headers: buildOrgHeader(organizationSlug), - } - ); - return extractProject(response); -} - -/** - * Get project details using API token authentication - * @param {Object} apiClient - API HTTP client - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @returns {Promise} Project details - */ -export async function getProjectWithApiToken( - apiClient, - projectSlug, - organizationSlug -) { - let response = await apiClient.request(buildProjectUrl(projectSlug), { - method: 'GET', - headers: buildOrgHeader(organizationSlug), - }); - return extractProject(response); -} - -/** - * Get project details, trying OAuth first then falling back to API token - * @param {Object} options - Options - * @param {Object} [options.oauthClient] - OAuth HTTP client - * @param {Object} [options.apiClient] - API token HTTP client - * @param {string} options.projectSlug - Project slug - * @param {string} options.organizationSlug - Organization slug - * @returns {Promise} Project details - */ -export async function getProject({ - oauthClient, - apiClient, - projectSlug, - organizationSlug, -}) { - // Try OAuth-based request first - if (oauthClient) { - try { - return await getProjectWithOAuth( - oauthClient, - projectSlug, - organizationSlug - ); - } catch { - // Fall back to API token - } - } - - // Fall back to API token - if (apiClient) { - try { - return await getProjectWithApiToken( - apiClient, - projectSlug, - organizationSlug - ); - } catch (error) { - throw buildProjectFetchError(error); - } - } - - throw buildNoAuthError(); -} - -/** - * Get recent builds for a project using OAuth authentication - * @param {Object} oauthClient - OAuth HTTP client - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @param {Object} options - Query options - * @returns {Promise} Array of builds - */ -export async function getRecentBuildsWithOAuth( - oauthClient, - projectSlug, - organizationSlug, - options = {} -) { - let response = await oauthClient.authenticatedRequest( - buildBuildsUrl(projectSlug, options), - { - method: 'GET', - headers: buildOrgHeader(organizationSlug), - } - ); - return extractBuilds(response); -} - -/** - * Get recent builds for a project using API token authentication - * @param {Object} apiClient - API HTTP client - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @param {Object} options - Query options - * @returns {Promise} Array of builds - */ -export async function getRecentBuildsWithApiToken( - apiClient, - projectSlug, - organizationSlug, - options = {} -) { - let response = await apiClient.request(buildBuildsUrl(projectSlug, options), { - method: 'GET', - headers: buildOrgHeader(organizationSlug), - }); - return extractBuilds(response); -} -/** - * Get recent builds for a project, trying OAuth first then falling back to API token - * @param {Object} options - Options - * @param {Object} [options.oauthClient] - OAuth HTTP client - * @param {Object} [options.apiClient] - API token HTTP client - * @param {string} options.projectSlug - Project slug - * @param {string} options.organizationSlug - Organization slug - * @param {number} [options.limit] - Number of builds to fetch - * @param {string} [options.branch] - Filter by branch - * @returns {Promise} Array of builds - */ -export async function getRecentBuilds({ - oauthClient, - apiClient, - projectSlug, - organizationSlug, - limit, - branch, -}) { - let queryOptions = { limit, branch }; - - // Try OAuth-based request first - if (oauthClient) { - try { - return await getRecentBuildsWithOAuth( - oauthClient, - projectSlug, - organizationSlug, - queryOptions - ); - } catch { - // Fall back to API token - } - } - - // Fall back to API token-based request - if (apiClient) { - try { - return await getRecentBuildsWithApiToken( - apiClient, - projectSlug, - organizationSlug, - queryOptions - ); - } catch { - return []; - } - } - - // No authentication available - return []; -} - -/** - * Create a project token - * @param {Object} apiClient - API HTTP client with request method - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @param {Object} tokenData - Token data - * @param {string} tokenData.name - Token name - * @param {string} [tokenData.description] - Token description - * @returns {Promise} Created token - */ -export async function createProjectToken( - apiClient, - projectSlug, - organizationSlug, - tokenData -) { - if (!apiClient) { - throw buildNoApiServiceError(); - } - - try { - let response = await apiClient.request( - buildTokensUrl(organizationSlug, projectSlug), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...buildOrgHeader(organizationSlug), - }, - body: JSON.stringify(tokenData), - } - ); - return extractToken(response); - } catch (error) { - throw buildTokenCreateError(error); - } -} - -/** - * List project tokens - * @param {Object} apiClient - API HTTP client with request method - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @returns {Promise} Array of tokens - */ -export async function listProjectTokens( - apiClient, - projectSlug, - organizationSlug -) { - if (!apiClient) { - throw buildNoApiServiceError(); - } - - try { - let response = await apiClient.request( - buildTokensUrl(organizationSlug, projectSlug), - { - method: 'GET', - headers: buildOrgHeader(organizationSlug), - } - ); - return extractTokens(response); - } catch (error) { - throw buildTokensFetchError(error); - } -} - -/** - * Revoke a project token - * @param {Object} apiClient - API HTTP client with request method - * @param {string} projectSlug - Project slug - * @param {string} organizationSlug - Organization slug - * @param {string} tokenId - Token ID to revoke - * @returns {Promise} - */ -export async function revokeProjectToken( - apiClient, - projectSlug, - organizationSlug, - tokenId -) { - if (!apiClient) { - throw buildNoApiServiceError(); - } - - try { - await apiClient.request( - buildTokensUrl(organizationSlug, projectSlug, tokenId), - { - method: 'DELETE', - headers: buildOrgHeader(organizationSlug), + async getTddBaselines(buildId) { + let session = createSession(); + if (!(await hasSavedSession())) { + throw session.loginRequired(); } - ); - } catch (error) { - throw buildTokenRevokeError(error); - } + return getTddBaselines(createClient(session), buildId); + }, + }; } diff --git a/src/domains/projects/project.service.test.js b/src/domains/projects/project.service.test.js deleted file mode 100644 index a4c16ee1..00000000 --- a/src/domains/projects/project.service.test.js +++ /dev/null @@ -1,501 +0,0 @@ -import assert from 'node:assert'; -import { describe, it } from 'node:test'; -import { - createProjectToken, - getProject, - getProjectWithApiToken, - getProjectWithOAuth, - getRecentBuilds, - getRecentBuildsWithApiToken, - getRecentBuildsWithOAuth, - listProjects, - listProjectsWithApiToken, - listProjectsWithOAuth, - listProjectTokens, - revokeProjectToken, -} from './project.service.js'; - -describe('project/operations', () => { - describe('listProjectsWithOAuth', () => { - it('fetches projects for all organizations', async () => { - let client = createMockOAuthClient({ - '/api/auth/cli/whoami': { - organizations: [ - { slug: 'org1', name: 'Org 1' }, - { slug: 'org2', name: 'Org 2' }, - ], - }, - '/api/project': { projects: [{ id: 'p1', name: 'Project 1' }] }, - }); - - let result = await listProjectsWithOAuth(client); - - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].organizationSlug, 'org1'); - }); - - it('returns empty array when no organizations', async () => { - let client = createMockOAuthClient({ - '/api/auth/cli/whoami': { organizations: [] }, - }); - - let result = await listProjectsWithOAuth(client); - - assert.deepStrictEqual(result, []); - }); - - it('silently skips failed org requests and returns successful ones', async () => { - let client = { - authenticatedRequest: async (endpoint, options) => { - if (endpoint === '/api/auth/cli/whoami') { - return { - organizations: [ - { slug: 'failing-org', name: 'Failing Org' }, - { slug: 'successful-org', name: 'Successful Org' }, - ], - }; - } - // Fail requests for 'failing-org', succeed for 'successful-org' - if (options?.headers?.['X-Organization'] === 'failing-org') { - throw new Error('Network error for failing-org'); - } - return { - projects: [{ id: 'p1', name: 'Project from successful org' }], - }; - }, - }; - - let result = await listProjectsWithOAuth(client); - - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].organizationSlug, 'successful-org'); - assert.strictEqual(result[0].name, 'Project from successful org'); - }); - }); - - describe('listProjectsWithApiToken', () => { - it('fetches projects using API client', async () => { - let client = createMockApiClient({ - '/api/project': { projects: [{ id: 'p1' }, { id: 'p2' }] }, - }); - - let result = await listProjectsWithApiToken(client); - - assert.strictEqual(result.length, 2); - }); - }); - - describe('listProjects', () => { - it('uses OAuth first when available', async () => { - let oauthCalled = false; - let apiCalled = false; - - let oauthClient = { - authenticatedRequest: async () => { - oauthCalled = true; - return { organizations: [] }; - }, - }; - - let apiClient = { - request: async () => { - apiCalled = true; - return { projects: [] }; - }, - }; - - await listProjects({ oauthClient, apiClient }); - - assert.strictEqual(oauthCalled, true); - assert.strictEqual(apiCalled, false); - }); - - it('falls back to API token when OAuth fails', async () => { - let apiCalled = false; - - let oauthClient = { - authenticatedRequest: async () => { - throw new Error('OAuth failed'); - }, - }; - - let apiClient = { - request: async () => { - apiCalled = true; - return { projects: [{ id: 'p1' }] }; - }, - }; - - let result = await listProjects({ oauthClient, apiClient }); - - assert.strictEqual(apiCalled, true); - assert.strictEqual(result.length, 1); - }); - - it('returns empty array when API token fails', async () => { - let apiClient = { - request: async () => { - throw new Error('API failed'); - }, - }; - - let result = await listProjects({ apiClient }); - - assert.deepStrictEqual(result, []); - }); - - it('returns empty array when no clients available', async () => { - let result = await listProjects({}); - - assert.deepStrictEqual(result, []); - }); - }); - - describe('getProjectWithOAuth', () => { - it('fetches project with org header', async () => { - let capturedHeaders = null; - let client = { - authenticatedRequest: async (_endpoint, options) => { - capturedHeaders = options.headers; - return { project: { id: 'p1', name: 'Project' } }; - }, - }; - - let result = await getProjectWithOAuth(client, 'my-project', 'my-org'); - - assert.deepStrictEqual(capturedHeaders, { 'X-Organization': 'my-org' }); - assert.strictEqual(result.id, 'p1'); - }); - }); - - describe('getProjectWithApiToken', () => { - it('fetches project using API client', async () => { - let client = createMockApiClient({ - '/api/project/my-project': { - project: { id: 'p1', slug: 'my-project' }, - }, - }); - - let result = await getProjectWithApiToken(client, 'my-project', 'my-org'); - - assert.strictEqual(result.id, 'p1'); - }); - }); - - describe('getProject', () => { - it('uses OAuth first when available', async () => { - let oauthClient = { - authenticatedRequest: async () => ({ - project: { id: 'p1', name: 'OAuth Project' }, - }), - }; - - let result = await getProject({ - oauthClient, - projectSlug: 'proj', - organizationSlug: 'org', - }); - - assert.strictEqual(result.name, 'OAuth Project'); - }); - - it('falls back to API token when OAuth fails', async () => { - let oauthClient = { - authenticatedRequest: async () => { - throw new Error('OAuth failed'); - }, - }; - - let apiClient = { - request: async () => ({ - project: { id: 'p1', name: 'API Project' }, - }), - }; - - let result = await getProject({ - oauthClient, - apiClient, - projectSlug: 'proj', - organizationSlug: 'org', - }); - - assert.strictEqual(result.name, 'API Project'); - }); - - it('throws wrapped error when API token fails', async () => { - let apiClient = { - request: async () => { - throw new Error('API failed'); - }, - }; - - await assert.rejects( - () => - getProject({ - apiClient, - projectSlug: 'proj', - organizationSlug: 'org', - }), - { code: 'PROJECT_FETCH_FAILED' } - ); - }); - - it('throws no auth error when no clients available', async () => { - await assert.rejects( - () => - getProject({ - projectSlug: 'proj', - organizationSlug: 'org', - }), - { code: 'NO_AUTH_SERVICE' } - ); - }); - }); - - describe('getRecentBuildsWithOAuth', () => { - it('fetches builds with org header', async () => { - let capturedEndpoint = null; - let client = { - authenticatedRequest: async endpoint => { - capturedEndpoint = endpoint; - return { builds: [{ id: 'b1' }, { id: 'b2' }] }; - }, - }; - - let result = await getRecentBuildsWithOAuth( - client, - 'my-project', - 'my-org', - { limit: 5 } - ); - - assert.ok(capturedEndpoint.includes('limit=5')); - assert.strictEqual(result.length, 2); - }); - }); - - describe('getRecentBuildsWithApiToken', () => { - it('fetches builds using API client', async () => { - let client = { - request: async () => ({ - builds: [{ id: 'b1' }], - }), - }; - - let result = await getRecentBuildsWithApiToken( - client, - 'my-project', - 'my-org' - ); - - assert.strictEqual(result.length, 1); - }); - }); - - describe('getRecentBuilds', () => { - it('uses OAuth first when available', async () => { - let oauthClient = { - authenticatedRequest: async () => ({ - builds: [{ id: 'oauth-build' }], - }), - }; - - let result = await getRecentBuilds({ - oauthClient, - projectSlug: 'proj', - organizationSlug: 'org', - }); - - assert.strictEqual(result[0].id, 'oauth-build'); - }); - - it('falls back to API token when OAuth fails', async () => { - let oauthClient = { - authenticatedRequest: async () => { - throw new Error('OAuth failed'); - }, - }; - - let apiClient = { - request: async () => ({ - builds: [{ id: 'api-build' }], - }), - }; - - let result = await getRecentBuilds({ - oauthClient, - apiClient, - projectSlug: 'proj', - organizationSlug: 'org', - }); - - assert.strictEqual(result[0].id, 'api-build'); - }); - - it('returns empty array when API token fails', async () => { - let apiClient = { - request: async () => { - throw new Error('API failed'); - }, - }; - - let result = await getRecentBuilds({ - apiClient, - projectSlug: 'proj', - organizationSlug: 'org', - }); - - assert.deepStrictEqual(result, []); - }); - - it('returns empty array when no clients available', async () => { - let result = await getRecentBuilds({ - projectSlug: 'proj', - organizationSlug: 'org', - }); - - assert.deepStrictEqual(result, []); - }); - }); - - describe('createProjectToken', () => { - it('creates token and returns extracted result', async () => { - let capturedBody = null; - let client = { - request: async (_endpoint, options) => { - capturedBody = JSON.parse(options.body); - return { token: { id: 't1', value: 'vzt_xxx' } }; - }, - }; - - let result = await createProjectToken(client, 'proj', 'org', { - name: 'My Token', - description: 'Test token', - }); - - assert.deepStrictEqual(capturedBody, { - name: 'My Token', - description: 'Test token', - }); - assert.strictEqual(result.id, 't1'); - }); - - it('throws when no API client', async () => { - await assert.rejects( - () => createProjectToken(null, 'proj', 'org', { name: 'tok' }), - { code: 'NO_API_SERVICE' } - ); - }); - - it('throws wrapped error on failure', async () => { - let client = { - request: async () => { - throw new Error('Create failed'); - }, - }; - - await assert.rejects( - () => createProjectToken(client, 'proj', 'org', { name: 'tok' }), - { code: 'TOKEN_CREATE_FAILED' } - ); - }); - }); - - describe('listProjectTokens', () => { - it('lists tokens for project', async () => { - let client = { - request: async () => ({ - tokens: [{ id: 't1' }, { id: 't2' }], - }), - }; - - let result = await listProjectTokens(client, 'proj', 'org'); - - assert.strictEqual(result.length, 2); - }); - - it('throws when no API client', async () => { - await assert.rejects(() => listProjectTokens(null, 'proj', 'org'), { - code: 'NO_API_SERVICE', - }); - }); - - it('throws wrapped error on failure', async () => { - let client = { - request: async () => { - throw new Error('Fetch failed'); - }, - }; - - await assert.rejects(() => listProjectTokens(client, 'proj', 'org'), { - code: 'TOKENS_FETCH_FAILED', - }); - }); - }); - - describe('revokeProjectToken', () => { - it('revokes token by ID', async () => { - let capturedEndpoint = null; - let capturedMethod = null; - let client = { - request: async (endpoint, options) => { - capturedEndpoint = endpoint; - capturedMethod = options.method; - }, - }; - - await revokeProjectToken(client, 'proj', 'org', 'tok_123'); - - assert.ok(capturedEndpoint.includes('tok_123')); - assert.strictEqual(capturedMethod, 'DELETE'); - }); - - it('throws when no API client', async () => { - await assert.rejects( - () => revokeProjectToken(null, 'proj', 'org', 'tok_123'), - { code: 'NO_API_SERVICE' } - ); - }); - - it('throws wrapped error on failure', async () => { - let client = { - request: async () => { - throw new Error('Revoke failed'); - }, - }; - - await assert.rejects( - () => revokeProjectToken(client, 'proj', 'org', 'tok_123'), - { code: 'TOKEN_REVOKE_FAILED' } - ); - }); - }); -}); - -// Test helpers - -function createMockOAuthClient(responses) { - return { - authenticatedRequest: async (endpoint, _options = {}) => { - for (let key of Object.keys(responses)) { - if (endpoint.includes(key)) { - return responses[key]; - } - } - throw new Error(`No mock response for ${endpoint}`); - }, - }; -} - -function createMockApiClient(responses) { - return { - request: async (endpoint, _options = {}) => { - for (let key of Object.keys(responses)) { - if (endpoint.includes(key)) { - return responses[key]; - } - } - throw new Error(`No mock response for ${endpoint}`); - }, - }; -} diff --git a/src/domains/projects/projects.commands.js b/src/domains/projects/projects.commands.js index a9e78021..f17c1589 100644 --- a/src/domains/projects/projects.commands.js +++ b/src/domains/projects/projects.commands.js @@ -7,6 +7,7 @@ import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; import { getApiUrl as defaultGetApiUrl } from '../config/environment.js'; import { serializeProject } from './discovery.serializers.js'; import { discoverProjects } from './discovery.service.js'; +import { showProjectTable } from './project-presentation.js'; /** * Projects command - List projects the user has access to @@ -68,10 +69,9 @@ export async function projectsCommand( }); } else { output.header('projects'); - showCredential(output, credential, 'projects'); - - let colors = output.getColors(); + showCredential(output, credential); + output.blank(); if (projects.length === 0) { output.print(' No projects found'); if (options.org) { @@ -84,13 +84,7 @@ export async function projectsCommand( ); output.blank(); - for (let project of projects) { - output.print( - ` ${colors.bold(project.name)} ${colors.dim(`@${project.organizationSlug}/${project.slug}`)}` - ); - if (project.buildCount != null) - output.print(` ${colors.dim(`${project.buildCount} builds`)}`); - } + showProjectTable(output, projects); if (pagination.hasMore) { output.blank(); @@ -101,12 +95,18 @@ export async function projectsCommand( ); } } - output.hint( - 'Read builds: vizzly builds --project --org ' - ); - output.hint( - 'Choose an upload destination: vizzly project link /' - ); + if (projects.length > 0) { + output.blank(); + let project = projects.length === 1 ? projects[0] : null; + let selector = project + ? `${project.organizationSlug}/${project.slug}` + : '/'; + let buildSelector = project + ? `--project ${project.slug} --org ${project.organizationSlug}` + : '--project --org '; + output.hint(`Read builds: vizzly builds ${buildSelector}`); + output.hint(`Choose uploads: vizzly project link ${selector}`); + } } output.cleanup(); diff --git a/src/domains/projects/upload-destination.service.js b/src/domains/projects/upload-destination.service.js index 8b5fdffb..584b69e5 100644 --- a/src/domains/projects/upload-destination.service.js +++ b/src/domains/projects/upload-destination.service.js @@ -1,8 +1,28 @@ import { VizzlyError } from '../../shared/vizzly-error.js'; import { createApiClient } from '../auth/cloud-client.js'; -import { credentialInfo, selectCredential } from '../auth/credential.rules.js'; +import { + credentialInfo, + requireCredentialScope, +} from '../auth/credential.rules.js'; +import { resolveCredential } from '../auth/credential.service.js'; -/** Resolve the effective upload token, independently of the saved user login. */ +export function uploadDestinationInfo(destination) { + if (!destination) return destination; + return { + ...destination, + credential: destination.credential + ? credentialInfo(destination.credential) + : null, + }; +} + +function uploadPermission(credential) { + let scopes = credential.authorization?.scopes; + if (!Array.isArray(scopes)) return 'unknown'; + return scopes.includes('builds:write') ? 'granted' : 'missing'; +} + +/** Resolve identity separately from the checkout's intended upload project. */ export async function resolveUploadDestination(config) { let result = { apiUrl: config.apiUrl, @@ -11,10 +31,17 @@ export async function resolveUploadDestination(config) { credential: null, status: 'unconfigured', matches: null, + projectQuery: {}, + uploadPermission: 'unknown', }; - if (!config.apiKey) return result; - let credential = selectCredential(config, { auth: 'token' }); - result.credential = credentialInfo(credential); + let credential = resolveCredential(config, { + allowNoToken: true, + command: 'upload', + }); + if (!credential.token) return result; + result.credential = credential; + result.uploadPermission = uploadPermission(credential); + if (credential.source === 'user' && !config.project) return result; result.status = 'unverified'; let client = createApiClient({ baseUrl: config.apiUrl, @@ -22,6 +49,22 @@ export async function resolveUploadDestination(config) { credential, command: 'project-status', }); + if (credential.source === 'user') { + let identity = await client.request('/api/auth/cli/whoami'); + result.credential = { + ...credential, + token: client.getToken(), + authorization: identity.authorization || credential.authorization, + }; + result.effective = config.project; + result.matches = true; + result.projectQuery = { + project: config.project.projectSlug, + organization: config.project.organizationSlug, + }; + result.uploadPermission = uploadPermission(result.credential); + return result; + } let context = await client.request('/api/sdk/token/context'); if (!context.organization?.slug || !context.project?.slug) { throw new VizzlyError( @@ -34,6 +77,7 @@ export async function resolveUploadDestination(config) { projectSlug: context.project.slug, }; result.status = 'verified'; + result.uploadPermission = 'granted'; result.matches = !result.configured || (result.configured.organizationSlug === result.effective.organizationSlug && @@ -43,12 +87,13 @@ export async function resolveUploadDestination(config) { export async function requireUploadDestination(config) { let destination = await resolveUploadDestination(config); - if (destination.status !== 'verified') { + if (!destination.effective) { throw new VizzlyError( - 'Choose an upload destination with vizzly project link or set VIZZLY_TOKEN.', + 'Log in and choose an upload destination with vizzly project link /, or set VIZZLY_TOKEN.', 'UPLOAD_PROJECT_REQUIRED' ); } + requireCredentialScope(destination.credential, 'builds:write'); if (!destination.matches) { throw new VizzlyError( `Upload token targets ${destination.effective.organizationSlug}/${destination.effective.projectSlug}, but this checkout is configured for ${destination.configured.organizationSlug}/${destination.configured.projectSlug}. Remove the token override or link the intended project before uploading.`, diff --git a/src/domains/runs/run-environment.js b/src/domains/runs/run-environment.js index b0c6bac7..963101de 100644 --- a/src/domains/runs/run-environment.js +++ b/src/domains/runs/run-environment.js @@ -50,6 +50,7 @@ export function buildClientOptions(config) { return { baseUrl: config.apiUrl, token: config.apiKey, + ...(config.credential ? { credential: config.credential } : {}), command: 'run', }; } diff --git a/src/domains/runs/run.commands.js b/src/domains/runs/run.commands.js index 4c736492..46421caa 100644 --- a/src/domains/runs/run.commands.js +++ b/src/domains/runs/run.commands.js @@ -12,6 +12,7 @@ import { import * as defaultOutput from '../../shared/output.js'; import { VizzlyError } from '../../shared/vizzly-error.js'; import { createApiClient as defaultCreateApiClient } from '../auth/cloud-client.js'; +import { scopeErrorDetails } from '../auth/credential-error.serializers.js'; import { credentialSourceLabel } from '../auth/credential-presentation.js'; import { createBuild as defaultCreateApiBuild, @@ -22,7 +23,10 @@ import { createBuildObject as defaultCreateBuildObject } from '../builds/build-i import { CONFIG_DEFAULTS } from '../config/config-defaults.js'; import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; import { getTokenContext as defaultGetTokenContext } from '../context/context.service.js'; -import { requireUploadDestination } from '../projects/upload-destination.service.js'; +import { + requireUploadDestination, + uploadDestinationInfo, +} from '../projects/upload-destination.service.js'; import { createUploader as defaultCreateUploader } from '../uploads/upload.service.js'; import { finalizeBuild as defaultFinalizeBuild, @@ -68,6 +72,7 @@ export async function resolveBuildDisplayUrl({ let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, + credential: config.credential, command: 'run', }); let tokenContext = await getTokenContext(client); @@ -237,17 +242,23 @@ export async function runCommand( } // Validate API token (unless --allow-no-token is set) - if (!config.apiKey && !config.allowNoToken) { + if (!config.apiKey && !config.userToken && !config.allowNoToken) { output.error( - 'API token required. Use --token, set VIZZLY_TOKEN environment variable, or use --allow-no-token to run without uploading' + 'Login or API token required. Run vizzly login, use --token, set VIZZLY_TOKEN, or use --allow-no-token to run without uploading.' ); exit(1); return { success: false, reason: 'no-api-key' }; } - if (config.apiKey) { - config.uploadDestination = await requireUploadDestination(config); - let destination = config.uploadDestination; + if (config.apiKey || config.userToken) { + let destination = await requireUploadDestination(config); + config = { + ...config, + apiKey: destination.credential.token, + credential: destination.credential, + projectQuery: destination.projectQuery, + uploadDestination: destination, + }; output.info( `Upload destination: ${destination.effective.organizationSlug}/${destination.effective.projectSlug} (${credentialSourceLabel(destination.credential.source)})` ); @@ -430,7 +441,7 @@ export async function runCommand( }); let jsonResult = { - destination: config.uploadDestination, + destination: uploadDestinationInfo(config.uploadDestination), buildId: result.buildId, status: 'completed', url: displayUrl, @@ -512,6 +523,7 @@ export async function runCommand( error: { code: error.code, message: error.message, + ...scopeErrorDetails(error), }, executionTimeMs, git: { branch, commit, message }, @@ -535,6 +547,7 @@ export async function runCommand( message: error.getUserMessage ? error.getUserMessage() : error.message, + ...scopeErrorDetails(error), }, executionTimeMs, git: { branch, commit, message }, @@ -583,7 +596,7 @@ export async function runCommand( } let jsonResult = { - destination: config.uploadDestination, + destination: uploadDestinationInfo(config.uploadDestination), buildId: result.buildId, status: buildResult.status, url: displayUrl, @@ -641,6 +654,7 @@ export async function runCommand( let errorDetails = { code: error.code || 'VIZZLY_STATUS_UNAVAILABLE', message: error.getUserMessage ? error.getUserMessage() : error.message, + ...scopeErrorDetails(error), }; let unavailableResult = { buildId: result.buildId || null, diff --git a/src/domains/runs/run.service.js b/src/domains/runs/run.service.js index ec19a56f..787b96f9 100644 --- a/src/domains/runs/run.service.js +++ b/src/domains/runs/run.service.js @@ -1,3 +1,4 @@ +import { getAppBaseUrl } from '../../shared/api-url.js'; import { normalizeSetBaseline } from './run.normalizers.js'; import { hasApiKey, @@ -65,7 +66,7 @@ export async function createBuild({ runOptions, tdd, config, deps }) { let client = createApiClient(clientOptions); let payload = buildApiBuildPayload(runOptions, config._comparisonOverrides); - let buildResult = await createApiBuild(client, payload); + let buildResult = await createApiBuild(client, payload, config.projectQuery); if (!buildResult?.id) { throw new Error('Vizzly API did not return a build ID'); @@ -90,6 +91,14 @@ export async function createBuild({ runOptions, tdd, config, deps }) { export async function fetchBuildUrl({ buildId, config, deps }) { let { createApiClient, getBuild, output } = deps; + if ( + config.credential?.kind === 'user' && + config.uploadDestination?.effective + ) { + let { organizationSlug, projectSlug } = config.uploadDestination.effective; + return `${getAppBaseUrl(config.apiUrl)}/${organizationSlug}/${projectSlug}/builds/${buildId}`; + } + let clientOptions = buildClientOptions(config); if (!clientOptions) { return null; diff --git a/src/domains/screenshots/cloud-screenshots.js b/src/domains/screenshots/cloud-screenshots.js index b2b6f360..ea4ebdbe 100644 --- a/src/domains/screenshots/cloud-screenshots.js +++ b/src/domains/screenshots/cloud-screenshots.js @@ -1,3 +1,4 @@ +import { buildEndpointWithParams } from '../../shared/cloud-protocol.js'; import * as output from '../../shared/output.js'; import { buildScreenshotPayload, @@ -13,14 +14,22 @@ import { computeSha256 } from './screenshot-hash.js'; * @param {string} buildId - Build ID for screenshot record creation * @returns {Promise} { existing, missing, screenshots } */ -export async function checkShas(client, screenshots, buildId) { +export async function checkShas( + client, + screenshots, + buildId, + projectQuery = {} +) { try { let payload = buildShaCheckPayload(screenshots, buildId); - return await client.request('/api/sdk/check-shas', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); + return await client.request( + buildEndpointWithParams('/api/sdk/check-shas', projectQuery), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } + ); } catch (error) { // Continue without deduplication on error output.debug('sha-check', 'failed, continuing without deduplication', { diff --git a/src/domains/uploads/upload.commands.js b/src/domains/uploads/upload.commands.js index f436d2a7..85e1257d 100644 --- a/src/domains/uploads/upload.commands.js +++ b/src/domains/uploads/upload.commands.js @@ -9,12 +9,16 @@ import { } from '../../shared/git.js'; import * as defaultOutput from '../../shared/output.js'; import { createApiClient as defaultCreateApiClient } from '../auth/cloud-client.js'; +import { scopeErrorDetails } from '../auth/credential-error.serializers.js'; import { credentialSourceLabel } from '../auth/credential-presentation.js'; import { finalizeBuild as defaultFinalizeBuild } from '../builds/build.service.js'; import { CONFIG_DEFAULTS } from '../config/config-defaults.js'; import { loadConfig as defaultLoadConfig } from '../config/config-loader.js'; import { getTokenContext as defaultGetTokenContext } from '../context/context.service.js'; -import { requireUploadDestination } from '../projects/upload-destination.service.js'; +import { + requireUploadDestination, + uploadDestinationInfo, +} from '../projects/upload-destination.service.js'; import { writeSession as defaultWriteSession } from '../runs/session-store.js'; import { createUploader as defaultCreateUploader } from './upload.service.js'; @@ -111,16 +115,22 @@ export async function uploadCommand( config = await loadConfig(globalOptions.config, allOptions); // Validate API token - if (!config.apiKey) { + if (!config.apiKey && !config.userToken) { output.error( - 'API token required. Use --token or set VIZZLY_TOKEN environment variable' + 'Login or API token required. Run vizzly login, use --token, or set VIZZLY_TOKEN.' ); exit(1); return { success: false, reason: 'no-api-key' }; } destination = await requireUploadDestination(config); - config.uploadDestination = destination; + config = { + ...config, + apiKey: destination.credential.token, + credential: destination.credential, + projectQuery: destination.projectQuery, + uploadDestination: destination, + }; output.info( `Upload destination: ${destination.effective.organizationSlug}/${destination.effective.projectSlug} (${credentialSourceLabel(destination.credential.source)})` ); @@ -220,6 +230,7 @@ export async function uploadCommand( let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, + credential: config.credential, command: 'upload', }); let executionTime = Date.now() - uploadStartTime; @@ -244,7 +255,7 @@ export async function uploadCommand( : null); output.data({ - destination, + destination: uploadDestinationInfo(destination), buildId: result.buildId, url: buildUrl, stats: { @@ -307,7 +318,7 @@ export async function uploadCommand( )); output.data({ - destination, + destination: uploadDestinationInfo(destination), buildId: result.buildId, status: buildResult.failedComparisons > 0 ? 'failed' : 'completed', url: waitBuildUrl, @@ -377,7 +388,7 @@ export async function uploadCommand( if (status >= 500) { if (globalOptions.json) { output.data({ - destination, + destination: uploadDestinationInfo(destination), buildId: null, status: 'skipped', message: 'Vizzly API unavailable - upload skipped', @@ -398,6 +409,7 @@ export async function uploadCommand( let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, + credential: config.credential, command: 'upload', }); let executionTime = Date.now() - uploadStartTime; @@ -410,7 +422,7 @@ export async function uploadCommand( // JSON output for errors if (globalOptions.json) { output.data({ - destination, + destination: uploadDestinationInfo(destination), buildId: buildId || null, status: 'failed', error: { @@ -418,6 +430,7 @@ export async function uploadCommand( message: error?.getUserMessage ? error.getUserMessage() : error.message, + ...scopeErrorDetails(error), }, executionTimeMs: Date.now() - uploadStartTime, }); diff --git a/src/domains/uploads/upload.service.js b/src/domains/uploads/upload.service.js index de58b886..604d07eb 100644 --- a/src/domains/uploads/upload.service.js +++ b/src/domains/uploads/upload.service.js @@ -1,5 +1,6 @@ import { readFile, stat } from 'node:fs/promises'; import { glob } from 'glob'; +import { buildEndpointWithParams } from '../../shared/cloud-protocol.js'; import { getDefaultBranch } from '../../shared/git.js'; import * as output from '../../shared/output.js'; import { @@ -48,13 +49,22 @@ import { getElapsedTime, isTimedOut } from './upload-timing.js'; */ export function createUploader( - { apiKey, apiUrl, userAgent, command, upload: uploadConfig = {} } = {}, + { + apiKey, + apiUrl, + credential, + projectQuery, + userAgent, + command, + upload: uploadConfig = {}, + } = {}, options = {} ) { let signal = options.signal || new AbortController().signal; let client = createApiClient({ baseUrl: apiUrl, token: apiKey, + credential, command: command || 'upload', sdkUserAgent: userAgent, allowNoToken: true, @@ -86,7 +96,7 @@ export function createUploader( async function uploadScreenshots(uploadOptions) { return upload({ uploadOptions, - config: { apiKey, apiUrl }, + config: { apiKey, apiUrl, projectQuery }, signal, batchSize, deps: { @@ -209,6 +219,7 @@ export async function checkExistingFiles({ client, signal, buildId, + projectQuery, deps, }) { let { checkShas, createError, output } = deps; @@ -224,7 +235,7 @@ export async function checkExistingFiles({ let screenshotBatch = batch.map(fileToScreenshotFormat); try { - let res = await checkShas(client, screenshotBatch, buildId); + let res = await checkShas(client, screenshotBatch, buildId, projectQuery); let { existing = [], screenshots = [] } = res || {}; for (let sha of existing) { @@ -265,6 +276,7 @@ export async function checkExistingFiles({ export async function uploadFiles({ toUpload, buildId, + projectQuery, client, signal, batchSize, @@ -294,12 +306,15 @@ export async function uploadFiles({ } try { - result = await client.request('/api/sdk/upload', { - method: 'POST', - body: form, - signal, - headers: {}, - }); + result = await client.request( + buildEndpointWithParams('/api/sdk/upload', projectQuery || {}), + { + method: 'POST', + body: form, + signal, + headers: {}, + } + ); } catch (err) { throw createError(`Upload failed: ${err.message}`, 'UPLOAD_FAILED', { batch: Math.floor(i / batchSize) + 1, @@ -460,7 +475,7 @@ export async function upload({ // Create build let defaultBranch = await getDefaultBranch(); let buildInfo = buildBuildInfo(uploadOptions, defaultBranch); - let build = await createBuild(client, buildInfo); + let build = await createBuild(client, buildInfo, config.projectQuery); let buildId = build.id; let toUpload = fileMetadata; @@ -473,6 +488,7 @@ export async function upload({ client, signal, buildId, + projectQuery: config.projectQuery, deps: { checkShas, createError, output }, }); toUpload = existingResult.toUpload; @@ -490,6 +506,7 @@ export async function upload({ existing, screenshots, buildId, + projectQuery: config.projectQuery, buildInfo, client, signal, diff --git a/src/shared/local-lock.js b/src/shared/local-lock.js new file mode 100644 index 00000000..51f61160 --- /dev/null +++ b/src/shared/local-lock.js @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto'; +import { createConnection, createServer } from 'node:net'; + +function lockError(message) { + let error = new Error(message); + error.code = 'LOCAL_LOCK_UNAVAILABLE'; + return error; +} + +function listen(server, port) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port, exclusive: true }, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} + +async function waitForOwner(port, signal) { + return await new Promise((resolve, reject) => { + let socket = createConnection({ host: '127.0.0.1', port }); + let handshake = AbortSignal.timeout(2000); + let received = ''; + let verified = false; + let finish = error => { + handshake.removeEventListener('abort', expired); + signal?.removeEventListener('abort', aborted); + socket.destroy(); + if (error) reject(error); + else resolve(); + }; + let expired = () => { + if (!verified) + finish(lockError('Another local service occupies the lock address.')); + }; + let aborted = () => finish(signal.reason); + handshake.addEventListener('abort', expired, { once: true }); + signal?.addEventListener('abort', aborted, { once: true }); + socket.on('data', data => { + received += data.toString(); + if ( + received.length > 86 || + (received.includes('\n') && + !/^vizzly-local-lock-v1 [a-f0-9]{64}\n$/.test(received)) + ) { + finish(lockError('Another local service occupies the lock address.')); + } else if (/^vizzly-local-lock-v1 [a-f0-9]{64}\n$/.test(received)) { + verified = true; + handshake.removeEventListener('abort', expired); + } + }); + socket.once('error', error => { + finish( + error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET' + ? null + : error + ); + }); + socket.once('close', () => finish()); + if (signal?.aborted) aborted(); + }); +} + +/** Kernel-held process lock; owner death closes waiting sockets automatically. */ +export async function withLocalLock(key, operation, { signal } = {}) { + let deadline = AbortSignal.timeout(60000); + let waitingSignal = signal ? AbortSignal.any([signal, deadline]) : deadline; + let identity = createHash('sha256').update(key).digest('hex'); + let port = 20000 + (Number.parseInt(identity.slice(0, 8), 16) % 40000); + for (let attempts = 0; attempts < 64; attempts++) { + if (deadline.aborted && !signal?.aborted) { + throw lockError( + 'Timed out waiting for another local operation. Try again.' + ); + } + waitingSignal.throwIfAborted(); + let sockets = new Set(); + let server = createServer(socket => { + sockets.add(socket); + socket.on('error', () => {}); + socket.once('close', () => sockets.delete(socket)); + socket.write(`vizzly-local-lock-v1 ${identity}\n`); + }); + try { + await listen(server, port); + } catch (error) { + if (error.code !== 'EADDRINUSE') throw error; + try { + await waitForOwner(port, waitingSignal); + } catch (waitError) { + if (deadline.aborted && !signal?.aborted) { + throw lockError( + 'Timed out waiting for another local operation. Try again.' + ); + } + throw waitError; + } + continue; + } + try { + return await operation(); + } finally { + for (let socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(resolve)); + } + } + throw lockError('Unable to acquire the local process lock. Try again.'); +} diff --git a/src/shared/local-lock.test.js b/src/shared/local-lock.test.js new file mode 100644 index 00000000..fbc7d505 --- /dev/null +++ b/src/shared/local-lock.test.js @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { withLocalLock } from './local-lock.js'; + +function messageFrom(child) { + return new Promise((resolve, reject) => { + let exited = () => { + cleanup(); + reject(new Error('Lock process exited before sending its state.')); + }; + let message = value => { + cleanup(); + resolve(value); + }; + let cleanup = () => { + child.removeListener('exit', exited); + child.removeListener('message', message); + }; + child.once('message', message); + child.once('exit', exited); + }); +} + +test('a crashed lock owner releases its waiting processes without filesystem recovery', async t => { + let home = await mkdtemp(join(tmpdir(), 'vizzly-process-lock-')); + let filename = join(home, 'lock.js'); + let moduleUrl = new URL('./local-lock.js', import.meta.url).href; + await writeFile( + filename, + `import { once } from 'node:events';\nimport { withLocalLock } from ${JSON.stringify(moduleUrl)};\nlet key=process.argv[2]; process.send('started'); await withLocalLock(key,async()=>{let release=once(process,'message'); process.send('acquired'); await release;}); process.send('released'); process.disconnect();` + ); + let key = randomUUID(); + let owner = fork(filename, [key], { execArgv: [], silent: true }); + let waiter; + t.after(async () => { + owner.kill(); + waiter?.kill(); + await rm(home, { recursive: true, force: true }); + }); + assert.equal(await messageFrom(owner), 'started'); + assert.equal(await messageFrom(owner), 'acquired'); + waiter = fork(filename, [key], { execArgv: [], silent: true }); + assert.equal(await messageFrom(waiter), 'started'); + let acquired = messageFrom(waiter); + let ownerExit = once(owner, 'exit'); + owner.kill('SIGKILL'); + await ownerExit; + assert.equal(await acquired, 'acquired'); + let released = messageFrom(waiter); + let exit = once(waiter, 'exit'); + waiter.send('release'); + assert.equal(await released, 'released'); + assert.equal((await exit)[0], 0); +}); + +test('independent callers enter an exclusive operation one at a time', async () => { + let key = randomUUID(); + let active = 0; + let visits = 0; + await Promise.all( + Array.from({ length: 12 }, () => + withLocalLock(key, async () => { + active++; + assert.equal(active, 1); + await Promise.resolve(); + visits++; + active--; + }) + ) + ); + assert.equal(visits, 12); +}); + +test('another Vizzly lock identity on the same port is allowed to finish before acquisition', async () => { + let key = randomUUID(); + let identity = createHash('sha256').update(key).digest('hex'); + let port = 20000 + (Number.parseInt(identity.slice(0, 8), 16) % 40000); + let server = createServer(socket => { + socket.end(`vizzly-local-lock-v1 ${'0'.repeat(64)}\n`); + server.close(); + }); + server.listen(port, '127.0.0.1'); + await once(server, 'listening'); + let closed = once(server, 'close'); + assert.equal(await withLocalLock(key, async () => 'acquired'), 'acquired'); + await closed; +}); + +test('an acquisition deadline leaves the current owner in control of its operation', async t => { + let deadline = new AbortController(); + let timeout = AbortSignal.timeout; + t.mock.method(AbortSignal, 'timeout', duration => + duration === 60000 ? deadline.signal : timeout(duration) + ); + let release; + let entered; + let gate = new Promise(resolve => { + release = resolve; + }); + let started = new Promise(resolve => { + entered = resolve; + }); + let ownerFinished = false; + let key = randomUUID(); + let owner = withLocalLock(key, async () => { + entered(); + await gate; + ownerFinished = true; + }); + await started; + let waiting = withLocalLock(key, async () => { + assert.fail('a waiter took the owned lock'); + }); + deadline.abort( + new DOMException('Acquisition deadline reached', 'TimeoutError') + ); + await assert.rejects(() => waiting, { code: 'LOCAL_LOCK_UNAVAILABLE' }); + assert.equal(ownerFinished, false); + release(); + await owner; + assert.equal(ownerFinished, true); +}); diff --git a/src/shared/output.js b/src/shared/output.js index 15eda68f..204b0d6b 100644 --- a/src/shared/output.js +++ b/src/shared/output.js @@ -366,6 +366,12 @@ export function error(message, err = null, data = {}) { name: err.name, message: err.getUserMessage ? err.getUserMessage() : err.message, code: err.code, + ...(err.code === 'INSUFFICIENT_SCOPE' + ? { + requiredScopes: err.context?.requiredScopes || [], + grantedScopes: err.context?.grantedScopes || [], + } + : {}), }; if (isVerbose()) { errorData.error.stack = err.stack; diff --git a/src/shared/secret-store.js b/src/shared/secret-store.js index dd19e571..9f242602 100644 --- a/src/shared/secret-store.js +++ b/src/shared/secret-store.js @@ -21,28 +21,20 @@ export async function saveSecret(account, secret, options = {}) { } try { - try { - await execFileFn('security', [ - 'delete-generic-password', + await execFileFn( + 'security', + [ + 'add-generic-password', '-s', service, '-a', account, - ]); - } catch { - // Missing secrets are fine; add-generic-password creates the current value. - } - - await execFileFn('security', [ - 'add-generic-password', - '-s', - service, - '-a', - account, - '-w', - secret, - '-U', - ]); + '-w', + secret, + '-U', + ], + { timeout: 30000 } + ); return true; } catch { return false; @@ -62,14 +54,11 @@ export async function getSecret(account, options = {}) { } try { - let { stdout } = await execFileFn('security', [ - 'find-generic-password', - '-s', - service, - '-a', - account, - '-w', - ]); + let { stdout } = await execFileFn( + 'security', + ['find-generic-password', '-s', service, '-a', account, '-w'], + { timeout: 30000 } + ); return stdout.trim() || null; } catch { return null; @@ -89,13 +78,11 @@ export async function deleteSecret(account, options = {}) { } try { - await execFileFn('security', [ - 'delete-generic-password', - '-s', - service, - '-a', - account, - ]); + await execFileFn( + 'security', + ['delete-generic-password', '-s', service, '-a', account], + { timeout: 30000 } + ); return true; } catch { return false; diff --git a/tests/cli/api/api.test.js b/tests/cli/api/api.test.js index a714bf2f..2bd89f0c 100644 --- a/tests/cli/api/api.test.js +++ b/tests/cli/api/api.test.js @@ -13,6 +13,11 @@ import { parseApiHeaders, validateApiOptions, } from '../../../src/domains/api/api.commands.js'; +import { createTokenStore } from '../../../src/domains/auth/token-store.js'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../helpers/auth-session.js'; let directory, server, origin; let requests = []; @@ -41,6 +46,7 @@ before(async () => { JSON.stringify({ accessToken: 'refreshed-user', refreshToken: 'new-refresh', + authorization: fixtureAuthorization, expiresIn: 900, }) ); @@ -246,7 +252,6 @@ async function credentialHome(name) { await writeFile( join(home, 'config.json'), JSON.stringify({ - auth: { accessToken: 'logged-in-user', refreshToken: 'saved-refresh' }, projectLink: { active: 'fixture', links: { @@ -255,6 +260,10 @@ async function credentialHome(name) { }, }) ); + await saveFixtureAuth(home, origin, { + accessToken: 'logged-in-user', + refreshToken: 'saved-refresh', + }); return { VIZZLY_HOME: home, VIZZLY_TOKEN: '' }; } test('CLI prefers login over linked upload credentials and honors explicit tokens', async () => { @@ -289,10 +298,11 @@ test('CLI refreshes a read once but never replays generic writes', async () => { ['GET', '/api/refreshable'], ] ); - let saved = JSON.parse( - await readFile(join(environment.VIZZLY_HOME, 'config.json'), 'utf8') - ); - assert.equal(saved.auth.accessToken, 'refreshed-user'); + let saved = await createTokenStore({ + apiUrl: origin, + home: environment.VIZZLY_HOME, + }).getTokens(); + assert.equal(saved.accessToken, 'refreshed-user'); for (let method of ['POST', 'PUT', 'PATCH', 'DELETE']) { before = requests.length; result = await cli( diff --git a/tests/cli/auth/login-integration.test.js b/tests/cli/auth/login-integration.test.js index 72939762..5f5e8e39 100644 --- a/tests/cli/auth/login-integration.test.js +++ b/tests/cli/auth/login-integration.test.js @@ -7,6 +7,8 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { pathToFileURL } from 'node:url'; +import { createTokenStore } from '../../../src/domains/auth/token-store.js'; +import { fixtureAuthorization } from '../../helpers/auth-session.js'; async function login( t, @@ -133,16 +135,15 @@ process.disconnect(); output += chunk; }); let [code] = await once(child, 'close'); - let config; - try { - config = JSON.parse(await readFile(join(directory, 'home', 'config.json'))); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } + let auth = + (await createTokenStore({ + apiUrl, + home: join(directory, 'home'), + }).getTokens()) || undefined; let browserPid = browserHangs ? Number(await readFile(join(directory, 'browser.pid'), 'utf8')) : undefined; - return { code, output, config, requests, waits, browserPid }; + return { code, output, auth, requests, waits, browserPid }; } let pending = { status: 400, body: { code: 'AUTHORIZATION_PENDING' } }; @@ -154,6 +155,7 @@ let complete = { refreshToken: 'refresh', expiresIn: 900, }, + authorization: fixtureAuthorization, user: { name: 'Robert', email: 'robert@example.test' }, organizations: [{ name: 'Vizzly', slug: 'vizzly' }], }, @@ -167,9 +169,9 @@ test( async t => { let result = await login(t, [pending, pending, complete]); assert.equal(result.code, 0, result.output); - assert.equal(result.config.auth.accessToken, 'access'); - assert.equal(result.config.auth.refreshToken, 'refresh'); - assert.equal(result.config.auth.user.email, 'robert@example.test'); + assert.equal(result.auth.accessToken, 'access'); + assert.equal(result.auth.refreshToken, 'refresh'); + assert.equal(result.auth.user.email, 'robert@example.test'); assert.match(result.output, /Authenticated/); assert.match(result.output, /Vizzly \(@vizzly\)/); assert.match(result.output, /Could not open browser automatically/); @@ -207,7 +209,7 @@ test( assert.equal(result.code, 0, result.output); assert.deepEqual(result.waits, [5000, 5000, 12000]); assert.match(result.output, /Rate limited. Next check in 12s/); - assert.equal(result.config.auth.accessToken, 'access'); + assert.equal(result.auth.accessToken, 'access'); assert.equal( result.requests.filter(req => req.path.endsWith('/initiate')).length, 1 @@ -229,7 +231,7 @@ for (let [serverCode, message] of [ ]); assert.equal(result.code, 1); assert.match(result.output, message); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); assert.equal(result.requests.length, 2); } ); @@ -250,7 +252,7 @@ for (let [expiresIn, seconds, message] of [ }); assert.equal(result.code, 1); assert.match(result.output, message); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); assert.equal( result.waits.reduce((total, wait) => total + wait, 0), seconds * 1000 @@ -267,7 +269,7 @@ test('login stops a stalled poll when the code expires', options, async t => { let result = await login(t, [{ hang: true }], { expiresIn: 2 }); assert.equal(result.code, 1); assert.match(result.output, /Device code expired/); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); assert.equal(result.requests.length, 2); }); @@ -285,7 +287,7 @@ test( /Please wait a few minutes before trying again/ ); assert.equal(result.requests.length, 1); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); } ); @@ -302,7 +304,7 @@ test( assert.match(result.output, /timed out after 2 minutes/); assert.deepEqual(result.waits, [5000, 115000]); assert.equal(result.requests.length, 2); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); } ); @@ -313,7 +315,7 @@ test( let result = await login(t, [{ status: 200, body: { status: 'unknown' } }]); assert.equal(result.code, 1); assert.match(result.output, /Unexpected response/); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); assert.equal(result.requests.length, 2); } ); @@ -332,7 +334,7 @@ test( assert.match(result.output, /Authentication failed/); assert.match(result.output, /Please try logging in again/); assert.match(result.output, /sign up at https:\/\/vizzly.dev/); - assert.equal(result.config?.auth, undefined); + assert.equal(result.auth, undefined); } ); @@ -345,7 +347,7 @@ test('a stuck browser launcher cannot prevent login polling or leave the launche assert.match(result.output, /Could not open browser automatically/); assert.match(result.output, /Please open the URL manually/); assert.match(result.output, /Authenticated/); - assert.equal(result.config.auth.accessToken, 'access'); + assert.equal(result.auth.accessToken, 'access'); assert.equal(result.requests.length, 3); assert.throws(() => process.kill(result.browserPid, 0), { code: 'ESRCH' }); }); diff --git a/tests/cli/auth/scopes.test.js b/tests/cli/auth/scopes.test.js new file mode 100644 index 00000000..28692e36 --- /dev/null +++ b/tests/cli/auth/scopes.test.js @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + createTokenStore, + getAuthStoragePath, +} from '../../../src/domains/auth/token-store.js'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../helpers/auth-session.js'; +import { findJSONMessage, runCLI } from '../../helpers/cli-runner.js'; +import { createCloudServer } from '../../helpers/cloud-server.js'; + +let authorization = { ...fixtureAuthorization, scopes: ['read'] }; +let user = { + id: 'scope-user', + username: 'scope-user', + email: 'scopes@example.test', +}; + +async function fixture(t, handler) { + let cwd = await mkdtemp(join(tmpdir(), 'vizzly-cli-scopes-')); + let home = join(cwd, 'home'); + let bin = join(cwd, 'bin'); + await mkdir(bin); + for (let command of ['open', 'xdg-open']) { + await writeFile(join(bin, command), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + } + let { client, requests } = await createCloudServer(t, handler); + let apiUrl = client.getBaseUrl(); + t.after(() => rm(cwd, { recursive: true, force: true })); + let env = { + VIZZLY_HOME: home, + VIZZLY_API_URL: apiUrl, + PATH: `${bin}:${process.env.PATH}`, + }; + return { + cwd, + home, + apiUrl, + requests, + run: (args, envOverrides = {}) => + runCLI(args, { cwd, env: { ...env, ...envOverrides } }), + store: createTokenStore({ apiUrl, home }), + }; +} + +function loginResponse(received, withAuthorization = true) { + if (received.url.endsWith('/initiate')) { + return { + body: { + device_code: 'synthetic-device', + user_code: 'ABCD-EFGH', + verification_uri: 'https://example.test/cli/authorize', + expires_in: 600, + interval: 0.001, + }, + }; + } + assert.equal(received.url, '/api/auth/cli/device/poll'); + return { + body: { + user, + organizations: [], + tokens: { + accessToken: 'synthetic-access', + refreshToken: 'synthetic-refresh', + expiresIn: 900, + }, + ...(withAuthorization ? { authorization } : {}), + }, + }; +} + +function data(result) { + return findJSONMessage(result.stdout, 'data')?.data; +} + +function failure(result) { + return findJSONMessage(result.stderr, 'error')?.error; +} + +test('login requests named scopes but stores and reports only browser-granted permissions', async t => { + let f = await fixture(t, received => loginResponse(received)); + let result = await f.run([ + 'login', + '--no-browser', + '--scopes', + 'read,builds:write read', + '--json', + ]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(f.requests[0].body, { + client_id: 'vizzly-cli', + scope: 'read builds:write', + }); + let saved = await f.store.getTokens(); + assert.deepEqual(saved.authorization, authorization); + assert.equal(saved.accessToken, 'synthetic-access'); + let loggedIn = data(result); + assert.equal(loggedIn.apiUrl, f.apiUrl); + assert.deepEqual(loggedIn.authorization, authorization); + assert.doesNotMatch( + result.stdout + result.stderr, + /synthetic-access|synthetic-refresh/ + ); +}); + +test('default login leaves permission defaults to the API and rejects an unscoped legacy response', async t => { + let f = await fixture(t, received => loginResponse(received, false)); + let result = await f.run(['login', '--no-browser', '--json']); + assert.equal(result.code, 1); + assert.deepEqual(f.requests[0].body, { client_id: 'vizzly-cli' }); + assert.equal(failure(result).code, 'LOGIN_REQUIRED'); + assert.match(failure(result).message, /choose permissions in your browser/); + assert.equal(await f.store.getTokens(), null); +}); + +test('invalid requested permissions fail before starting a browser login', async t => { + let f = await fixture(t, () => + assert.fail('Invalid options must not call the API') + ); + let result = await f.run([ + 'login', + '--no-browser', + '--scopes', + 'read,admin', + '--json', + ]); + assert.equal(result.code, 1); + assert.equal(failure(result).code, 'INVALID_SCOPE'); + assert.deepEqual(f.requests, []); +}); + +test('whoami reports the selected origin, identity source, session, and actual scopes', async t => { + let f = await fixture(t, received => { + assert.equal(received.url, '/api/auth/cli/whoami'); + assert.equal(received.headers.authorization, 'Bearer fixture-user'); + return { body: { user, organizations: [], authorization } }; + }); + await saveFixtureAuth(f.home, f.apiUrl, { authorization }); + let result = await f.run(['whoami', '--json']); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(data(result), { + authenticated: true, + apiUrl: f.apiUrl, + credential: { source: 'user', kind: 'user' }, + authorization, + user, + organizations: [], + tokenExpiresAt: '2999-01-01T00:00:00.000Z', + }); + let human = await f.run(['whoami', '--no-color']); + assert.equal(human.code, 0, human.stderr); + assert.match(human.stdout, /saved user login/); + assert.ok(human.stdout.includes(f.apiUrl)); + assert.match(human.stdout, /Permissions.*read/); + assert.doesNotMatch(human.stdout, /Session|[0-9a-f]{8}-[0-9a-f-]{27,}/i); + assert.doesNotMatch( + result.stdout + human.stdout, + /fixture-user-refresh|fixture-refresh/ + ); +}); + +test('a scope denial preserves required and granted permissions without refreshing or changing identity', async t => { + let f = await fixture(t, received => { + assert.equal( + new URL(received.url, 'http://fixture').pathname, + '/api/sdk/projects' + ); + assert.equal(received.headers.authorization, 'Bearer fixture-user'); + return { + status: 403, + body: { + error: 'This CLI session needs the read permission.', + code: 'INSUFFICIENT_SCOPE', + requiredScopes: ['read'], + grantedScopes: ['comments:write'], + }, + }; + }); + await saveFixtureAuth(f.home, f.apiUrl, { + authorization: { ...authorization, scopes: ['comments:write'] }, + }); + let before = await f.store.getTokens(); + let result = await f.run(['projects', '--json']); + assert.equal(result.code, 1); + let error = failure(result); + assert.equal(error.code, 'INSUFFICIENT_SCOPE'); + assert.deepEqual(error.requiredScopes, ['read']); + assert.deepEqual(error.grantedScopes, ['comments:write']); + assert.match(error.message, /vizzly login --scopes "comments:write read"/); + let raw = await f.run(['api', '/api/sdk/projects', '--json']); + assert.equal(raw.code, 1); + assert.equal(data(raw).error.code, 'INSUFFICIENT_SCOPE'); + assert.deepEqual(data(raw).error.requiredScopes, ['read']); + assert.deepEqual(data(raw).error.grantedScopes, ['comments:write']); + assert.equal(f.requests.length, 2); + assert.deepEqual(await f.store.getTokens(), before); +}); + +test('whoami identifies the API origin when no login is stored', async t => { + let f = await fixture(t, () => + assert.fail('An empty login must not call the API') + ); + let result = await f.run(['whoami', '--json']); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(data(result), { authenticated: false, apiUrl: f.apiUrl }); + assert.deepEqual(f.requests, []); +}); + +test('whoami gives a login recovery action when its saved session is revoked', async t => { + let f = await fixture(t, received => { + assert.ok( + ['/api/auth/cli/whoami', '/api/auth/cli/refresh'].includes(received.url) + ); + return { + status: 401, + body: { code: 'SESSION_REVOKED', error: 'CLI session revoked.' }, + }; + }); + await saveFixtureAuth(f.home, f.apiUrl, { authorization }); + let result = await f.run(['whoami', '--json']); + assert.equal(result.code, 1); + assert.equal(data(result).authenticated, false); + assert.equal(data(result).code, 'LOGIN_REQUIRED'); + assert.match(data(result).error, /vizzly login/); +}); + +test('an unavailable saved user store cannot block an explicit token or silently choose another identity', async t => { + let f = await fixture(t, received => { + assert.equal(received.headers.authorization, 'Bearer vzt_explicit'); + return { + body: { + projects: [], + meta: { total: 0, limit: 50, offset: 0, hasMore: false }, + }, + }; + }); + await saveFixtureAuth(f.home, f.apiUrl); + // The record references an unavailable external credential store; disable the + // native boundary so this test never reads the host's real Keychain. + await writeFile( + getAuthStoragePath(f.apiUrl, f.home), + JSON.stringify({ + version: 1, + apiUrl: f.apiUrl, + storage: 'keychain', + authorization, + }) + ); + let env = { VIZZLY_DISABLE_KEYCHAIN: 'true' }; + let explicit = await f.run( + ['projects', '--token', 'vzt_explicit', '--json'], + env + ); + assert.equal(explicit.code, 0, explicit.stderr); + assert.equal(data(explicit).auth.source, 'flag'); + assert.equal(f.requests.length, 1); + for (let args of [ + ['projects', '--auth', 'user', '--json'], + ['projects', '--json'], + ]) { + let selectedUser = await f.run(args, env); + assert.equal(selectedUser.code, 1); + assert.equal(failure(selectedUser).code, 'AUTH_STORAGE_UNAVAILABLE'); + } + assert.equal(f.requests.length, 1); +}); diff --git a/tests/cli/projects/account-browsing.test.js b/tests/cli/projects/account-browsing.test.js index d095ddd5..51786be5 100644 --- a/tests/cli/projects/account-browsing.test.js +++ b/tests/cli/projects/account-browsing.test.js @@ -4,6 +4,11 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { getAuthStoragePath } from '../../../src/domains/auth/token-store.js'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../helpers/auth-session.js'; import { findJSONMessage, runCLI } from '../../helpers/cli-runner.js'; import { createCloudServer } from '../../helpers/cloud-server.js'; @@ -36,24 +41,15 @@ function page(items, query) { }; } -async function fixture(t, { expired = false, binding = true } = {}) { +async function fixture( + t, + { expired = false, binding = true, scopes = fixtureAuthorization.scopes } = {} +) { let cwd = await mkdtemp(path.join(tmpdir(), 'vizzly-account-')); let home = path.join(cwd, 'state'); await mkdir(home); t.after(() => rm(cwd, { recursive: true, force: true })); let configPath = path.join(cwd, '.vizzlyrc.json'); - let authPath = path.join(home, 'config.json'); - await writeFile( - authPath, - JSON.stringify({ - auth: { - accessToken: userToken, - refreshToken: 'synthetic-refresh', - expiresAt: expired ? '2020-01-01T00:00:00Z' : '2999-01-01T00:00:00Z', - user: { name: 'Fixture user' }, - }, - }) - ); await writeFile( configPath, JSON.stringify( @@ -65,6 +61,7 @@ async function fixture(t, { expired = false, binding = true } = {}) { ) ); let state = { + authorization: { ...fixtureAuthorization, scopes }, refreshStatus: 200, rejectUser: false, failPage: null, @@ -87,6 +84,7 @@ async function fixture(t, { expired = false, binding = true } = {}) { accessToken: 'synthetic-refreshed', refreshToken: 'synthetic-rotated', expiresIn: 3600, + authorization: state.authorization, } : { error: 'refresh rejected' }, }; @@ -95,7 +93,13 @@ async function fixture(t, { expired = false, binding = true } = {}) { return { status: 401, body: { error: 'invalid user' } }; } if (url.pathname === '/api/auth/cli/whoami') { - return { body: { user: { name: 'Fixture user' }, organizations: [] } }; + return { + body: { + user: { name: 'Fixture user' }, + organizations: [], + authorization: state.authorization, + }, + }; } if (url.pathname === '/api/sdk/projects') { assert.equal(request.headers['vizzly-api-version'], '2026-09-12'); @@ -149,22 +153,17 @@ async function fixture(t, { expired = false, binding = true } = {}) { body: { organization: { slug: 'acme' }, project: { slug: 'app-0' } }, }; } - if (url.pathname === '/api/cli/app-1/link-token') { - return { - body: { - organization: { slug: 'studio' }, - project: { slug: 'app-1' }, - token: { - id: 't-1', - token: projectToken, - token_prefix: 'vzt_fixture', - }, - }, - }; - } return { status: 404, body: { error: 'Unexpected fixture request' } }; }); let apiUrl = client.getBaseUrl(); + let authPath = getAuthStoragePath(apiUrl, home); + await saveFixtureAuth(home, apiUrl, { + accessToken: userToken, + refreshToken: 'synthetic-refresh', + expiresAt: expired ? '2020-01-01T00:00:00Z' : '2999-01-01T00:00:00Z', + user: { name: 'Fixture user' }, + authorization: state.authorization, + }); async function cli(args, env = {}) { let result = await runCLI(['--no-color', ...args], { cwd, @@ -258,7 +257,9 @@ test('explicit tokens, source selection, and noninteractive missing targets are } let human = await f.cli(['projects'], { VIZZLY_TOKEN: projectToken }); assert.match(human.stdout + human.stderr, /only its project/); - assert.match(human.stdout + human.stderr, /--auth user/); + assert.match(human.stdout + human.stderr, /Credential:\s+VIZZLY_TOKEN/); + assert.match(human.stdout + human.stderr, /Unset VIZZLY_TOKEN/); + assert.doesNotMatch(human.stdout + human.stderr, /--auth user/); assert.doesNotMatch(human.stdout + human.stderr, /undefined/); }); @@ -269,14 +270,14 @@ test('expired and near-expiry saved sessions recover without falling back to the ]) { let f = await fixture(t); let auth = JSON.parse(await readFile(f.authPath, 'utf8')); - auth.auth.expiresAt = expiresAt; + auth.expiresAt = expiresAt; await writeFile(f.authPath, JSON.stringify(auth)); let result = await f.cli(['projects', '--auth', 'user', '--json'], { VIZZLY_TOKEN: projectToken, }); assert.equal(result.code, 0, result.stderr); assert.equal(f.requests[0].url, '/api/auth/cli/refresh'); - let saved = JSON.parse(await readFile(f.authPath, 'utf8')).auth; + let saved = JSON.parse(await readFile(f.authPath, 'utf8')); assert.equal(saved.accessToken, 'synthetic-refreshed'); assert.equal(saved.user.name, 'Fixture user'); assert.ok(Date.parse(saved.expiresAt) > Date.now()); @@ -284,7 +285,7 @@ test('expired and near-expiry saved sessions recover without falling back to the } }); -test('rejected and unavailable refreshes preserve session state and give different recovery advice', async t => { +test('rejected refreshes preserve credentials and uncertain server failures prevent token replay', async t => { for (let status of [401, 503]) { let f = await fixture(t, { expired: true }); f.state.refreshStatus = status; @@ -294,10 +295,22 @@ test('rejected and unavailable refreshes preserve session state and give differe }); assert.equal(result.code, 1); assert.equal(f.requests.length, 1); - assert.equal(await readFile(f.authPath, 'utf8'), before); - if (status === 401) + if (status === 401) { + assert.equal(await readFile(f.authPath, 'utf8'), before); assert.match(result.stderr, /LOGIN_REQUIRED|vizzly login/); - else assert.doesNotMatch(result.stderr, /vizzly login/); + } else { + let saved = JSON.parse(await readFile(f.authPath, 'utf8')); + assert.deepEqual(saved, { + ...JSON.parse(before), + refreshUncertain: true, + }); + assert.match(result.stderr, /AUTH_REFRESH_UNCERTAIN/); + assert.match(result.stderr, /vizzly login/); + let retried = await f.cli(['projects', '--json']); + assert.equal(retried.code, 1); + assert.match(retried.stderr, /AUTH_REFRESH_UNCERTAIN/); + assert.equal(f.requests.length, 1); + } } }); @@ -374,6 +387,37 @@ test('offline project status keeps the checkout binding separate from an unverif }); }); +test('explicit destinations work with write-only login and status distinguishes missing upload permission', async t => { + for (let scopes of [['builds:write'], ['read']]) { + let f = await fixture(t, { binding: false, scopes }); + let authBefore = await readFile(f.authPath, 'utf8'); + let linked = await f.cli(['project', 'link', 'studio/app-1', '--json']); + assert.equal(linked.code, 0, linked.stderr); + assert.equal(f.requests.length, 0); + assert.equal(await readFile(f.authPath, 'utf8'), authBefore); + let inspected = await f.cli(['project', 'status', '--json']); + assert.equal(inspected.code, 0, inspected.stderr); + let destination = data(inspected); + assert.equal(destination.status, 'unverified'); + assert.equal(destination.credential.source, 'user'); + assert.deepEqual(destination.credential.authorization.scopes, scopes); + assert.equal( + destination.uploadPermission, + scopes.includes('builds:write') ? 'granted' : 'missing' + ); + assert.deepEqual(destination.effective, { + organizationSlug: 'studio', + projectSlug: 'app-1', + }); + assert.deepEqual( + f.requests.map(request => request.url), + ['/api/auth/cli/whoami'] + ); + assert.equal(Object.hasOwn(destination.credential, 'token'), false); + assert.equal(Object.hasOwn(destination.credential, 'session'), false); + } +}); + test('linking chooses uploads, browsing stays read-only, and a mismatched override fails before writes', async t => { let f = await fixture(t, { binding: false }); let linked = await f.cli(['project', 'link', 'studio/app-1', '--json']); diff --git a/tests/cli/projects/discovery.test.js b/tests/cli/projects/discovery.test.js index 442dd502..85225dce 100644 --- a/tests/cli/projects/discovery.test.js +++ b/tests/cli/projects/discovery.test.js @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, it } from 'node:test'; +import { saveFixtureAuth } from '../../helpers/auth-session.js'; import { runCLI } from '../../helpers/cli-runner.js'; import { createCloudServer } from '../../helpers/cloud-server.js'; @@ -48,7 +49,7 @@ let organizations = [ }, ]; -async function createWorkspace(t, { config, accessToken } = {}) { +async function createWorkspace(t, { config, accessToken, apiUrl } = {}) { let cwd = await mkdtemp(path.join(tmpdir(), 'vizzly-project-discovery-')); let home = path.join(cwd, '.vizzly-home'); await mkdir(home, { recursive: true }); @@ -60,12 +61,7 @@ async function createWorkspace(t, { config, accessToken } = {}) { ); } if (accessToken) { - await writeFile( - path.join(home, 'config.json'), - JSON.stringify({ - auth: { accessToken, expiresAt: '2999-01-01T00:00:00.000Z' }, - }) - ); + await saveFixtureAuth(home, apiUrl, { accessToken }); } t.after(() => rm(cwd, { recursive: true, force: true })); @@ -102,6 +98,7 @@ describe('organization and project discovery CLI', () => { }); let workspace = await createWorkspace(t, { accessToken: 'saved-user-credential', + apiUrl: client.getBaseUrl(), }); let result = await runDiscoveryCLI(workspace, client.getBaseUrl(), [ 'projects', @@ -109,7 +106,10 @@ describe('organization and project discovery CLI', () => { '2', ]); assert.equal(result.code, 0, result.stderr); - assert.match(result.stdout, /Frontend/); + assert.match(result.stdout, /Name\s+Project\s+Organization\s+Builds/); + assert.match(result.stdout, /Frontend\s+vizzly\/frontend\s+Vizzly\s+100/); + assert.match(result.stdout, /Credential:\s+saved user login/); + assert.doesNotMatch(result.stdout, /Permissions:/); assert.match(result.stdout, /Dashboard/); assert.match(result.stdout, /Showing:\s+2 of 8/); assert.match(result.stdout, /--offset 2/); @@ -122,6 +122,65 @@ describe('organization and project discovery CLI', () => { assert.match(orgs.stdout, /via token/); }); + it('clearly lists a single project and selects the available credential automatically', async t => { + let { client, requests } = await createCloudServer(t, () => ({ + body: { + projects: [ + { + id: 'project-1', + name: 'Project', + slug: 'project', + organizationName: 'Robert', + organizationSlug: 'rob', + }, + ], + meta: { total: 1, limit: 50, offset: 0, hasMore: false }, + }, + })); + let workspace = await createWorkspace(t, { + accessToken: 'saved-user-credential', + apiUrl: client.getBaseUrl(), + }); + for (let withToken of [false, true]) { + let result = await runDiscoveryCLI( + workspace, + client.getBaseUrl(), + ['projects'], + withToken ? { VIZZLY_TOKEN: 'vzt_explicit-project' } : {} + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Name\s+Project\s+Organization/); + assert.match(result.stdout, /Project\s+rob\/project\s+Robert/); + assert.match( + result.stdout, + /Read builds: vizzly builds --project project --org rob/ + ); + assert.match( + result.stdout, + /Choose uploads: vizzly project link rob\/project/ + ); + assert.doesNotMatch(result.stdout, /||--auth|Permissions:/); + assert.match( + result.stdout, + withToken + ? /Credential:\s+VIZZLY_TOKEN/ + : /Credential:\s+saved user login/ + ); + assert.equal( + requests.at(-1).headers.authorization, + withToken + ? 'Bearer vzt_explicit-project' + : 'Bearer saved-user-credential' + ); + } + let verbose = await runDiscoveryCLI(workspace, client.getBaseUrl(), [ + 'projects', + '--verbose', + ]); + assert.equal(verbose.code, 0, verbose.stderr); + assert.match(verbose.stdout, /Permissions: read, builds:write/); + }); + it('reports empty accessible lists and distinguishes an absent login', async t => { let { client, requests } = await createCloudServer(t, ({ url }) => { let query = new URL(url, 'http://fixture').searchParams; @@ -141,6 +200,7 @@ describe('organization and project discovery CLI', () => { }); let workspace = await createWorkspace(t, { accessToken: 'saved-user-credential', + apiUrl: client.getBaseUrl(), }); for (let command of ['projects', 'orgs']) { let result = await runDiscoveryCLI(workspace, client.getBaseUrl(), [ diff --git a/tests/cli/projects/project-link.test.js b/tests/cli/projects/project-link.test.js index b7d2689e..cee2c5b8 100644 --- a/tests/cli/projects/project-link.test.js +++ b/tests/cli/projects/project-link.test.js @@ -21,21 +21,12 @@ before(async () => { requests.push({ url: request.url, headers: request.headers, - body: JSON.parse(Buffer.concat(chunks).toString()), + body: Buffer.concat(chunks).toString(), }); - let projectSlug = request.url.split('/')[3]; response.setHeader('Content-Type', 'application/json'); + response.statusCode = 404; response.end( - JSON.stringify({ - organization: { slug: request.headers['x-organization'].toLowerCase() }, - project: { slug: projectSlug.toLowerCase() }, - token: { - id: `id-${projectSlug}`, - token: `vzt_fixture_${projectSlug}`, - token_prefix: 'vzt_fix', - expires_at: requests.at(-1).body.expiresAt || null, - }, - }) + JSON.stringify({ error: 'Linking must not issue cloud requests' }) ); }); server.listen(0, '127.0.0.1'); @@ -56,10 +47,7 @@ async function project(name) { let cwd = path.join(directory, name); let home = path.join(cwd, 'user-state'); await mkdir(home, { recursive: true }); - await writeFile( - path.join(home, 'config.json'), - JSON.stringify({ auth: { accessToken: 'fixture-user' } }) - ); + await writeFile(path.join(home, 'config.json'), '{}'); return { cwd, home }; } @@ -95,28 +83,32 @@ async function cli(fixture, args) { } describe('project linking', () => { - it('binds the repository and reuses credentials without a second token request', async () => { + it('binds the repository repeatedly without issuing or changing credentials', async () => { let fixture = await project('repeat'); let start = requests.length; + let authBefore = await readFile( + path.join(fixture.home, 'config.json'), + 'utf8' + ); let first = await cli(fixture, ['project', 'link', 'team/app']); assert.equal(first.code, 0, first.stderr + first.stdout); assert.equal(first.data.linked, true); - assert.equal(first.data.reused, false); assert.equal(first.data.binding.status, 'bound'); let filename = path.join(fixture.cwd, 'vizzly.config.js'); let configSource = await readFile(filename, 'utf8'); assert.ok(configSource.includes('"organizationSlug": "team"')); assert.ok(!configSource.includes('vzt_fixture')); - assert.equal(requests.at(-1).url, '/api/cli/app/link-token'); - assert.equal(requests.at(-1).headers.authorization, 'Bearer fixture-user'); let second = await cli(fixture, ['project', 'link', 'team/app']); assert.equal(second.code, 0, second.stderr + second.stdout); - assert.equal(second.data.reused, true); - assert.equal(requests.length, start + 1); + assert.equal(requests.length, start); assert.equal(await readFile(filename, 'utf8'), configSource); + assert.equal( + await readFile(path.join(fixture.home, 'config.json'), 'utf8'), + authBefore + ); }); - it('preserves executable config and requests an edit before minting credentials', async () => { + it('preserves executable config and requests an explicit destination edit', async () => { let fixture = await project('executable'); let source = 'let threshold = Number(process.env.THRESHOLD || 2);\n' + @@ -134,7 +126,7 @@ describe('project linking', () => { assert.equal(requests.length, start); }); - it('binds the canonical API identity so the next command finds its credential', async () => { + it('normalizes destination slugs without provisioning a project credential', async () => { let fixture = await project('canonical'); let result = await cli(fixture, ['project', 'link', 'Team/App']); assert.equal(result.code, 0, result.stderr + result.stdout); @@ -144,14 +136,13 @@ describe('project linking', () => { ); assert.match(source, /"organizationSlug": "team"/); assert.match(source, /"projectSlug": "app"/); - let loaded = await cli(fixture, ['config', 'linkedProject']); + let loaded = await cli(fixture, ['config', 'project']); assert.equal(loaded.code, 0, loaded.stderr + loaded.stdout); - assert.equal(loaded.data.value.organization, 'team'); - assert.equal(loaded.data.value.project, 'app'); + assert.equal(loaded.data.value.organizationSlug, 'team'); + assert.equal(loaded.data.value.projectSlug, 'app'); let start = requests.length; let repeated = await cli(fixture, ['project', 'link', 'team/app']); assert.equal(repeated.code, 0, repeated.stderr + repeated.stdout); - assert.equal(repeated.data.reused, true); assert.equal(requests.length, start); }); @@ -194,7 +185,7 @@ describe('project linking', () => { }); }); - it('retains explicit token name and expiration for a requested new credential', async () => { + it('rejects token-provisioning options instead of silently minting a credential', async () => { let fixture = await project('expiry'); let result = await cli(fixture, [ 'project', @@ -205,24 +196,25 @@ describe('project linking', () => { '--expires-at', '2999-01-01T00:00:00.000Z', ]); - assert.equal(result.code, 0, result.stderr + result.stdout); - assert.deepEqual(requests.at(-1).body, { - name: 'Fixture link', - expiresAt: '2999-01-01T00:00:00.000Z', - }); + assert.equal(result.code, 1, result.stderr + result.stdout); + assert.match( + result.stderr + result.stdout, + /unknown option|automation tokens/ + ); }); - it('requires login when no matching credential exists', async () => { + it('can save an explicit destination before login without claiming project access', async () => { let fixture = await project('anonymous'); await writeFile(path.join(fixture.home, 'config.json'), '{}'); let start = requests.length; let result = await cli(fixture, ['project', 'link', 'team/app']); - assert.equal(result.code, 1, result.stderr + result.stdout); - assert.ok(result.stderr.includes('Login required')); + assert.equal(result.code, 0, result.stderr + result.stdout); + assert.equal(result.data.linked, true); assert.equal(requests.length, start); - await assert.rejects(readFile(path.join(fixture.cwd, 'vizzly.config.js')), { - code: 'ENOENT', - }); + assert.match( + await readFile(path.join(fixture.cwd, 'vizzly.config.js'), 'utf8'), + /"projectSlug": "app"/ + ); }); it('returns an actionable manual edit for config that cannot be evaluated', async () => { diff --git a/tests/cli/projects/project-picker.test.js b/tests/cli/projects/project-picker.test.js index 1a99792a..e9ea1027 100644 --- a/tests/cli/projects/project-picker.test.js +++ b/tests/cli/projects/project-picker.test.js @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile } from 'node:fs/promises'; import http from 'node:http'; import path from 'node:path'; +import { getAuthStoragePath } from '../../../src/domains/auth/token-store.js'; +import { saveFixtureAuth } from '../../helpers/auth-session.js'; import { terminalFixture as fixture, terminalTest as ptyTest, @@ -33,19 +35,6 @@ async function projectPickerFixture(t) { meta: { total: 11, limit: 10, offset, hasMore: offset === 0 }, }) ); - } else if (url.pathname === '/api/cli/web/link-token') { - assert.equal(request.headers['x-organization'], 'studio'); - response.end( - JSON.stringify({ - organization: { slug: 'studio' }, - project: { slug: 'web' }, - token: { - id: 'token-1', - token: 'vzt_picker_secret', - token_prefix: 'vzt_fixture', - }, - }) - ); } else { response.statusCode = 404; response.end(JSON.stringify({ error: 'Unexpected fixture request' })); @@ -55,20 +44,15 @@ async function projectPickerFixture(t) { await once(server, 'listening'); t.after(() => new Promise(resolve => server.close(resolve))); await mkdir(path.join(cwd, 'user-state')); - await writeFile( - path.join(cwd, 'user-state', 'config.json'), - JSON.stringify({ - auth: { - accessToken: 'fixture-picker-user', - expiresAt: '2999-01-01T00:00:00Z', - }, - }) - ); + let apiUrl = `http://127.0.0.1:${server.address().port}`; + let home = path.join(cwd, 'user-state'); + await saveFixtureAuth(home, apiUrl, { accessToken: 'fixture-picker-user' }); + let authPath = getAuthStoragePath(apiUrl, home); let environment = { - VIZZLY_API_URL: `http://127.0.0.1:${server.address().port}`, + VIZZLY_API_URL: apiUrl, VIZZLY_NO_UPDATE_NOTIFIER: '1', }; - return { cwd, calls, environment }; + return { cwd, calls, environment, authPath }; } ptyTest( @@ -94,7 +78,6 @@ ptyTest( [ '/api/sdk/projects?limit=10&offset=0', '/api/sdk/projects?limit=10&offset=10', - '/api/cli/web/link-token', ] ); let { default: config } = await import(path.join(cwd, 'vizzly.config.js')); @@ -110,8 +93,12 @@ ptyTest( { timeout: 30000 }, async t => { for (let answer of ['q\n', '\x03', '\x04']) { - let { cwd, calls, environment } = await projectPickerFixture(t); - let filename = path.join(cwd, 'user-state', 'config.json'); + let { + cwd, + calls, + environment, + authPath: filename, + } = await projectPickerFixture(t); let before = await readFile(filename, 'utf8'); let result = await wizard( t, diff --git a/tests/cli/review/review-cli.test.js b/tests/cli/review/review-cli.test.js index 69d1cc7a..10a13b76 100644 --- a/tests/cli/review/review-cli.test.js +++ b/tests/cli/review/review-cli.test.js @@ -1,26 +1,24 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; +import { getAuthStoragePath } from '../../../src/domains/auth/token-store.js'; +import { + fixtureAuthorization, + saveFixtureAuth, +} from '../../helpers/auth-session.js'; import { runCLI } from '../../helpers/cli-runner.js'; -function createWorkspace() { +async function createWorkspace(apiUrl) { let cwd = mkdtempSync(join(tmpdir(), 'vizzly-cli-review-')); let vizzlyHome = join(cwd, '.vizzly-home'); - mkdirSync(vizzlyHome, { recursive: true }); - writeFileSync( - join(vizzlyHome, 'config.json'), - JSON.stringify({ - auth: { - accessToken: 'user-access-token', - refreshToken: 'user-refresh-token', - expiresAt: '2099-01-01T00:00:00.000Z', - user: { id: 'user-1' }, - }, - }) - ); + await saveFixtureAuth(vizzlyHome, apiUrl, { + accessToken: 'user-access-token', + refreshToken: 'user-refresh-token', + user: { id: 'user-1' }, + }); return { cwd, vizzlyHome }; } @@ -174,6 +172,7 @@ async function withReviewApiServer(callback) { sendJson(res, 200, { accessToken: 'refreshed-user-access-token', refreshToken: 'refreshed-user-refresh-token', + authorization: fixtureAuthorization, expiresAt: '2099-01-01T00:00:00.000Z', }); return; @@ -222,7 +221,7 @@ describe('commands/review CLI', () => { it('changes review decisions and safely retries the same command', async () => { await withReviewApiServer(async ({ apiUrl, state }) => { - let { cwd, vizzlyHome } = createWorkspace(); + let { cwd, vizzlyHome } = await createWorkspace(apiUrl); let env = { VIZZLY_API_URL: apiUrl, VIZZLY_HOME: vizzlyHome }; state.failAfterNextDecision = true; @@ -311,7 +310,7 @@ describe('commands/review CLI', () => { it('prints approval annotations and human comments, and reports comment failures without replay', async t => { await withReviewApiServer(async ({ apiUrl, state }) => { - let { cwd, vizzlyHome } = createWorkspace(); + let { cwd, vizzlyHome } = await createWorkspace(apiUrl); t.after(() => rmSync(cwd, { recursive: true, force: true })); let env = { VIZZLY_API_URL: apiUrl, VIZZLY_HOME: vizzlyHome }; let approved = await runCLI( @@ -362,7 +361,7 @@ describe('commands/review CLI', () => { it('rejects missing reasons, blank messages, and missing login before making review changes', async t => { await withReviewApiServer(async ({ apiUrl, state }) => { - let { cwd, vizzlyHome } = createWorkspace(); + let { cwd, vizzlyHome } = await createWorkspace(apiUrl); t.after(() => rmSync(cwd, { recursive: true, force: true })); let env = { VIZZLY_API_URL: apiUrl, VIZZLY_HOME: vizzlyHome }; for (let args of [ @@ -373,7 +372,7 @@ describe('commands/review CLI', () => { assert.equal(result.code, 1); assert.match(result.stderr, /reason|message.*required/i); } - rmSync(join(vizzlyHome, 'config.json')); + rmSync(getAuthStoragePath(apiUrl, vizzlyHome)); let result = await runCLI(['--no-color', 'approve', state.comparisonId], { cwd, env, diff --git a/tests/cli/setup/init-guided.test.js b/tests/cli/setup/init-guided.test.js index 8556c10e..1cb947f5 100644 --- a/tests/cli/setup/init-guided.test.js +++ b/tests/cli/setup/init-guided.test.js @@ -14,6 +14,7 @@ import { import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { saveFixtureAuth } from '../../helpers/auth-session.js'; import { terminalFixture as fixture, terminalTest as ptyTest, @@ -658,7 +659,7 @@ ptyTest( ); ptyTest( - 'cloud linking after local setup saves repository identity and keeps credentials in user storage', + 'cloud linking after local setup saves a destination without issuing credentials', { timeout: 30000 }, async t => { let cwd = await fixture(t); @@ -685,27 +686,17 @@ ptyTest( ); return; } - response.end( - JSON.stringify({ - organization: { slug: 'fixture-org' }, - project: { slug: 'fixture-app' }, - token: { - id: 'fixture-id', - token: 'fixture-private-token', - token_prefix: 'fixture-prefix', - }, - }) - ); + response.statusCode = 404; + response.end(JSON.stringify({ error: 'Unexpected fixture request' })); }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); t.after(() => new Promise(resolve => server.close(resolve))); await mkdir(path.join(cwd, 'user-state')); - await writeFile( - path.join(cwd, 'user-state', 'config.json'), - JSON.stringify({ auth: { accessToken: 'fixture-user-login' } }) - ); let apiUrl = `http://127.0.0.1:${server.address().port}`; + await saveFixtureAuth(path.join(cwd, 'user-state'), apiUrl, { + accessToken: 'fixture-user-login', + }); let result = await wizard( t, cwd, @@ -718,10 +709,7 @@ ptyTest( { VIZZLY_API_URL: apiUrl } ); assert.equal(result.code, 0, result.output); - assert.deepEqual(calls, [ - '/api/sdk/projects?limit=10&offset=0', - '/api/cli/fixture-app/link-token', - ]); + assert.deepEqual(calls, ['/api/sdk/projects?limit=10&offset=0']); let source = await readFile(path.join(cwd, 'vizzly.config.js'), 'utf8'); assert.doesNotMatch(source, /fixture-private-token/); let { default: config } = await import(path.join(cwd, 'vizzly.config.js')); diff --git a/tests/cli/uploads/scoped-user-upload.test.js b/tests/cli/uploads/scoped-user-upload.test.js new file mode 100644 index 00000000..895646f8 --- /dev/null +++ b/tests/cli/uploads/scoped-user-upload.test.js @@ -0,0 +1,296 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createTokenStore } from '../../../src/domains/auth/token-store.js'; +import { findJSONMessage, runCLI } from '../../helpers/cli-runner.js'; +import { createCloudServer } from '../../helpers/cloud-server.js'; + +let originalToken = 'synthetic-upload-user'; +let refreshedToken = 'synthetic-upload-refreshed'; +let image = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWP4////fwAJ+wP9CNHoHgAAAABJRU5ErkJggg=='; + +async function fixture( + t, + { scopes = ['builds:write'], expired = false, buildStatus = 201 } = {} +) { + let cwd = await mkdtemp(join(tmpdir(), 'vizzly-user-upload-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + let authorization = { + sessionId: 'upload-session', + clientId: 'vizzly-cli', + scopes, + }; + let { client, requests } = await createCloudServer(t, request => { + let url = new URL(request.url, 'http://fixture'); + if (url.pathname === '/api/auth/cli/refresh') { + assert.equal(request.body.refreshToken, 'synthetic-upload-refresh'); + return { + body: { + accessToken: refreshedToken, + refreshToken: 'synthetic-upload-rotated', + expiresIn: 3600, + authorization, + }, + }; + } + if (url.pathname === '/api/auth/cli/whoami') { + return { body: { user: { username: 'fixture-user' }, authorization } }; + } + if (url.pathname === '/api/sdk/token/context') { + return { + body: { organization: { slug: 'acme' }, project: { slug: 'web' } }, + }; + } + if (url.pathname === '/api/sdk/builds' && request.method === 'POST') { + return { + status: buildStatus, + body: + buildStatus === 201 + ? { id: 'user-build' } + : { error: 'Rejected selected credential' }, + }; + } + if (url.pathname === '/api/sdk/check-shas') { + return { body: { existing: [], missing: [], screenshots: [] } }; + } + if (url.pathname === '/api/sdk/upload') + return { body: { build: { id: 'user-build' } } }; + if (url.pathname === '/api/sdk/builds/user-build/screenshots') { + return { + body: request.body.image_data + ? { id: 'uploaded-capture' } + : { upload_required: true }, + }; + } + if (url.pathname === '/api/sdk/builds/user-build/status') { + return { body: { build: { id: 'user-build', status: 'completed' } } }; + } + if (url.pathname === '/api/sdk/parallel/user-parallel/finalize') { + return { + body: { + build: { + id: 'user-build', + status: 'completed', + parallel_id: 'user-parallel', + }, + }, + }; + } + if (url.pathname === '/api/sdk/builds/user-build/preview/upload-zip') { + return { + body: { preview_url: 'https://preview.example/fixture', file_count: 1 }, + }; + } + assert.fail(`Unexpected request: ${request.method} ${request.url}`); + }); + let apiUrl = client.getBaseUrl(); + let home = join(cwd, 'state'); + await createTokenStore({ apiUrl, home, storage: 'file' }).saveTokens({ + accessToken: originalToken, + refreshToken: 'synthetic-upload-refresh', + expiresAt: expired ? '2020-01-01T00:00:00Z' : '2999-01-01T00:00:00Z', + user: { username: 'fixture-user' }, + authorization, + }); + await writeFile( + join(cwd, '.vizzlyrc.json'), + JSON.stringify({ + project: { organizationSlug: 'acme', projectSlug: 'web' }, + }) + ); + let screenshots = join(cwd, 'screenshots'); + await mkdir(screenshots); + await writeFile( + join(screenshots, 'home-chrome.png'), + Buffer.from(image, 'base64') + ); + let options = { cwd, env: { VIZZLY_HOME: home, VIZZLY_API_URL: apiUrl } }; + return { + cwd, + apiUrl, + screenshots, + options, + requests, + token: expired ? refreshedToken : originalToken, + }; +} + +function assertSelectedUser(f) { + for (let request of f.requests.filter( + request => !request.url.endsWith('/refresh') + )) { + assert.equal(request.headers.authorization, `Bearer ${f.token}`); + } + assert.equal( + f.requests.filter(request => request.url.includes('/token/context')).length, + 0 + ); + for (let request of f.requests.filter(request => { + let pathname = new URL(request.url, 'http://fixture').pathname; + return [ + '/api/sdk/builds', + '/api/sdk/upload', + '/api/sdk/check-shas', + '/api/sdk/parallel/user-parallel/finalize', + ].includes(pathname); + })) { + let url = new URL(request.url, 'http://fixture'); + assert.equal(url.searchParams.get('project'), 'web'); + assert.equal(url.searchParams.get('organization'), 'acme'); + } +} + +test('a write-only saved login refreshes once and uploads to its bound project', async t => { + let f = await fixture(t, { expired: true }); + let result = await runCLI(['--json', 'upload', f.screenshots], f.options); + assert.equal(result.code, 0, result.stderr + result.stdout); + let data = findJSONMessage(result.stdout, 'data').data; + assert.equal(data.buildId, 'user-build'); + assert.equal(data.url, `${f.apiUrl}/acme/web/builds/user-build`); + assert.equal(data.destination.credential.kind, 'user'); + assert.equal(data.destination.credential.token, undefined); + assert.equal(data.destination.credential.session, undefined); + assert.doesNotMatch(result.stdout + result.stderr, /synthetic-upload/); + assert.equal( + f.requests.filter(request => request.url.endsWith('/refresh')).length, + 1 + ); + assert.ok( + f.requests.some( + request => request.method === 'PUT' && request.url.endsWith('/status') + ) + ); + assertSelectedUser(f); +}); + +test('a write-only user run captures screenshots and finalizes with the same login', async t => { + let f = await fixture(t, { expired: true }); + let server = createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let port = server.address().port; + await new Promise(resolve => server.close(resolve)); + await writeFile( + join(f.cwd, 'capture.js'), + ` + let response = await fetch(process.env.VIZZLY_SERVER_URL + '/screenshot', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'User run capture', image: '${image}', type: 'base64' }) + }); + if (!response.ok) throw new Error(await response.text()); + ` + ); + let result = await runCLI( + ['--json', 'run', 'node capture.js', '--port', String(port)], + f.options + ); + assert.equal(result.code, 0, result.stderr + result.stdout); + let data = findJSONMessage(result.stdout, 'data').data; + assert.equal(data.buildId, 'user-build'); + assert.equal(data.uploads.uploaded, 1); + assert.equal(data.url, `${f.apiUrl}/acme/web/builds/user-build`); + assert.equal( + f.requests.filter(request => request.url.endsWith('/refresh')).length, + 1 + ); + assert.equal( + f.requests.filter(request => request.url === '/api/sdk/builds/user-build') + .length, + 0 + ); + assert.ok( + f.requests.some( + request => request.url.endsWith('/screenshots') && request.body.image_data + ) + ); + assertSelectedUser(f); + assert.doesNotMatch(result.stdout + result.stderr, /synthetic-upload/); +}); + +for (let command of ['upload', 'run']) { + test(`${command} rejects a read-only login before creating builds or starting tests`, async t => { + let f = await fixture(t, { scopes: ['read'] }); + let args = + command === 'upload' + ? [command, f.screenshots] + : [command, "node -e \"require('fs').writeFileSync('started','yes')\""]; + let result = await runCLI(['--json', ...args], f.options); + assert.equal(result.code, 1, result.stderr + result.stdout); + assert.match(result.stderr + result.stdout, /builds:write/); + let error = + findJSONMessage(result.stdout, 'data')?.data.error || + findJSONMessage(result.stderr, 'error')?.error; + assert.equal(error.code, 'INSUFFICIENT_SCOPE'); + assert.deepEqual(error.requiredScopes, ['builds:write']); + assert.deepEqual(error.grantedScopes, ['read']); + assert.equal( + f.requests.filter(request => request.method !== 'GET').length, + 0 + ); + await assert.rejects(access(join(f.cwd, 'started')), { code: 'ENOENT' }); + }); +} + +test('an explicit CI token stays authoritative over an available saved login', async t => { + let f = await fixture(t, { scopes: ['read'], expired: true }); + let result = await runCLI(['--json', 'upload', f.screenshots], { + ...f.options, + env: { ...f.options.env, VIZZLY_TOKEN: 'vzt_explicit-ci' }, + }); + assert.equal(result.code, 0, result.stderr + result.stdout); + assert.equal( + f.requests.filter(request => request.url.includes('/api/auth/cli/')).length, + 0 + ); + for (let request of f.requests) + assert.equal(request.headers.authorization, 'Bearer vzt_explicit-ci'); +}); + +test('parallel finalization uses the saved user and explicit project binding', async t => { + let f = await fixture(t); + let result = await runCLI(['--json', 'finalize', 'user-parallel'], f.options); + assert.equal(result.code, 0, result.stderr + result.stdout); + assert.ok( + f.requests.some(request => + request.url.includes('/parallel/user-parallel/finalize') + ) + ); + assertSelectedUser(f); +}); + +test('a write-only user uploads a preview after acknowledging public link access', async t => { + let f = await fixture(t); + let preview = join(f.cwd, 'preview'); + await mkdir(preview); + await writeFile(join(preview, 'index.html'), '

Preview

'); + let result = await runCLI( + ['--json', 'preview', preview, '--build', 'user-build', '--public-link'], + f.options + ); + assert.equal(result.code, 0, result.stderr + result.stdout); + assert.ok( + f.requests.some(request => request.url.endsWith('/preview/upload-zip')) + ); + assertSelectedUser(f); +}); + +test('a rejected user build write is not replayed or retried as a project token', async t => { + let f = await fixture(t, { buildStatus: 401 }); + let result = await runCLI(['--json', 'upload', f.screenshots], f.options); + assert.equal(result.code, 1, result.stderr + result.stdout); + assert.equal( + f.requests.filter( + request => + request.method === 'POST' && request.url.startsWith('/api/sdk/builds') + ).length, + 1 + ); + assert.equal( + f.requests.filter(request => request.url.endsWith('/refresh')).length, + 0 + ); + assertSelectedUser(f); +}); diff --git a/tests/helpers/auth-session.js b/tests/helpers/auth-session.js new file mode 100644 index 00000000..b00f9924 --- /dev/null +++ b/tests/helpers/auth-session.js @@ -0,0 +1,24 @@ +import { createTokenStore } from '../../src/domains/auth/token-store.js'; + +export let fixtureAuthorization = Object.freeze({ + sessionId: '8bfb8060-ef8e-4c3e-b58b-ffb826ba899b', + clientId: 'vizzly-cli', + scopes: Object.freeze([ + 'read', + 'builds:write', + 'reviews:write', + 'comments:write', + ]), +}); + +/** Save synthetic login data through the same origin-scoped store as the CLI. */ +export async function saveFixtureAuth(home, apiUrl, auth = {}) { + await createTokenStore({ apiUrl, home }).saveTokens({ + accessToken: 'fixture-user', + refreshToken: 'fixture-refresh', + expiresAt: '2999-01-01T00:00:00.000Z', + user: { username: 'fixture-user' }, + authorization: fixtureAuthorization, + ...auth, + }); +} diff --git a/tests/reporter/specs/builds-workflow.spec.js b/tests/reporter/specs/builds-workflow.spec.js index f1ccbeef..43c5506a 100644 --- a/tests/reporter/specs/builds-workflow.spec.js +++ b/tests/reporter/specs/builds-workflow.spec.js @@ -12,9 +12,9 @@ test('signed-out users see the sign-in prompt without fetching projects', async await expect( page.getByRole('heading', { name: /sign in required/i }) ).toBeVisible(); - expect(cloud.requests.some(request => request.path === '/api/project')).toBe( - false - ); + expect( + cloud.requests.some(request => request.path === '/api/sdk/projects') + ).toBe(false); }); test.describe('Signed-in builds', () => { @@ -33,7 +33,7 @@ test.describe('Signed-in builds', () => { page.getByRole('heading', { name: /no projects found/i }) ).toBeVisible(); expect( - cloud.requests.some(request => request.path === '/api/project') + cloud.requests.some(request => request.path === '/api/sdk/projects') ).toBe(true); }); }); diff --git a/tests/reporter/support/cloud-api.js b/tests/reporter/support/cloud-api.js index 9c7ede41..e8126fc6 100644 --- a/tests/reporter/support/cloud-api.js +++ b/tests/reporter/support/cloud-api.js @@ -1,5 +1,6 @@ import { once } from 'node:events'; import { createServer } from 'node:http'; +import { fixtureAuthorization } from '../../helpers/auth-session.js'; // Only the remote service is simulated. Local auth, storage, and HTTP routes are real. export async function startCloudApi() { @@ -66,10 +67,42 @@ export async function startCloudApi() { organizations: [{ slug: 'test-org', name: 'Test organization' }], }, }; - else if (path === '/api/project') - response = { body: { projects: api.projects } }; - else if (path.startsWith('/api/project/') && path.endsWith('/builds')) - response = { body: { builds: api.builds } }; + else if (path === '/api/sdk/projects') { + let url = new URL(req.url, 'http://cloud'); + let limit = Number(url.searchParams.get('limit')); + let offset = Number(url.searchParams.get('offset')); + let projects = api.projects.slice(offset, offset + limit); + response = { + body: { + projects, + meta: { + total: api.projects.length, + limit, + offset, + hasMore: offset + projects.length < api.projects.length, + }, + }, + }; + } else if (path === '/api/sdk/builds') { + let url = new URL(req.url, 'http://cloud'); + let limit = Number(url.searchParams.get('limit')); + let offset = Number(url.searchParams.get('offset')); + let builds = api.builds.slice(offset, offset + limit); + response = { + body: { + builds, + meta: { + total: api.builds.length, + limit, + offset, + hasMore: offset + builds.length < api.builds.length, + }, + }, + }; + } else if (/^\/api\/sdk\/builds\/[^/]+\/tdd-baselines$/.test(path)) + response = { + body: { build: {}, screenshots: [], signatureProperties: [] }, + }; else response = { status: 404, @@ -105,5 +138,6 @@ export let authorizedResponse = { expiresIn: 3600, }, user: { username: 'test-user' }, + authorization: fixtureAuthorization, }, }; diff --git a/tests/reporter/support/reporter-server.js b/tests/reporter/support/reporter-server.js index 1fa17527..0dbf725b 100644 --- a/tests/reporter/support/reporter-server.js +++ b/tests/reporter/support/reporter-server.js @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createTokenStore } from '../../../src/domains/auth/token-store.js'; import { writeReporterFixture } from './write-reporter-fixture.js'; export async function startRealReporter(fixtureData, options = {}) { @@ -100,9 +101,10 @@ export async function startRealReporter(fixtureData, options = {}) { readConfig: async () => JSON.parse(await readFile(join(workingDir, '.vizzlyrc.json'), 'utf8')), readAuth: async () => - JSON.parse( - await readFile(join(workingDir, 'home', 'config.json'), 'utf8') - ).auth, + createTokenStore({ + apiUrl: options.apiUrl, + home: join(workingDir, 'home'), + }).getTokens(), readReport: async () => JSON.parse( await readFile( diff --git a/tests/reporter/support/server-process.js b/tests/reporter/support/server-process.js index d71de2ae..110f8d73 100644 --- a/tests/reporter/support/server-process.js +++ b/tests/reporter/support/server-process.js @@ -2,14 +2,14 @@ import { createHttpServer } from '../../../src/adapters/http/http-server.js'; import { createAuthService } from '../../../src/domains/auth/auth.service.js'; -import { saveAuthTokens } from '../../../src/domains/auth/token-store.js'; import { createConfigService } from '../../../src/domains/config/config.service.js'; import { createProjectService } from '../../../src/domains/projects/project.service.js'; import { createTddHandler } from '../../../src/domains/tdd/screenshot-handler.js'; +import { saveFixtureAuth } from '../../helpers/auth-session.js'; let apiUrl = process.env.REPORTER_TEST_API_URL; if (process.env.REPORTER_TEST_SIGNED_IN === 'true') { - await saveAuthTokens({ + await saveFixtureAuth(process.env.VIZZLY_HOME, apiUrl, { accessToken: 'reporter-access', refreshToken: 'reporter-refresh', expiresAt: '2100-01-01T00:00:00Z',