Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions extensions/vscode/src/api/types/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ export type TestResult = {
user: CredentialUser | null;
url: string | null;
serverType: ServerType | null;
// When true, Snowflake connections are configured on the system and Token
// Authentication should be hidden (it won't work from within Snowflake).
hasSnowflakeConnections: boolean;
error: AgentError | null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { ServerType } from "src/api/types/contentRecords";
import { newConnectCredential } from "./newConnectCredential";
import {
newConnectCredential,
getAuthMethod,
AuthMethod,
AuthMethodName,
} from "./newConnectCredential";

// Mock the MultiStepInput module
vi.mock("./multiStepHelper", () => {
Expand Down Expand Up @@ -200,3 +205,35 @@ describe("newConnectCredential API calls", () => {
);
});
});

describe("getAuthMethod", () => {
test("returns API_KEY for AuthMethodName.API_KEY", () => {
expect(getAuthMethod(AuthMethodName.API_KEY)).toBe(AuthMethod.API_KEY);
});

test("returns TOKEN for AuthMethodName.TOKEN", () => {
expect(getAuthMethod(AuthMethodName.TOKEN)).toBe(AuthMethod.TOKEN);
});

test("returns SNOWFLAKE_CONN for AuthMethodName.SNOWFLAKE_CONN", () => {
expect(getAuthMethod(AuthMethodName.SNOWFLAKE_CONN)).toBe(
AuthMethod.SNOWFLAKE_CONN,
);
});
});

describe("AuthMethod enum", () => {
test("has correct values", () => {
expect(AuthMethod.API_KEY).toBe("apiKey");
expect(AuthMethod.TOKEN).toBe("token");
expect(AuthMethod.SNOWFLAKE_CONN).toBe("snowflakeConnection");
});
});

describe("AuthMethodName enum", () => {
test("has correct display names", () => {
expect(AuthMethodName.API_KEY).toBe("API Key");
expect(AuthMethodName.TOKEN).toBe("Token Authentication");
expect(AuthMethodName.SNOWFLAKE_CONN).toBe("Snowflake Connection");
});
});
95 changes: 64 additions & 31 deletions extensions/vscode/src/multiStepInputs/newConnectCredential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,26 @@ import {
TokenAuthResult,
} from "src/auth/ConnectAuthTokenActivator";

