diff --git a/.config/repo/oxlint.config.mts b/.config/repo/oxlint.config.mts index e74427b9..d0cdb135 100644 --- a/.config/repo/oxlint.config.mts +++ b/.config/repo/oxlint.config.mts @@ -1,37 +1,27 @@ /** - * @file Socket-vscode repo oxlint config. Imports the fleet factory and - * augments it in JS — see `.config/fleet/oxlint.config.mts` for why this is - * a factory call rather than oxlint `extends` (extends drops - * plugins/categories/ignorePatterns and mis-roots relative globs). + * Repository-owned lint configuration. Keep the basic lint command usable + * without hydrating the shared Fleet config. */ - -import { config } from '../fleet/oxlint.config.mts' - -export default config({ - rules: { - // This extension is interop-heavy by nature: acorn AST node probing - // (js-source-externals), the Go wasm runtime glue (wasm-executor), branded - // TextDocumentURIString casts, and JSON.parse narrowing across scripts. - // Every one of its ~90 `as` sites is a deliberate narrowing at an untyped - // boundary, so the type-aware blanket rule is noise here. Re-enable per - // directory as boundaries grow real validators (typebox schemas already - // cover the lockstep manifest path). - 'typescript/no-unsafe-type-assertion': 'off', +export default { + categories: { + correctness: "error", + suspicious: "warn", }, - overrides: [ - { - // tsgolint resolves each file's nearest tsconfig; everything outside - // src/ falls back to a projectless view WITHOUT the fleet base's - // noUncheckedIndexedAccess, so the type-aware pass mislabels the - // `arr[i]!` assertions those trees need as "unnecessary" and its - // autofix strips them — breaking the check-tier tsc - // (.config/fleet/tsconfig.check.json), which DOES see the fleet base. - // Scope the rule to src/ (covered by the root tsconfig project) until - // the scripts/test trees get a real tsconfig project of their own. - files: ['scripts/**', 'test/**', 'vendor/**', '.github/**', '*.mts'], - rules: { - 'typescript/no-unnecessary-type-assertion': 'off', - }, - }, + ignorePatterns: [ + "coverage/**", + "out/**", + "vendor/**", + "scripts/**", + "test/**", + ".github/**", + ".git-hooks/**", + ".claude/**", + ".config/**", + "docs/**", ], -}) + plugins: ["typescript"], + rules: { + "no-unused-vars": "off", + "typescript/no-unsafe-type-assertion": "off", + }, +}; diff --git a/.config/repo/rolldown.config.mts b/.config/repo/rolldown.config.mts index b7757dbd..8794cfbc 100644 --- a/.config/repo/rolldown.config.mts +++ b/.config/repo/rolldown.config.mts @@ -6,7 +6,6 @@ import type { Plugin, RolldownOptions } from 'rolldown' import { getEnvValue } from '@socketsecurity/lib-stable/env/rewire' -import { defineGuardedPlugin } from '../fleet/rolldown/define-guarded.mts' const rootPath = path.resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -47,6 +46,19 @@ export function stageParserWasmPlugin(): Plugin { } } +export function defineExtensionVersionPlugin(version: string): Plugin { + const replacement = JSON.stringify(version) + return { + name: 'define-extension-version', + transform(source) { + return source.replace( + /process\s*\.\s*env\s*\[\s*(['"])INLINED_EXTENSION_VERSION\1\s*\]/gu, + replacement, + ) + }, + } +} + const pkg = JSON.parse( readFileSync(path.join(rootPath, 'package.json'), 'utf8'), ) as { version?: string | undefined } @@ -89,9 +101,7 @@ const config: RolldownOptions = { // `process.env['X']` on index-signature types — and both normalize to this // same key. The `INLINED_*` name follows the fleet convention (see // socket-cli) for build-inlined values. - defineGuardedPlugin({ - 'process.env.INLINED_EXTENSION_VERSION': JSON.stringify(extensionVersion), - }), + defineExtensionVersionPlugin(extensionVersion), stageParserWasmPlugin(), ], // The sources are `.mts` (the fleet's sources-are-mts rule) and import each diff --git a/.config/repo/tsconfig.json b/.config/repo/tsconfig.json index 3cdf785d..03c565d1 100644 --- a/.config/repo/tsconfig.json +++ b/.config/repo/tsconfig.json @@ -1,5 +1,17 @@ { - "extends": ["../fleet/tsconfig.base.json", "./tsconfig.check.json"], + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "lib": ["DOM", "ES2024"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node", "vscode"] + }, "include": ["../../src/**/*.mts", "../../src/**/*.d.ts"], - "exclude": ["../../node_modules"] + "exclude": ["../../node_modules", "../../out"] } diff --git a/.config/repo/vitest.config.mts b/.config/repo/vitest.config.mts new file mode 100644 index 00000000..88310681 --- /dev/null +++ b/.config/repo/vitest.config.mts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +const root = path.resolve(import.meta.dirname, "../.."); + +export default defineConfig({ + resolve: { + alias: { + vscode: path.join(root, "test/stubs/vscode.mts"), + }, + }, + test: { + setupFiles: [path.join(root, "test/repo/vitest.setup.mts")], + include: ["test/repo/unit/**/*.test.mts"], + exclude: [ + "test/**/*.fuzz.mts", + "test/repo/unit/test-extension.test.mts", + "test/repo/unit/test-extension-auth.test.mts", + "test/repo/unit/scripts/install-claude-plugins.test.mts", + ], + testTimeout: 15000, + hookTimeout: 15000, + pool: "forks", + maxWorkers: 4, + }, +}); diff --git a/.github/actions/fleet/setup/action.yml b/.github/actions/fleet/setup/action.yml index 818a8696..40574a37 100644 --- a/.github/actions/fleet/setup/action.yml +++ b/.github/actions/fleet/setup/action.yml @@ -789,18 +789,16 @@ runs: # Source of truth (baked into the binary) lives at # SocketDev/firewall:src/lib/registries/default.ts. # - # The bypass list itself lives in the wheelhouse-canonical - # location `.config/fleet/sfw-bypass-list.txt` (action_path is - # the running repo checkout (inlined local action); - # `../../../../.config/fleet` walks up out of - # `.github/actions/fleet/setup/` to repo root). Every fleet repo gets - # a byte-identical copy via sync-scaffolding, so socket-btm's - # install-sfw.mts can read its own local copy at the same path. + # A Fleet checkout may provide repo-specific registry bypass entries. + # Standalone repos do not carry that Fleet config and rely on the + # registry defaults compiled into Socket Firewall. BYPASS_FILE="${{ github.action_path }}/../../../../.config/fleet/sfw-bypass-list.txt" { echo 'SFW_CUSTOM_REGISTRIES<=16'} - compromise@14.18.0: - resolution: {integrity: sha512-SCNTeMZN8ZnvDz96igAjV2QuvvmcKA1bVDbFr8IH672Ec9w+fTSxixIPGgrlivaxU+jyk/Ei3AT3gGtP23n6Vg==} - engines: {node: '>=18'} - content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -5846,10 +2321,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - efrt@2.9.0: - resolution: {integrity: sha512-ZMAvlcu4AfmtJ4K/0hs0uofVUZPSeLG/w4yOyZawQLl9YEy8iDKIxIIPsUAlS907clC7bgD17e73vi5unwNE/g==} - engines: {node: '>=12.0.0'} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -6042,10 +2513,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grad-school@0.0.9: - resolution: {integrity: sha512-/aoG9CJz/1gB7x5XKp9i7ggA4k+MqZD7Neph3crfrOLWERkeJFXDXzNefvPW0j97iGTNYqTL+rpBEcz83MTmZw==} - engines: {node: '>=12'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -7134,10 +3601,6 @@ packages: structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} - suffix-thumb@6.0.0: - resolution: {integrity: sha512-HsCTMLaupbob5ZG//ZW6PSj446tHT5kSJ1qXS5F/qO2Nt13uAn6ZLPhJQ/H6bN/jVQCb+pcqe2psEwy4Mp5+Vw==} - engines: {node: '>=18'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -8821,12 +5284,6 @@ snapshots: commander@11.1.0: {} - compromise@14.18.0: - dependencies: - efrt: 2.9.0 - grad-school: 0.0.9 - suffix-thumb: 6.0.0 - content-disposition@1.1.0: optional: true @@ -9020,8 +5477,6 @@ snapshots: ee-first@1.1.1: {} - efrt@2.9.0: {} - encodeurl@2.0.0: optional: true @@ -9252,8 +5707,6 @@ snapshots: graceful-fs@4.2.11: {} - grad-school@0.0.9: {} - has-flag@4.0.0: {} hono@4.13.7: @@ -10614,8 +7067,6 @@ snapshots: dependencies: boundary: 2.0.0 - suffix-thumb@6.0.0: {} - supports-color@7.2.0: dependencies: has-flag: 4.0.0 diff --git a/scripts/repo/clean.mts b/scripts/repo/clean.mts new file mode 100644 index 00000000..b26331fa --- /dev/null +++ b/scripts/repo/clean.mts @@ -0,0 +1,8 @@ +import { rm } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const root = process.cwd() +for (const entry of ['coverage', 'dist', 'out', '.vitest']) { + await rm(path.join(root, entry), { force: true, recursive: true }) +} diff --git a/src/api.mts b/src/api.mts index 2f6f82c0..75245e67 100644 --- a/src/api.mts +++ b/src/api.mts @@ -15,6 +15,7 @@ import { export type OrgInfo = { id: string + slug: string name: string image: string | undefined // The Socket API returns an open-ended plan string (opensource, team, @@ -120,6 +121,7 @@ export async function getOrganizations( const org = orgList[i]! organizations.set(org.id, { id: org.id, + slug: typeof org.slug === 'string' ? org.slug : '', name: org.name ?? '', image: org.image ?? undefined, plan: org.plan, diff --git a/src/auth-context-state.mts b/src/auth-context-state.mts new file mode 100644 index 00000000..38be4fa6 --- /dev/null +++ b/src/auth-context-state.mts @@ -0,0 +1,34 @@ +export type Listener = (ready: boolean) => void + +let revision = 0 +let ready = true +const listeners = new Set() + +export function beginAuthContextSync(): number { + revision += 1 + ready = false + for (const listener of listeners) { + listener(false) + } + return revision +} + +export function finishAuthContextSync(syncRevision: number): boolean { + if (syncRevision !== revision) { + return false + } + ready = true + for (const listener of listeners) { + listener(true) + } + return true +} + +export function getAuthContextState(): { revision: number; ready: boolean } { + return { revision, ready } +} + +export function onAuthContextStateChange(listener: Listener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/auth.mts b/src/auth.mts index c8954153..97f9f0ef 100644 --- a/src/auth.mts +++ b/src/auth.mts @@ -1,12 +1,16 @@ -import crypto from 'node:crypto' +import * as vscode from 'vscode' import os from 'node:os' import path from 'node:path' - -import * as vscode from 'vscode' - import { DIAGNOSTIC_SOURCE_STR, EXTENSION_PREFIX } from './util.mts' +import crypto from 'node:crypto' import { getOrganizations } from './api.mts' import type { OrgInfo } from './api.mts' +import { selectOrganization } from './organization-selection.mts' +import { + beginAuthContextSync, + finishAuthContextSync, + getAuthContextState, +} from './auth-context-state.mts' import { createRetiredApiTokenError, isRetiredApiToken, @@ -17,6 +21,11 @@ export type APIConfig = { apiKey: string } +export type AuthenticatedContext = { + accessToken: string + organization: OrgInfo +} + export type SettingsFile = { apiKey?: string | undefined [key: string]: unknown @@ -42,6 +51,7 @@ export async function activate( 'Socket Security needs to login for full functionality' pleaseLoginStatusBar.command = `${EXTENSION_PREFIX}.login` + beginAuthContextSync() let legacyRetiredCredential = false retiredCredentialBlocked = false credentialStateReady = false @@ -55,6 +65,7 @@ export async function activate( vscode.AuthenticationSession > = new Map() let sessionRevision = 0 + let latestSessionSync: Promise | undefined const storedSessionsChanges = new vscode.EventEmitter() @@ -63,15 +74,13 @@ export async function activate( disposables?.push( secrets.onDidChange(e => { if (e.key === API_TOKEN_SECRET_KEY) { - void syncLiveSessionFromSecretStorage().catch(() => {}) + void requestSessionSync() } }), ) - function isRetiredCredential(apiKey: string | undefined): boolean { - return isRetiredApiToken(apiKey) || (!apiKey && legacyRetiredCredential) - } async function syncLiveSessionFromSecretStorage() { const revision = ++sessionRevision + const contextRevision = beginAuthContextSync() credentialStateReady = false let apiKey: string | undefined try { @@ -83,15 +92,46 @@ export async function activate( vscode.AuthenticationSession['accessToken'], vscode.AuthenticationSession >() - const retiredCredential = isRetiredCredential(apiKey) + const retiredCredential = + isRetiredApiToken(apiKey) || (!apiKey && legacyRetiredCredential) const organizations = apiKey && !retiredCredential ? await getOrganizations(apiKey) : undefined - const org = organizations?.organizations.values().next().value - const resolvedSession = resolveStoredSession(apiKey, org, liveSessions) - if (resolvedSession) { - storedSessions.set(resolvedSession.accessToken, resolvedSession) + if (apiKey && organizations) { + const selection = selectOrganization( + vscode.workspace.getConfiguration().get('socket-security.orgSlug'), + organizations.organizations, + ) + if (selection.status === 'selected') { + storedSessions.set( + apiKey, + sessionFromAPIKey( + apiKey, + selection.organization, + liveSessions.get(apiKey), + ), + ) + } else { + storedSessions.set( + apiKey, + sessionFromAPIKey( + apiKey, + { + id: 'socket-token', + slug: '', + name: 'Socket API token', + image: undefined, + plan: '', + }, + liveSessions.get(apiKey), + ), + ) + } } if (revision !== sessionRevision) { + const latestSync = latestSessionSync + if (latestSync) { + await latestSync + } return } retiredCredentialBlocked = retiredCredential @@ -117,11 +157,6 @@ export async function activate( removed.push(liveSessionWithoutStoredSession) } liveSessions = storedSessions - if (retiredCredential) { - pleaseLoginStatusBar.show() - } else if (storedSessions.size > 0) { - pleaseLoginStatusBar.hide() - } if (added.length + changed.length + removed.length > 0) { storedSessionsChanges.fire({ added, @@ -129,6 +164,15 @@ export async function activate( removed, }) } + if (retiredCredential) { + pleaseLoginStatusBar.show() + } + finishAuthContextSync(contextRevision) + } + function requestSessionSync(): Promise { + const sync = syncLiveSessionFromSecretStorage() + latestSessionSync = sync + return sync } //#endregion //#region service glue @@ -175,19 +219,23 @@ export async function activate( } throwIfRetiredApiToken(apiKey) const organizations = await getOrganizations(apiKey) - const org = organizations?.organizations.values().next().value - if (!org) { - throw new Error('No organization found for the provided API key') + if (!organizations) { + throw new Error('Invalid API key') } - await secrets.store(API_TOKEN_SECRET_KEY, apiKey) + beginAuthContextSync() + try { + await secrets.store(API_TOKEN_SECRET_KEY, apiKey) + } catch (error) { + await requestSessionSync() + throw error + } + await requestSessionSync() legacyRetiredCredential = false - await syncLiveSessionFromSecretStorage() const session = liveSessions.get(apiKey) if (!session) { - throw new Error( - 'Socket credentials changed during login. Retry Socket Security Login.', - ) + throw new Error('The API token changed while logging in') } + pleaseLoginStatusBar.hide() return session }, async removeSession(sessionId: string): Promise { @@ -197,9 +245,15 @@ export async function activate( if (!session) { return } - await secrets.delete(API_TOKEN_SECRET_KEY) - legacyRetiredCredential = false - await syncLiveSessionFromSecretStorage() + beginAuthContextSync() + try { + await secrets.delete(API_TOKEN_SECRET_KEY) + legacyRetiredCredential = false + } catch (error) { + await requestSessionSync() + throw error + } + await requestSessionSync() try { pleaseLoginStatusBar.show() } catch {} @@ -207,6 +261,13 @@ export async function activate( }, ) context.subscriptions.push(service) + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('socket-security.orgSlug')) { + void requestSessionSync() + } + }), + ) vscode.commands.registerCommand(`${EXTENSION_PREFIX}.login`, async () => { // An explicit Login must always let the user re-enter a token, even when a // stale or cached session already exists. `createIfNone` only prompts when @@ -222,7 +283,7 @@ export async function activate( } catch {} }) try { - await syncLiveSessionFromSecretStorage() + await requestSessionSync() } catch {} let session try { @@ -238,6 +299,10 @@ export async function activate( } export async function getAPIKey() { + const start = getAuthContextState() + if (!start.ready) { + return undefined + } if (!credentialStateReady) { throw new Error( 'Socket credential storage is unavailable. Retry Socket Security Login.', @@ -249,6 +314,10 @@ export async function getAPIKey() { const session = await vscode.authentication.getSession(EXTENSION_PREFIX, [], { createIfNone: false, }) + const end = getAuthContextState() + if (start.revision !== end.revision || !end.ready) { + return undefined + } if (!credentialStateReady) { throw new Error( 'Socket credential storage is unavailable. Retry Socket Security Login.', @@ -258,7 +327,58 @@ export async function getAPIKey() { throw createRetiredApiTokenError() } throwIfRetiredApiToken(session?.accessToken) - return session?.accessToken + return start.revision === end.revision && end.ready + ? session?.accessToken + : undefined +} + +export async function getAuthenticatedContext(): Promise< + AuthenticatedContext | undefined +> { + const start = getAuthContextState() + if (!start.ready) { + return undefined + } + if (!credentialStateReady) { + throw new Error( + 'Socket credential storage is unavailable. Retry Socket Security Login.', + ) + } + if (retiredCredentialBlocked) { + throw createRetiredApiTokenError() + } + const session = await vscode.authentication.getSession(EXTENSION_PREFIX, [], { + createIfNone: false, + }) + if (!session) { + return undefined + } + throwIfRetiredApiToken(session.accessToken) + if (start.revision !== getAuthContextState().revision) { + return undefined + } + const configuredSlug = vscode.workspace + .getConfiguration() + .get('socket-security.orgSlug') + const organizations = await getOrganizations(session.accessToken) + const currentSession = await vscode.authentication.getSession( + EXTENSION_PREFIX, + [], + { createIfNone: false }, + ) + if ( + start.revision !== getAuthContextState().revision || + currentSession?.accessToken !== session.accessToken + ) { + return undefined + } + const selection = selectOrganization( + configuredSlug, + organizations?.organizations, + ) + return selection.status === 'selected' + ? { accessToken: session.accessToken, organization: selection.organization } + : undefined } /** @@ -377,19 +497,6 @@ export async function readLegacySettings( return {} } -export function resolveStoredSession( - apiKey: string | undefined, - org: OrgInfo | undefined, - liveSessions: Map< - vscode.AuthenticationSession['accessToken'], - vscode.AuthenticationSession - >, -): vscode.AuthenticationSession | undefined { - return apiKey && org - ? sessionFromAPIKey(apiKey, org, liveSessions.get(apiKey)) - : undefined -} - export function sessionFromAPIKey( apiKey: string, org: OrgInfo, @@ -398,7 +505,7 @@ export function sessionFromAPIKey( throwIfRetiredApiToken(apiKey) const account = { id: org.id, - label: `${org.name} (${org.plan})`, + label: org.plan ? `${org.name} (${org.plan})` : org.name, } if ( previousSession?.account.id === account.id && @@ -406,8 +513,8 @@ export function sessionFromAPIKey( ) { return previousSession } - // The id is a bare UUID: `session.id` and `account.id` are readable far more - // widely than `accessToken`, so neither may carry the token. + // The id is a bare UUID: `session.id` and `account.id` are readable by far + // more of the editor than `accessToken` is, so neither may carry the token. return { __proto__: null, accessToken: apiKey, diff --git a/src/data/editor-config.mts b/src/data/editor-config.mts index 72b5f12f..512f595e 100644 --- a/src/data/editor-config.mts +++ b/src/data/editor-config.mts @@ -74,6 +74,11 @@ export function activate(context: vscode.ExtensionContext) { } return { __proto__: null, + getOrgSlug() { + return vscode.workspace + .getConfiguration() + .get('socket-security.orgSlug') + }, getConfigValues, onDependentConfig(sections: string[], fn: Callback) { const listener: Listener = { diff --git a/src/extension.mts b/src/extension.mts index 247f534e..3a207103 100644 --- a/src/extension.mts +++ b/src/extension.mts @@ -6,7 +6,10 @@ import * as vscode from 'vscode' import { activate as activateAuth } from './auth.mts' import { activate as activateEditorConfig } from './data/editor-config.mts' import { activate as activateFiles } from './ui/file.mts' -import { initializeCacheStorage } from './ui/purl-alerts-and-scores/manager.mts' +import { + activatePackageDataInvalidation, + initializeCacheStorage, +} from './ui/purl-alerts-and-scores/manager.mts' // `process.env['INLINED_EXTENSION_VERSION']` is replaced at build time with // the package.json version by the rolldown build (.config/repo/rolldown.config.mts → @@ -19,6 +22,7 @@ import { initializeCacheStorage } from './ui/purl-alerts-and-scores/manager.mts' export async function activate(context: vscode.ExtensionContext) { initializeCacheStorage(context.globalStorageUri.fsPath) activateEditorConfig(context) + activatePackageDataInvalidation(context) await activateAuth(context, context.subscriptions) activateFiles(context) if (vscode.lm?.registerMcpServerDefinitionProvider) { diff --git a/src/organization-selection.mts b/src/organization-selection.mts new file mode 100644 index 00000000..7c49b6ff --- /dev/null +++ b/src/organization-selection.mts @@ -0,0 +1,37 @@ +import type { OrgInfo } from './api.mts' + +export type OrganizationSelection = + | { status: 'missing' } + | { status: 'invalid' } + | { status: 'unavailable' } + | { status: 'inaccessible'; slug: string } + | { status: 'ambiguous'; slug: string } + | { status: 'selected'; organization: OrgInfo } + +export function selectOrganization( + configuredSlug: unknown, + organizations: ReadonlyMap | undefined, +): OrganizationSelection { + if (configuredSlug === undefined || configuredSlug === '') { + return { status: 'missing' } + } + if ( + typeof configuredSlug !== 'string' || + configuredSlug.trim() !== configuredSlug + ) { + return { status: 'invalid' } + } + if (!organizations) { + return { status: 'unavailable' } + } + const matches = Array.from(organizations.values()).filter( + organization => organization.slug === configuredSlug, + ) + if (matches.length === 0) { + return { status: 'inaccessible', slug: configuredSlug } + } + if (matches.length > 1) { + return { status: 'ambiguous', slug: configuredSlug } + } + return { status: 'selected', organization: matches[0]! } +} diff --git a/src/ui/purl-alerts-and-scores/manager.mts b/src/ui/purl-alerts-and-scores/manager.mts index 3ce59aa5..2cbcf6cf 100644 --- a/src/ui/purl-alerts-and-scores/manager.mts +++ b/src/ui/purl-alerts-and-scores/manager.mts @@ -1,4 +1,5 @@ import type { SimPURL } from '../externals/parse-externals.mts' +import * as vscode from 'vscode' import { logger } from '../../infra/log.mts' import path from 'node:path' import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' @@ -11,15 +12,37 @@ import type { PackageScoreAndAlerts } from '../../api.mts' import { safeDeleteSync } from '@socketsecurity/lib/fs/safe' import { isObject } from '@socketsecurity/lib/objects/predicates' import { worstArtifactsByPurl } from './select-artifacts.mts' +import { onAuthContextStateChange } from '../../auth-context-state.mts' export let cacheDirectory: string | undefined +export function activatePackageDataInvalidation( + context: vscode.ExtensionContext, +) { + context.subscriptions.push( + new vscode.Disposable( + onAuthContextStateChange(ready => { + if (ready) { + PURLDataCache.singleton.resume() + } else { + clearPackageData() + } + }), + ), + ) +} + export function clearCache() { if (cacheDirectory) { safeDeleteSync(cacheDirectory) } } +export function clearPackageData() { + PURLDataCache.singleton.clear(false) + clearCache() +} + export function initializeCacheStorage(globalStoragePath: string) { cacheDirectory = path.join(globalStoragePath, 'package-data') } @@ -129,6 +152,9 @@ export class PURLPackageData { watcher(this) } } + notifyContextChanged() { + this.#notifyWatchers() + } } export class PURLDataCache { static singleton: PURLDataCache = new PURLDataCache() @@ -138,7 +164,51 @@ export class PURLDataCache { #pkgsNeedingUpdate: Set = new Set() // in-flight PURLs #currentPendingUpdates: Set = new Set() + #controllers: Set = new Set() + #generation = 0 + #contextReady = true private constructor() {} + clear(requeueActive = true) { + this.#generation += 1 + if (!requeueActive) { + this.#contextReady = false + } + for (const controller of this.#controllers) { + controller.abort(new Error('Authentication context changed')) + } + this.#controllers.clear() + this.#pkgsNeedingUpdate.clear() + this.#currentPendingUpdates.clear() + const activePurls = Array.from(this.#pkgData.keys()) + for (const data of this.#pkgData.values()) { + data.pkgData = undefined + data.error = undefined + data.mtime = -Infinity + data.notifyContextChanged() + const filePath = data.filepath() + if (filePath) { + try { + safeDeleteSync(filePath) + } catch {} + } + } + if (requeueActive) { + for (let i = 0, { length } = activePurls; i < length; i += 1) { + this.queueUpdate(activePurls[i]!) + } + } + } + resume() { + if (this.#contextReady) { + return + } + this.#contextReady = true + const pendingPurls = Array.from(this.#pkgsNeedingUpdate) + this.#pkgsNeedingUpdate.clear() + for (const purl of new Set([...this.#pkgData.keys(), ...pendingPurls])) { + this.queueUpdate(purl) + } + } watch(purl: SimPURL): PURLPackageData { let pkgDataForPURL = this.#pkgData.get(purl) if (!pkgDataForPURL) { @@ -158,16 +228,26 @@ export class PURLDataCache { } const thisIsTheBusForTheseUpdates = this.#pkgsNeedingUpdate.size === 0 this.#pkgsNeedingUpdate.add(purl) + if (!this.#contextReady) { + return + } // logger.info(`is bus`, thisIsTheBusForTheseUpdates, `for`, purl, `pending updates:`, this.#currentPendingUpdates.size, `queued updates:`, this.#pkgsNeedingUpdate.size); if (!thisIsTheBusForTheseUpdates) { return // already scheduled a bus trip } const controller = new AbortController() + const generation = this.#generation + this.#controllers.add(controller) const abort = controller.abort.bind(controller) const timer = setTimeout(abort, this.timeout) void (async () => { await Promise.resolve() + if (generation !== this.#generation) { + this.#controllers.delete(controller) + clearTimeout(timer) + return + } const thesePendingUpdates = new Set(Array.from(this.#pkgsNeedingUpdate)) this.#pkgsNeedingUpdate.clear() // oxlint-disable-next-line socket/prefer-cached-for-loop -- iterating a Set. @@ -192,12 +272,18 @@ export class PURLDataCache { } controller.signal.addEventListener('abort', () => { clearTimeout(timer) + if (generation !== this.#generation) { + return + } bailPendingCacheEntries( controller.signal.reason || new Error('Aborted'), ) }) try { const apiKey = await getAPIKey() + if (controller.signal.aborted || generation !== this.#generation) { + return + } // logger.info(`Requesting Socket API for PURLs: ${[...thesePendingUpdates].join(', ')}`) // Bound the SDK request with the same ceiling the AbortController timer // uses so a hung connection can't leave entries pending forever. @@ -256,6 +342,7 @@ export class PURLDataCache { bailPendingCacheEntries(reason) } finally { clearTimeout(timer) + this.#controllers.delete(controller) } })() } diff --git a/test/repo/unit/api-public.test.mts b/test/repo/unit/api-public.test.mts index c2bed499..a15702c1 100644 --- a/test/repo/unit/api-public.test.mts +++ b/test/repo/unit/api-public.test.mts @@ -26,8 +26,8 @@ const requestCases = [ { name: 'authenticated', apiKey: 'sktsec_test_token', - origin: 'https://api.socket.dev', - path: '/v0/purl', + origin: proxyOrigin, + path: '/batch', query: { alerts: 'true', compact: 'false' }, }, ] @@ -257,8 +257,8 @@ describe('public package response bounds', () => { describe('authenticated package response bounds', () => { test('rejects identity responses beyond the SDK wire-byte limit', async () => { - const scope = nock('https://api.socket.dev') - .post('/v0/purl') + const scope = nock(proxyOrigin) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, Buffer.alloc(10 * 1024 * 1024 + 1, 0x20), { 'content-encoding': 'identity', @@ -281,14 +281,14 @@ describe('authenticated package response bounds', () => { purls.push(`pkg:npm/example-authenticated-${index}`) } const artifacts = [publicArtifact(purls[0]!), publicArtifact(purls[1024]!)] - const first = nock('https://api.socket.dev') - .post('/v0/purl', { + const first = nock(proxyOrigin) + .post('/batch', { components: purls.slice(0, 1024).map(purl => ({ purl })), }) .query({ alerts: 'true', compact: 'false' }) .reply(200, JSON.stringify(artifacts[0])) - const second = nock('https://api.socket.dev') - .post('/v0/purl', { components: [{ purl: purls[1024] }] }) + const second = nock(proxyOrigin) + .post('/batch', { components: [{ purl: purls[1024] }] }) .query({ alerts: 'true', compact: 'false' }) .reply(200, JSON.stringify(artifacts[1])) const seen: PackageScoreAndAlerts[] = [] diff --git a/test/repo/unit/api.test.mts b/test/repo/unit/api.test.mts index 2cb2a2d4..388adfa3 100644 --- a/test/repo/unit/api.test.mts +++ b/test/repo/unit/api.test.mts @@ -1,7 +1,7 @@ /** * @file Unit tests for the Socket API layer (src/api.mts), which wraps * `@socketsecurity/sdk`. Every request is exercised against a nock-mocked - * api.socket.dev — no real network (the fleet setup fails net-connect + * purl-api.socket.dev — no real network (the test setup fails net-connect * closed), and no owned-infrastructure mocking. We assert both the request * the SDK sends (path, query, body) and how api.ts reshapes the response. */ @@ -19,10 +19,11 @@ import type { PackageScoreAndAlerts } from '../../../src/api.mts' import type { SimPURL } from '../../../src/ui/externals/parse-externals.mts' const API_ORIGIN = 'https://api.socket.dev' +const PURL_ORIGIN = 'https://purl-api.socket.dev' const TOKEN = 'sktsec_test_token' const nullPayloadValue: unknown = JSON.parse('null') -// Build a full artifact line as the /v0/purl NDJSON stream emits it. +// Build a full artifact line as the PURL batch NDJSON stream emits it. function artifactLine(purl: SimPURL, overall: number): string { const artifact: PackageScoreAndAlerts = { alerts: [], @@ -83,6 +84,7 @@ describe('api getOrganizations', () => { name: 'Acme', image: 'https://img/acme.png', plan: 'enterprise', + slug: 'acme', }, ], ]), @@ -104,6 +106,7 @@ describe('api getOrganizations', () => { expect(result?.organizations.get('org-2')).toEqual({ id: 'org-2', + slug: 'nully', name: '', image: undefined, plan: 'some-future-plan', @@ -128,8 +131,8 @@ describe('api getOrganizations', () => { describe('api streamPackageScores', () => { test('accepts the cache timeout within the SDK transport limit', async () => { const purl = 'pkg:npm/example-cache-timeout' as SimPURL - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, `${artifactLine(purl, 0.5)}\n`) @@ -159,7 +162,7 @@ describe('api streamPackageScores', () => { cause: new ResponseError( response, TOKEN, - `${API_ORIGIN}/?token=${TOKEN}`, + `${PURL_ORIGIN}/?token=${TOKEN}`, ), }), ) @@ -217,8 +220,8 @@ describe('api streamPackageScores', () => { { version: 42 }, ] for (let index = 0, { length } = malformed; index < length; index += 1) { - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply( 200, @@ -239,8 +242,8 @@ describe('api streamPackageScores', () => { async identity => { const purl = 'pkg:npm/example-invalid-identity' as SimPURL const invalid = { ...JSON.parse(artifactLine(purl, 0.1)), ...identity } - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, `${JSON.stringify(invalid)}\n${artifactLine(purl, 0.9)}\n`) @@ -276,8 +279,8 @@ describe('api streamPackageScores', () => { }, ], } - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, `${JSON.stringify(artifact)}\n`) @@ -295,7 +298,7 @@ describe('api streamPackageScores', () => { status, error: TOKEN, cause: `Authorization: Bearer ${TOKEN}`, - url: `${API_ORIGIN}/v0/purl?token=${TOKEN}`, + url: `${PURL_ORIGIN}/batch?token=${TOKEN}`, }) expect(error).toMatchObject({ @@ -311,10 +314,12 @@ describe('api streamPackageScores', () => { 'rejects an SDK HTTP %i failure without response details', async status => { const purl = 'pkg:npm/example-denied@1.0.0' as SimPURL - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) - .reply(status, { error: { message: `Authorization: Bearer ${TOKEN}` } }) + .reply(status, { + error: { message: `Authorization: Bearer ${TOKEN}` }, + }) const result = collectPackageScores(streamPackageScores(TOKEN, [purl])) await expect(result).rejects.toMatchObject({ @@ -339,8 +344,8 @@ describe('api streamPackageScores', () => { type: 'npm', alerts: [{ action: 'warn', type, severity: 'medium' }], }) - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply( 200, @@ -371,8 +376,8 @@ describe('api streamPackageScores', () => { `{"inputPurl":"${purl}","name":"example-invalid-score","type":"npm","alerts":[],"score":${score}}`, ) .join('\n') - const scope = nock(API_ORIGIN) - .post('/v0/purl') + const scope = nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, `${lines}\n${artifactLine(purl, 0)}\n`) @@ -386,8 +391,8 @@ describe('api streamPackageScores', () => { test('yields one score+alerts object per artifact line', async () => { const purlA = 'pkg:npm/left-pad@1.0.0' as SimPURL const purlB = 'pkg:npm/right-pad@2.0.0' as SimPURL - nock(API_ORIGIN) - .post('/v0/purl') + nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, `${artifactLine(purlA, 42)}\n${artifactLine(purlB, 99)}\n`) @@ -400,11 +405,11 @@ describe('api streamPackageScores', () => { expect(seen.map(s => s.score.overall)).toEqual([42, 99]) }) - test('requests /v0/purl with the pending purls in the body', async () => { + test('requests /batch with the pending purls in the body', async () => { const purl = 'pkg:npm/express@4.0.0' as SimPURL let sentBody: unknown - nock(API_ORIGIN) - .post('/v0/purl', body => { + nock(PURL_ORIGIN) + .post('/batch', body => { sentBody = body return true }) @@ -438,8 +443,8 @@ describe('api streamPackageScores', () => { _type: 'purlError', value: { error: 'Package not found', inputPurl: purl }, }) - nock(API_ORIGIN) - .post('/v0/purl') + nock(PURL_ORIGIN) + .post('/batch') .query({ alerts: 'true', compact: 'false' }) .reply(200, `${artifactLine(purl, 77)}\n${summaryLine}\n${errorLine}\n`) diff --git a/test/repo/unit/auth-lifecycle.test.mts b/test/repo/unit/auth-lifecycle.test.mts index fb5e6ed2..95d13b6b 100644 --- a/test/repo/unit/auth-lifecycle.test.mts +++ b/test/repo/unit/auth-lifecycle.test.mts @@ -2,8 +2,20 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import * as vscode from 'vscode' import { getOrganizations } from '../../../src/api.mts' -import { activate, API_TOKEN_SECRET_KEY } from '../../../src/auth.mts' -import { resetStubAuthState } from '../../stubs/vscode.mts' +import { + activate, + API_TOKEN_SECRET_KEY, + getAPIKey, + getAuthenticatedContext, +} from '../../../src/auth.mts' +import { getAuthContextState } from '../../../src/auth-context-state.mts' +import { + fireStubConfigurationChange, + resetStubConfigurationChanges, + resetStubAuthState, + setStubGetSessionResult, + setStubWorkspaceState, +} from '../../stubs/vscode.mts' import type { OrganizationsRecord } from '../../../src/api.mts' @@ -23,6 +35,7 @@ const exampleOrganizations: OrganizationsRecord = { 'example-org', { id: 'example-org', + slug: 'example-org', image: undefined, name: 'Example Organization', plan: 'enterprise', @@ -48,6 +61,10 @@ class LifecycleSecretStorage { beforeEach(() => { resetStubAuthState() + resetStubConfigurationChanges() + setStubWorkspaceState({ + configuration: { 'socket-security.orgSlug': 'example-org' }, + }) vi.mocked(getOrganizations) .mockReset() .mockResolvedValue(exampleOrganizations) @@ -100,9 +117,10 @@ describe('authentication lifecycle', () => { lookup.resolve({ organizations: new Map([ [ - 'example-updated-org', + 'example-org', { id: 'example-updated-org', + slug: 'example-org', image: undefined, name: 'Updated Example Organization', plan: 'free', @@ -147,6 +165,100 @@ describe('authentication lifecycle', () => { expect(changes).not.toHaveBeenCalled() }) + test('keeps token-only legacy sessions when no organization is configured', async () => { + setStubWorkspaceState({ configuration: {} }) + const provider = await activateProvider(new LifecycleSecretStorage()) + + const [session] = await provider.getSessions(undefined, {}) + + expect(session?.accessToken).toBe(EXAMPLE_TOKEN) + expect(session?.account).toEqual({ + id: 'socket-token', + label: 'Socket API token', + }) + setStubGetSessionResult(() => + Promise.resolve({ accessToken: EXAMPLE_TOKEN }), + ) + await expect(getAuthenticatedContext()).resolves.toBeUndefined() + }) + + test('refreshes provider account details when the configured org changes', async () => { + const secrets = new LifecycleSecretStorage() + const provider = await activateProvider(secrets) + const [session] = await provider.getSessions(undefined, {}) + const changes = vi.fn() + provider.onDidChangeSessions(changes) + setStubWorkspaceState({ + configuration: { 'socket-security.orgSlug': 'missing-org' }, + }) + fireStubConfigurationChange('socket-security.orgSlug') + await vi.waitFor(async () => { + expect((await provider.getSessions(undefined, {}))[0]?.account).toEqual({ + id: 'socket-token', + label: 'Socket API token', + }) + }) + + expect(changes).toHaveBeenCalledWith({ + added: [], + changed: [ + expect.objectContaining({ + account: { id: 'socket-token', label: 'Socket API token' }, + }), + ], + removed: [], + }) + expect(session).toBeDefined() + }) + + test('rejects an organization lookup after the credential changes', async () => { + const secrets = new LifecycleSecretStorage() + await activateProvider(secrets) + setStubGetSessionResult(() => + Promise.resolve({ accessToken: EXAMPLE_TOKEN }), + ) + const lookup = Promise.withResolvers() + vi.mocked(getOrganizations).mockImplementationOnce(() => lookup.promise) + + const context = getAuthenticatedContext() + await vi.waitFor(() => { + expect(getOrganizations).toHaveBeenCalled() + }) + secrets.changes.fire({ key: API_TOKEN_SECRET_KEY }) + lookup.resolve(exampleOrganizations) + + await expect(context).resolves.toBeUndefined() + }) + + test('hides the previous credential until a replacement sync finishes', async () => { + const secrets = new LifecycleSecretStorage() + await activateProvider(secrets) + setStubGetSessionResult(() => + Promise.resolve({ accessToken: EXAMPLE_TOKEN }), + ) + const replacementLookup = Promise.withResolvers< + OrganizationsRecord | undefined + >() + const started = Promise.withResolvers() + vi.mocked(getOrganizations).mockImplementationOnce(() => { + started.resolve() + return replacementLookup.promise + }) + + secrets.changes.fire({ key: API_TOKEN_SECRET_KEY }) + await started.promise + + await expect(getAuthenticatedContext()).resolves.toBeUndefined() + replacementLookup.resolve(exampleOrganizations) + await replacementLookup.promise + await vi.waitFor(async () => { + expect(await getAuthenticatedContext()).toEqual({ + accessToken: EXAMPLE_TOKEN, + organization: exampleOrganizations.organizations.get('example-org'), + }) + }) + }) + test('keeps the current session when a stale session is removed', async () => { const secrets = new LifecycleSecretStorage() const provider = await activateProvider(secrets) @@ -179,6 +291,7 @@ describe('authentication lifecycle', () => { }) test('validates the submitted token independently of input validation', async () => { + setStubWorkspaceState({ configuration: {} }) const secrets = new LifecycleSecretStorage() const provider = await activateProvider(secrets) const sessions = await provider.getSessions(undefined, {}) @@ -196,6 +309,40 @@ describe('authentication lifecycle', () => { expect(await provider.getSessions(undefined, {})).toEqual(sessions) }) + test('waits for the store-triggered session sync before completing login', async () => { + const secrets = new LifecycleSecretStorage() + const provider = await activateProvider(secrets) + const token = 'sktsec_example_new_token' + const syncLookup = Promise.withResolvers() + const syncStarted = Promise.withResolvers() + let lookupCount = 0 + vi.mocked(getOrganizations).mockImplementation(async requestedToken => { + if (requestedToken !== token) { + return undefined + } + lookupCount += 1 + if (lookupCount === 1) { + return exampleOrganizations + } + syncStarted.resolve() + return syncLookup.promise + }) + vi.mocked(vscode.window.showInputBox).mockResolvedValue(token) + + const login = provider.createSession([], {}) + await syncStarted.promise + + expect(getAuthContextState().ready).toBe(false) + await expect(getAPIKey()).resolves.toBeUndefined() + + syncLookup.resolve(exampleOrganizations) + const session = await login + + expect(getAuthContextState().ready).toBe(true) + expect(session.accessToken).toBe(token) + expect((await provider.getSessions(undefined, {}))[0]).toBe(session) + }) + test('removes a session when its token has no organizations', async () => { const secrets = new LifecycleSecretStorage() const provider = await activateProvider(secrets) @@ -233,5 +380,6 @@ describe('authentication lifecycle', () => { expect(secrets.token).toBeUndefined() expect(await provider.getSessions(undefined, {})).toEqual([]) + expect(getAuthContextState().ready).toBe(true) }) }) diff --git a/test/repo/unit/auth.test.mts b/test/repo/unit/auth.test.mts index 8eedab8e..2dea25c2 100644 --- a/test/repo/unit/auth.test.mts +++ b/test/repo/unit/auth.test.mts @@ -161,6 +161,7 @@ describe('legacy token migration', () => { describe('session identifiers', () => { const org: OrgInfo = { id: 'org-1', + slug: 'acme', image: undefined, name: 'Acme', plan: 'enterprise', diff --git a/test/repo/unit/define-guarded.test.mts b/test/repo/unit/define-guarded.test.mts deleted file mode 100644 index e13b305f..00000000 --- a/test/repo/unit/define-guarded.test.mts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @file Tests for the guarded compile-time define plugin - * (.config/fleet/rolldown/define-guarded.mts). Locks in that a single dotted - * define - * key matches dot, single-quote, and double-quote member access alike (TS - * forces quoted bracket access on `process.env`), that TS source is parsed - * (not rejected as JS), and that write / dynamic positions are left - * untouched. - */ - -import { describe, expect, test } from 'vitest' - -import { defineGuardedPlugin } from '../../../.config/fleet/rolldown/define-guarded.mts' - -// Exported `function` declaration: satisfies both fleet rules — -// socket/prefer-function-declaration (no module-scope arrow) and -// socket/export-top-level-functions (top-level functions must be exported). -export function run(code: string, id = 'src/example.ts'): string | undefined { - const plugin = defineGuardedPlugin({ - 'process.env.INLINED_EXTENSION_VERSION': JSON.stringify('2.0.3'), - }) - // rolldown's transform hook signature is (code, id); call it directly. - const transform = plugin.transform as ( - this: unknown, - code: string, - id: string, - ) => { code: string } | undefined - const result = transform.call({}, code, id) - return result?.code -} - -describe('defineGuardedPlugin', () => { - test('rewrites dot access', () => { - const out = run('const a = process.env.INLINED_EXTENSION_VERSION') - expect(out).toContain('"2.0.3"') - expect(out).not.toContain('INLINED_EXTENSION_VERSION') - }) - - test('rewrites single-quote bracket access', () => { - const out = run("const b = process.env['INLINED_EXTENSION_VERSION']") - expect(out).toContain('"2.0.3"') - expect(out).not.toContain('INLINED_EXTENSION_VERSION') - }) - - test('rewrites double-quote bracket access', () => { - const out = run('const c = process.env["INLINED_EXTENSION_VERSION"]') - expect(out).toContain('"2.0.3"') - expect(out).not.toContain('INLINED_EXTENSION_VERSION') - }) - - test('parses TypeScript source (type annotations do not abort the rewrite)', () => { - const out = run( - 'export function f(ctx: vscode.ExtensionContext): string {\n' + - " return process.env['INLINED_EXTENSION_VERSION']\n" + - '}\n', - ) - expect(out).toContain('"2.0.3"') - }) - - test('parses .tsx source', () => { - const out = run( - 'const e =
{process.env["INLINED_EXTENSION_VERSION"]}
', - 'src/example.tsx', - ) - expect(out).toContain('"2.0.3"') - }) - - test('leaves an assignment target untouched', () => { - const code = "process.env['INLINED_EXTENSION_VERSION'] = 'x'" - expect(run(code)).toBeUndefined() - }) - - test('leaves dynamic computed access untouched', () => { - const code = - 'const k = "INLINED_EXTENSION_VERSION"\nconst d = process.env[k]' - expect(run(code)).toBeUndefined() - }) - - test('does not rewrite an unrelated env var', () => { - const code = "const u = process.env['SOMETHING_ELSE']" - expect(run(code)).toBeUndefined() - }) -}) diff --git a/test/repo/unit/organization-selection.test.mts b/test/repo/unit/organization-selection.test.mts new file mode 100644 index 00000000..eb45c912 --- /dev/null +++ b/test/repo/unit/organization-selection.test.mts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest' + +import { selectOrganization } from '../../../src/organization-selection.mts' +import type { OrgInfo } from '../../../src/api.mts' + +const organization: OrgInfo = { + id: 'org-id', + slug: 'acme', + name: 'Acme', + image: undefined, + plan: 'enterprise', +} + +describe('selectOrganization', () => { + test('requires an explicit slug', () => { + expect( + selectOrganization(undefined, new Map([['org-id', organization]])), + ).toEqual({ status: 'missing' }) + expect(selectOrganization('', new Map([['org-id', organization]]))).toEqual( + { status: 'missing' }, + ) + expect( + selectOrganization(' ', new Map([['org-id', organization]])), + ).toEqual({ status: 'invalid' }) + }) + + test('selects only an exact accessible slug', () => { + const organizations = new Map([['org-id', organization]]) + expect(selectOrganization('acme', organizations)).toEqual({ + status: 'selected', + organization, + }) + expect(selectOrganization('org-id', organizations)).toEqual({ + status: 'inaccessible', + slug: 'org-id', + }) + expect(selectOrganization('Acme', organizations)).toEqual({ + status: 'inaccessible', + slug: 'Acme', + }) + }) + + test('reports unavailable, inaccessible, and ambiguous choices', () => { + expect(selectOrganization('acme', undefined)).toEqual({ + status: 'unavailable', + }) + expect( + selectOrganization('other', new Map([['org-id', organization]])), + ).toEqual({ status: 'inaccessible', slug: 'other' }) + expect( + selectOrganization( + 'acme', + new Map([ + ['org-id', organization], + ['org-id-2', { ...organization, id: 'org-id-2' }], + ]), + ), + ).toEqual({ status: 'ambiguous', slug: 'acme' }) + }) +}) diff --git a/test/repo/unit/rolldown.config-extension-version.test.mts b/test/repo/unit/rolldown.config-extension-version.test.mts new file mode 100644 index 00000000..31bd0ede --- /dev/null +++ b/test/repo/unit/rolldown.config-extension-version.test.mts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import { defineExtensionVersionPlugin } from "../../../.config/repo/rolldown.config.mts"; + +export function rewriteInlinedExtensionVersion( + code: string, + version = "2.0.3", +): string { + const plugin = defineExtensionVersionPlugin(version); + const transform = plugin.transform; + if (typeof transform !== "function") { + throw new TypeError("Extension version plugin must provide a transform."); + } + const result: unknown = Reflect.apply(transform, {}, [ + code, + "src/example.mts", + ]); + if (typeof result !== "string") { + throw new TypeError( + "Extension version plugin did not return transformed code.", + ); + } + return result; +} + +describe("defineExtensionVersionPlugin", () => { + test("rewrites single-quoted bracket access", () => { + expect( + rewriteInlinedExtensionVersion( + "const version = process.env['INLINED_EXTENSION_VERSION']", + ), + ).toBe('const version = "2.0.3"'); + }); + + test("rewrites double-quoted bracket access", () => { + expect( + rewriteInlinedExtensionVersion( + 'const version = process.env["INLINED_EXTENSION_VERSION"]', + ), + ).toBe('const version = "2.0.3"'); + }); + + test("rewrites TypeScript source", () => { + const source = + "export function readVersion(): string {\n" + + " return process.env['INLINED_EXTENSION_VERSION']\n" + + "}\n"; + expect(rewriteInlinedExtensionVersion(source)).toBe( + 'export function readVersion(): string {\n return "2.0.3"\n}\n', + ); + }); + + test("leaves dot and dynamic access untouched", () => { + const source = + "const a = process.env.INLINED_EXTENSION_VERSION\n" + + 'const key = "INLINED_EXTENSION_VERSION"\n' + + "const b = process.env[key]"; + expect(rewriteInlinedExtensionVersion(source)).toBe(source); + }); + + test("leaves unrelated environment variables untouched", () => { + const source = "const value = process.env['OTHER_VARIABLE']"; + expect(rewriteInlinedExtensionVersion(source)).toBe(source); + }); +}); diff --git a/test/repo/unit/ui/purl-alerts-and-scores/manager-cache-requests.test.mts b/test/repo/unit/ui/purl-alerts-and-scores/manager-cache-requests.test.mts index 4b3d5daf..ab5cf5e1 100644 --- a/test/repo/unit/ui/purl-alerts-and-scores/manager-cache-requests.test.mts +++ b/test/repo/unit/ui/purl-alerts-and-scores/manager-cache-requests.test.mts @@ -263,4 +263,75 @@ describe('PURLDataCache request lifecycle', () => { await flushRequests() expect(entry.pkgData).toEqual(packageScore(purl)) }) + + test('notifies live entries and keeps them bound during context reset', async () => { + const purl = 'pkg:npm/example-context-reset@1.0.0' + const entry = cache.watch(purl) + await flushRequests() + const watcher = vi.fn() + entry.subscribe(watcher) + + cache.clear() + + expect(entry.pkgData).toBeUndefined() + expect(entry.isStale()).toBe(true) + expect(watcher).toHaveBeenCalledTimes(1) + await flushRequests() + expect(entry.pkgData).toEqual(packageScore(purl)) + }) + + test('does not let an old queue drain work from a new generation', async () => { + const firstPurl = 'pkg:npm/example-old-generation@1.0.0' + const secondPurl = 'pkg:npm/example-new-generation@1.0.0' + cache.watch(firstPurl) + cache.clear() + cache.watch(secondPurl) + await flushRequests() + + expect(streamPackageScores).toHaveBeenCalledTimes(1) + const requestedPurls = streamPackageScores.mock.calls[0]?.[1] + expect(requestedPurls).toContain(firstPurl) + expect(requestedPurls).toContain(secondPurl) + expect(streamPackageScores.mock.calls[0]?.[2]?.signal?.aborted).toBe(false) + }) + + test('holds active PURLs until the authentication context is ready', async () => { + const purl = 'pkg:npm/example-auth-pending@1.0.0' + const entry = cache.watch(purl) + await flushRequests() + streamPackageScores.mockClear() + + cache.clear(false) + cache.watch(purl) + await flushRequests() + expect(streamPackageScores).not.toHaveBeenCalled() + + cache.resume() + await flushRequests() + + expect(streamPackageScores).toHaveBeenCalledTimes(1) + expect(streamPackageScores.mock.calls[0]?.[1]).toContain(purl) + expect(entry.pkgData).toEqual(packageScore(purl)) + }) + + test('ignores a held response from the invalidated credential', async () => { + const oldResponse = Promise.withResolvers() + streamPackageScores.mockImplementationOnce(async function* () { + yield await oldResponse.promise + }) + const purl = 'pkg:npm/example-invalidated-credential@1.0.0' + const entry = cache.watch(purl) + await flushRequests() + + cache.clear(false) + cache.resume() + await flushRequests() + expect(entry.pkgData).toEqual(packageScore(purl)) + + oldResponse.resolve(packageScore(purl, 1)) + await flushRequests() + + expect(entry.pkgData).toEqual(packageScore(purl)) + expect(entry.pkgData?.score.overall).toBe(90) + }) }) diff --git a/test/repo/vitest.setup.mts b/test/repo/vitest.setup.mts new file mode 100644 index 00000000..b3bf3bbe --- /dev/null +++ b/test/repo/vitest.setup.mts @@ -0,0 +1,3 @@ +import nock from "nock"; + +nock.disableNetConnect(); diff --git a/test/stubs/vscode.mts b/test/stubs/vscode.mts index 28a4707e..02028552 100644 --- a/test/stubs/vscode.mts +++ b/test/stubs/vscode.mts @@ -25,6 +25,20 @@ const state: StubWorkspaceState = { isTrusted: true, workspaceFolders: undefined, } +let configurationChanges: EventEmitter<{ + affectsConfiguration(section: string): boolean +}> + +export function fireStubConfigurationChange(section: string): void { + configurationChanges.fire({ + affectsConfiguration: candidate => candidate === section, + }) +} + +export function resetStubConfigurationChanges(): void { + configurationChanges.dispose() + configurationChanges = new EventEmitter() +} export function setStubWorkspaceState(next: Partial): void { state.configuration = next.configuration ?? {} @@ -155,6 +169,8 @@ export class EventEmitter { } } +configurationChanges = new EventEmitter() + // oxlint-disable-next-line socket/const-enum-shape -- VS Code API export const ConfigurationTarget = { // oxlint-disable-next-line socket/const-enum-shape -- VS Code API @@ -213,6 +229,9 @@ export const workspace = { get isTrusted(): boolean { return state.isTrusted }, + get onDidChangeConfiguration() { + return configurationChanges.event + }, onDidGrantWorkspaceTrust(_listener: () => void): Disposable { return new Disposable(() => {}) }, diff --git a/tsconfig.json b/tsconfig.json index b3cce1b7..4de6d333 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "./.config/fleet/tsconfig.base.json" + "extends": "./.config/repo/tsconfig.json" }