enum AuthMethod {
export enum AuthMethod {
API_KEY = "apiKey",
TOKEN = "token",
SNOWFLAKE_CONN = "snowflakeConnection",
}

enum AuthMethodName {
export enum AuthMethodName {
API_KEY = "API Key",
TOKEN = "Token Authentication",
SNOWFLAKE_CONN = "Snowflake Connection",
}

const getAuthMethod = (authMethodName: AuthMethodName) => {
export const getAuthMethod = (authMethodName: AuthMethodName) => {
switch (authMethodName) {
case AuthMethodName.API_KEY:
return AuthMethod.API_KEY;
case AuthMethodName.TOKEN:
return AuthMethod.TOKEN;
case AuthMethodName.SNOWFLAKE_CONN:
return AuthMethod.SNOWFLAKE_CONN;
}
};

Expand All @@ -76,6 +80,9 @@ export async function newConnectCredential(
let serverType: ServerType = ServerType.CONNECT;
const productName: ProductName = ProductName.CONNECT;
let authMethod: AuthMethod = AuthMethod.TOKEN;
// When true, Snowflake connections are available on the system (we're inside Snowflake)
// and Token Authentication should be hidden (browser can't reach internal URLs).
let hasSnowflakeConnections: boolean = false;

enum step {
INPUT_SERVER_URL = "inputServerUrl",
Expand Down Expand Up @@ -106,6 +113,10 @@ export async function newConnectCredential(
return authMethod === AuthMethod.API_KEY;
};

const isSnowflakeConn = (authMethod: AuthMethod) => {
return authMethod === AuthMethod.SNOWFLAKE_CONN;
};

const isValidTokenAuth = () => {
// for token authentication, require token and privateKey
return (
Expand All @@ -117,17 +128,17 @@ export async function newConnectCredential(
};

const isValidApiKeyAuth = () => {
// for API key authentication, require apiKey
return (
isConnect(serverType) &&
isApiKey(authMethod) &&
isString(state.data.apiKey)
);
// for API key authentication, require apiKey (works for both Connect and Snowflake)
return isApiKey(authMethod) && isString(state.data.apiKey);
};

const isValidSnowflakeAuth = () => {
// for Snowflake, require snowflakeConnection
return isSnowflake(serverType) && isString(state.data.snowflakeConnection);
// for Snowflake Connection authentication, require snowflakeConnection
return (
isSnowflake(serverType) &&
isSnowflakeConn(authMethod) &&
isString(state.data.snowflakeConnection)
);
};

// ***************************************************************
Expand Down Expand Up @@ -272,6 +283,8 @@ export async function newConnectCredential(
// serverType will be overwritten if it is snowflake
serverType = testResult.data.serverType;
}
// Capture whether we're inside a Snowflake environment
hasSnowflakeConnections = testResult.data.hasSnowflakeConnections;
} catch (e) {
return Promise.resolve({
message: `Error: Invalid URL (unable to validate connectivity with Server URL - ${getMessageFromError(e)}).`,
Expand All @@ -286,14 +299,6 @@ export async function newConnectCredential(

state.data.url = formatURL(resp.trim());

if (isSnowflake(serverType)) {
return {
name: step.INPUT_SNOWFLAKE_CONN,
step: (input: MultiStepInput) =>
steps[step.INPUT_SNOWFLAKE_CONN](input, state),
};
}

return {
name: step.INPUT_AUTH_METHOD,
step: (input: MultiStepInput) =>
Expand All @@ -302,34 +307,62 @@ export async function newConnectCredential(
}

// ***************************************************************
// Step: Select authentication method (Connect only)
// Step: Select authentication method
// For Connect (not in Snowflake): Token Authentication (Recommended) or API Key
// For Snowflake (detected by URL or by hasSnowflakeConnections): Snowflake Connection or API Key (no Token Auth)
// ***************************************************************
async function inputAuthMethod(input: MultiStepInput, state: MultiStepState) {
const authMethods = [
{
label: AuthMethodName.TOKEN,
description: "Recommended - one click connection",
},
{
label: AuthMethodName.API_KEY,
description: "Manually enter an API key",
},
];
// Hide Token Auth when:
// - URL is detected as Snowflake (isSnowflake(serverType))
// - OR Snowflake connections are available on the system (hasSnowflakeConnections)
// This handles the case where user enters internal URL like https://connect/
const shouldHideTokenAuth =
isSnowflake(serverType) || hasSnowflakeConnections;

const authMethods = shouldHideTokenAuth
? [
{
label: AuthMethodName.SNOWFLAKE_CONN,
description: "Use Snowflake connection for authentication",
},
{
label: AuthMethodName.API_KEY,
description: "Manually enter an API key",
},
]
: [
{
label: AuthMethodName.TOKEN,
description: "Recommended - one click connection",
},
{
label: AuthMethodName.API_KEY,
description: "Manually enter an API key",
},
];

const pick = await input.showQuickPick({
title: state.title,
step: 0,
totalSteps: 0,
placeholder: "Select authentication method",
items: authMethods,
activeItem: authMethods[0], // Token authentication is default
activeItem: authMethods[0],
buttons: [],
shouldResume: () => Promise.resolve(false),
ignoreFocusOut: true,
});

authMethod = getAuthMethod(pick.label as AuthMethodName);

if (isSnowflakeConn(authMethod)) {
return {
name: step.INPUT_SNOWFLAKE_CONN,
step: (input: MultiStepInput) =>
steps[step.INPUT_SNOWFLAKE_CONN](input, state),
};
}

if (isApiKey(authMethod)) {
return {
name: step.INPUT_API_KEY,
Expand Down
127 changes: 127 additions & 0 deletions extensions/vscode/src/utils/multiStepHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (C) 2026 by Posit Software, PBC.

import { describe, expect, test } from "vitest";
import {
isConnect,
isConnectCloud,
isSnowflake,
isConnectProduct,
isConnectCloudProduct,
getProductType,
getProductName,
getServerType,
} from "./multiStepHelpers";
import {
ServerType,
ProductType,
ProductName,
} from "../api/types/contentRecords";

describe("Server Type helpers", () => {
describe("isConnect", () => {
test("returns true for ServerType.CONNECT", () => {
expect(isConnect(ServerType.CONNECT)).toBe(true);
});

test("returns false for ServerType.SNOWFLAKE", () => {
expect(isConnect(ServerType.SNOWFLAKE)).toBe(false);
});

test("returns false for ServerType.CONNECT_CLOUD", () => {
expect(isConnect(ServerType.CONNECT_CLOUD)).toBe(false);
});
});

describe("isSnowflake", () => {
test("returns true for ServerType.SNOWFLAKE", () => {
expect(isSnowflake(ServerType.SNOWFLAKE)).toBe(true);
});

test("returns false for ServerType.CONNECT", () => {
expect(isSnowflake(ServerType.CONNECT)).toBe(false);
});

test("returns false for ServerType.CONNECT_CLOUD", () => {
expect(isSnowflake(ServerType.CONNECT_CLOUD)).toBe(false);
});
});

describe("isConnectCloud", () => {
test("returns true for ServerType.CONNECT_CLOUD", () => {
expect(isConnectCloud(ServerType.CONNECT_CLOUD)).toBe(true);
});

test("returns false for ServerType.CONNECT", () => {
expect(isConnectCloud(ServerType.CONNECT)).toBe(false);
});

test("returns false for ServerType.SNOWFLAKE", () => {
expect(isConnectCloud(ServerType.SNOWFLAKE)).toBe(false);
});
});
});

describe("Product Type helpers", () => {
describe("isConnectProduct", () => {
test("returns true for ProductType.CONNECT", () => {
expect(isConnectProduct(ProductType.CONNECT)).toBe(true);
});

test("returns false for ProductType.CONNECT_CLOUD", () => {
expect(isConnectProduct(ProductType.CONNECT_CLOUD)).toBe(false);
});
});

describe("isConnectCloudProduct", () => {
test("returns true for ProductType.CONNECT_CLOUD", () => {
expect(isConnectCloudProduct(ProductType.CONNECT_CLOUD)).toBe(true);
});

test("returns false for ProductType.CONNECT", () => {
expect(isConnectCloudProduct(ProductType.CONNECT)).toBe(false);
});
});
});

describe("Type conversion helpers", () => {
describe("getProductType", () => {
test("returns ProductType.CONNECT for ServerType.CONNECT", () => {
expect(getProductType(ServerType.CONNECT)).toBe(ProductType.CONNECT);
});

test("returns ProductType.CONNECT for ServerType.SNOWFLAKE", () => {
// Snowflake is a Connect product (Connect running inside Snowflake)
expect(getProductType(ServerType.SNOWFLAKE)).toBe(ProductType.CONNECT);
});

test("returns ProductType.CONNECT_CLOUD for ServerType.CONNECT_CLOUD", () => {
expect(getProductType(ServerType.CONNECT_CLOUD)).toBe(
ProductType.CONNECT_CLOUD,
);
});
});

describe("getProductName", () => {
test("returns ProductName.CONNECT for ProductType.CONNECT", () => {
expect(getProductName(ProductType.CONNECT)).toBe(ProductName.CONNECT);
});

test("returns ProductName.CONNECT_CLOUD for ProductType.CONNECT_CLOUD", () => {
expect(getProductName(ProductType.CONNECT_CLOUD)).toBe(
ProductName.CONNECT_CLOUD,
);
});
});

describe("getServerType", () => {
test("returns ServerType.CONNECT for ProductName.CONNECT", () => {
expect(getServerType(ProductName.CONNECT)).toBe(ServerType.CONNECT);
});

test("returns ServerType.CONNECT_CLOUD for ProductName.CONNECT_CLOUD", () => {
expect(getServerType(ProductName.CONNECT_CLOUD)).toBe(
ServerType.CONNECT_CLOUD,
);
});
});
});
2 changes: 1 addition & 1 deletion internal/services/api/api_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log
})).Methods(http.MethodDelete)

// POST /api/test-credentials
r.Handle(ToPath("test-credentials"), PostTestCredentialsHandlerFunc(log)).
r.Handle(ToPath("test-credentials"), PostTestCredentialsHandlerFunc(log, snowflake.NewConnections())).
Methods(http.MethodPost)

// POST /api/connect/open-content
Expand Down
Loading
Loading