diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index baa53e6..cb5c58c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,8 +37,12 @@ The extension runs in a parallel instance of Visual Studio Code. To start it, pr ### Running the tests -To run the tests run: +1. Run Materialize in docker: +```bash +docker run -v mzdata:/mzdata -p 6875:6875 -p 6876:6876 materialize/materialized +``` +2. Run the VSCode tests: ```bash npm run test ``` diff --git a/resources/style.css b/resources/style.css index 7759a37..b819d34 100644 --- a/resources/style.css +++ b/resources/style.css @@ -56,6 +56,10 @@ color: rgb(220 38 38); } +.profileErrorMessage { + color: rgb(220 38 38); +} + .action_button { display: flex; align-items: center; diff --git a/src/clients/admin.ts b/src/clients/admin.ts index 18d86a2..6a10f39 100644 --- a/src/clients/admin.ts +++ b/src/clients/admin.ts @@ -1,5 +1,7 @@ import fetch from "node-fetch"; import AppPassword from "../context/appPassword"; +import { JwksError } from "jwks-rsa"; +import { Errors } from "../utilities/error"; const jwksClient = require("jwks-rsa"); const jwt = require("node-jsonwebtoken"); @@ -50,8 +52,16 @@ export default class AdminClient { headers: {'Content-Type': 'application/json'} }); - this.auth = (await response.json()) as AuthenticationResponse; - return this.auth.accessToken; + if (response.status === 200) { + this.auth = (await response.json()) as AuthenticationResponse; + return this.auth.accessToken; + } else { + const { errors } = await response.json() as any; + const [error] = errors; + console.error("[AdminClient]", "Error during getToken: ", error); + + throw new Error(error); + } } else { return this.auth.accessToken; } @@ -70,24 +80,42 @@ export default class AdminClient { /// Verifies the JWT signature using a JWK from the well-known endpoint and /// returns the user claims. async getClaims() { - const [jwk] = await this.getJwks(); - const key = jwk.getPublicKey(); + console.log("[AdminClient]", "Getting Token."); const token = await this.getToken(); - // Ignore expiration during tests - // The extension is not in charge of manipulating any type of information in Materialize servers. - const authData = jwt.verify(token, key, { complete: true }); + try { + console.log("[AdminClient]", "Getting JWKS."); + const [jwk] = await this.getJwks(); + const key = jwk.getPublicKey(); + + // Ignore expiration during tests + const authData = jwt.verify(token, key, { complete: true }); - return authData.payload; + return authData.payload; + } catch (err) { + console.error("[AdminClient]", "Error retrieving claims: ", err); + throw new Error(Errors.verifyCredential); + } } /// Returns the current user's email. async getEmail() { - const claims = await this.getClaims(); - if (typeof claims === "string") { - return JSON.parse(claims).email as string; - } else { - return claims.email as string; + let claims = await this.getClaims(); + + try { + if (typeof claims === "string") { + claims = JSON.parse(claims); + } + + console.log(claims); + if (!claims.email) { + throw new Error(Errors.emailNotPresentInClaims); + } else { + return claims.email as string; + } + } catch (err) { + console.error("[AdminClient]", "Error retrieving email: ", err); + throw new Error(Errors.retrievingEmail); } } } diff --git a/src/clients/cloud.ts b/src/clients/cloud.ts index 00d7cf1..cf5d647 100644 --- a/src/clients/cloud.ts +++ b/src/clients/cloud.ts @@ -1,6 +1,7 @@ import fetch from "node-fetch"; import AdminClient from "./admin"; import * as vscode from 'vscode'; +import { Errors } from "../utilities/error"; const DEFAULT_API_CLOUD_ENDPOINT = 'https://api.cloud.materialize.com'; @@ -64,7 +65,7 @@ export default class CloudClient { // eslint-disable-next-line @typescript-eslint/naming-convention 'Content-Type': 'application/json', // eslint-disable-next-line @typescript-eslint/naming-convention - "Authorization": `Bearer ${(await this.adminClient.getToken())}` + "Authorization": `Bearer ${await this.adminClient.getToken()}` } }); } @@ -73,31 +74,49 @@ export default class CloudClient { const cloudProviders = []; let cursor = ''; - while (true) { - let response = await this.fetch(`${this.providersEndpoint}?limit=50&cursor=${cursor}`); + try { + while (true) { + let response = await this.fetch(`${this.providersEndpoint}?limit=50&cursor=${cursor}`); - console.log("[CloudClient]", `Status: ${response.status}`); - const cloudProviderResponse = (await response.json()) as CloudProviderResponse; - cloudProviders.push(...cloudProviderResponse.data); + console.log("[CloudClient]", `Status: ${response.status}`); + const cloudProviderResponse = (await response.json()) as CloudProviderResponse; + cloudProviders.push(...cloudProviderResponse.data); - if (cloudProviderResponse.nextCursor) { - cursor = cloudProviderResponse.nextCursor; - } else { - break; + if (cloudProviderResponse.nextCursor) { + cursor = cloudProviderResponse.nextCursor; + } else { + break; + } } + } catch (err) { + console.error("[CloudClient]", "Error listing cloud providers: ", err); + throw new Error(Errors.listingCloudProviders); } return cloudProviders; } async getRegion(cloudProvider: CloudProvider): Promise { - const regionEdnpoint = `${cloudProvider.url}/api/region`; + try { + const regionEdnpoint = `${cloudProvider.url}/api/region`; - let response = await this.fetch(regionEdnpoint); + let response = await this.fetch(regionEdnpoint); - console.log("[CloudClient]", `Status: ${response.status}`); - const region: Region = (await response.json()) as Region; - return region; + console.log("[CloudClient]", `Status: ${response.status}`); + if (response.status === 200) { + const region: Region = (await response.json()) as Region; + return region; + } else { + try { + throw new Error((await response.json() as any).error); + } catch (err) { + throw err; + } + } + } catch (err) { + console.error("[CloudClient]", "Error retrieving region: ", err); + throw new Error(Errors.retrievingRegion); + } } /** @@ -107,11 +126,11 @@ export default class CloudClient { */ async getHost(region: string) { console.log("[CloudClient]", "Listing cloud providers."); - const cloudProviders = await this.listCloudProviders(); - console.log("[CloudClient]", "Providers: ", cloudProviders); + console.log("[CloudClient]", "Providers: ", cloudProviders); const provider = cloudProviders.find(x => x.id === region); + console.log("[CloudClient]", "Selected provider: ", provider); if (provider) { console.log("[CloudClient]", "Retrieving region."); @@ -120,10 +139,12 @@ export default class CloudClient { if (!regionInfo) { console.error("[CloudClient]", "Region is not enabled."); - vscode.window.showErrorMessage("Region is not enabled."); + throw new Error(Errors.disabledRegion); } else { return regionInfo.sqlAddress; } + } else { + throw new Error(Errors.invalidProvider.replace("${region}", region)); } } } \ No newline at end of file diff --git a/src/clients/sql.ts b/src/clients/sql.ts index 8441276..e532f80 100644 --- a/src/clients/sql.ts +++ b/src/clients/sql.ts @@ -5,21 +5,25 @@ import { MaterializeObject } from "../providers/schema"; import AdminClient from "./admin"; import CloudClient from "./cloud"; import * as vscode from 'vscode'; +import { Context, EventType } from "../context"; export default class SqlClient { private pool: Promise; private adminClient: AdminClient; private cloudClient: CloudClient; + private context: Context; private profile: NonStorableConfigProfile; constructor( adminClient: AdminClient, cloudClient: CloudClient, profile: NonStorableConfigProfile, + context: Context, ) { this.adminClient = adminClient; this.cloudClient = cloudClient; this.profile = profile; + this.context = context; this.pool = new Promise((res, rej) => { const asyncOp = async () => { @@ -37,7 +41,8 @@ export default class SqlClient { rej(err); }); } catch (err) { - vscode.window.showErrorMessage(`Error connecting to the region: ${err}`); + console.error("[SqlClient]", "Error creating pool: ", err); + this.context.emit("event", { type: EventType.error, message: err }); } }; @@ -45,6 +50,10 @@ export default class SqlClient { }); } + async connectErr() { + await this.pool; + } + /** * Rreturns the connection options for a PSQL connection. * @returns string connection options @@ -97,7 +106,6 @@ export default class SqlClient { async* cursorQuery(statement: string): AsyncGenerator { const pool = await this.pool; const client = await pool.connect(); - const id = randomUUID(); try { const batchSize = 100; // Number of rows to fetch in each batch diff --git a/src/context/appPassword.ts b/src/context/appPassword.ts index 8905cbf..6e765b9 100644 --- a/src/context/appPassword.ts +++ b/src/context/appPassword.ts @@ -1,4 +1,5 @@ import * as uuid from "uuid"; +import { Errors } from "../utilities/error"; const PREFIX = 'mzp_'; @@ -50,7 +51,7 @@ export default class AppPassword { ); if (filteredChars.length !== 64) { - throw new Error(); + throw new Error(Errors.invalidLengthAppPassword); } // Lazy way to rebuild uuid. @@ -58,15 +59,16 @@ export default class AppPassword { const clientId = AppPassword.formatDashlessUuid(filteredChars.slice(0, 32).join('')); const secretKey = AppPassword.formatDashlessUuid(filteredChars.slice(32).join('')); - return { - clientId, - secretKey, - }; + return { + clientId, + secretKey, + }; } catch (err) { console.log("Error parsing UUID."); + throw new Error(Errors.invalidAppPassword); } } - throw new Error("Invalid app-password"); + throw new Error(Errors.invalidAppPassword); } } \ No newline at end of file diff --git a/src/context/config.ts b/src/context/config.ts index c54930e..789dfae 100644 --- a/src/context/config.ts +++ b/src/context/config.ts @@ -57,6 +57,7 @@ export class Config { const profile = this.config.profiles[profileName]; if (!profile) { + // TODO: Display in the profile section. vscode.window.showErrorMessage(`Error. The selected default profile '${profileName}' does not exist.`); return; } @@ -74,6 +75,8 @@ export class Config { this.createFileOrDir(this.configDir); } } catch (err) { + console.error("[Config]", "Error loading config: ", err); + // TODO: Display this in the profile config section. vscode.window.showErrorMessage('Error creating the configuration directory.'); } @@ -82,17 +85,11 @@ export class Config { } try { + console.log("[Config]", "Config file path: ", this.configFilePath); let configInToml = readFileSync(this.configFilePath, 'utf-8'); - try { - return TOML.parse(configInToml) as ConfigFile; - } catch (err) { - vscode.window.showErrorMessage('Error parsing the configuration file.'); - console.error("Error parsing configuration file."); - throw err; - } + return TOML.parse(configInToml) as ConfigFile; } catch (err) { - vscode.window.showErrorMessage('Error reading the configuration file.'); - console.error("Error reading the configuration file.", err); + console.error("[Config]", "Error reading the configuration file.", err); throw err; } } diff --git a/src/context/context.ts b/src/context/context.ts index dc00571..8aeb1ad 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -4,7 +4,7 @@ import { AdminClient, CloudClient, SqlClient } from "../clients"; import { Config, NonStorableConfigProfile } from "./config"; import { MaterializeObject, MaterializeSchemaObject } from "../providers/schema"; import AppPassword from "./appPassword"; -import * as vscode from 'vscode'; +import { Errors } from "../utilities/error"; export enum EventType { newProfiles, @@ -13,6 +13,7 @@ export enum EventType { queryResults, environmentLoaded, environmentChange, + error } interface Environment { @@ -50,6 +51,7 @@ export class Context extends EventEmitter { this.adminClient = new AdminClient(profile["app-password"], this.getAdminEndpoint(profile)); this.cloudClient = new CloudClient(this.adminClient, profile["cloud-endpoint"]); this.loadEnvironment(); + return true; } @@ -66,7 +68,8 @@ export class Context extends EventEmitter { cloudUrl.hostname = "admin." + hostname.slice(4); return cloudUrl.toString(); } else { - vscode.window.showErrorMessage("The admin endpoint is invalid."); + console.error("The admin endpoint is invalid."); + return undefined; } } @@ -79,10 +82,15 @@ export class Context extends EventEmitter { const profile = this.config.getProfile(); - if (!this.adminClient || !this.cloudClient || !profile) { - throw new Error("Missing clients."); + if (!this.adminClient || !this.cloudClient) { + throw new Error(Errors.unconfiguredClients); + } else if (!profile) { + throw new Error(Errors.unconfiguredProfile); } else { - this.sqlClient = new SqlClient(this.adminClient, this.cloudClient, profile); + this.sqlClient = new SqlClient(this.adminClient, this.cloudClient, profile, this); + this.sqlClient.connectErr().catch((err) => { + this.emit("event", { type: EventType.error, data: { message: err.message } }); + }); // TODO: Do in parallel. if (!this.config.getCluster()) { @@ -107,7 +115,7 @@ export class Context extends EventEmitter { console.log("[Context]", "Databases: ", databases, " - Database: " , this.config.getDatabase()); if (!database) { // Display error to user. - throw new Error("Error finding database."); + throw new Error(Errors.databaseIsNotAvailable); } const schemasPromise = this.sqlClient.getSchemas(database); @@ -118,12 +126,12 @@ export class Context extends EventEmitter { if (!cluster) { // Display error to user. - throw new Error("Error finding cluster."); + throw new Error(Errors.clusterIsNotAvailable); } if (!schema) { // Display error to user. - throw new Error("Error finding schema."); + throw new Error(Errors.schemaIsNotAvailable); } this.environment = { diff --git a/src/providers/auth.ts b/src/providers/auth.ts index 834297f..1a9fafe 100644 --- a/src/providers/auth.ts +++ b/src/providers/auth.ts @@ -66,6 +66,7 @@ interface State { isRemoveProfile: boolean; isAddNewProfile: boolean; isLoading: boolean; + error: undefined | string; } /** @@ -88,6 +89,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { isAddNewProfile: false, isRemoveProfile: false, isLoading: this.context.isLoading(), + error: undefined, }; // Await for readyness when the extension activates from the outside. @@ -99,8 +101,20 @@ export default class AuthProvider implements vscode.WebviewViewProvider { }; }); - this.context.on("event", ({ type }) => { + this.context.on("event", (data) => { + const { type } = data; switch (type) { + case EventType.error: { + const { message } = data; + console.log("[AuthProvider]", "Error detected: ", message, data); + this.state.error = message; + this.state.isLoading = false; + + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + break; + } case EventType.newProfiles: { console.log("[AuthProvider]", "New profiles available."); if (this._view) { @@ -131,6 +145,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { console.log("[AuthProvider]", "New environment available."); if (this._view) { this.state.isLoading = false; + this.state.error = undefined; // Do not refresh the webview if the user is removing or adding a profile. // The UI will auto update after this action ends. @@ -160,6 +175,8 @@ export default class AuthProvider implements vscode.WebviewViewProvider { webviewView: vscode.WebviewView ) { this.state.isAddNewProfile = false; + this.state.error = undefined; + if (appPasswordResponse) { const { appPassword, region } = appPasswordResponse; @@ -354,6 +371,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { const schema = this.context.getSchema(); const cluster = this.context.getCluster(); const profileName = this.context.getProfileName(); + console.log("[Auth]", this.state.error); content = ( `
@@ -375,34 +393,38 @@ export default class AuthProvider implements vscode.WebviewViewProvider {
- ${this.state.isLoading ? `` : "Connection Options"} -
- -
-
- -
- +
+ +
+ `: ""}
`); } diff --git a/src/providers/schema.ts b/src/providers/schema.ts index c9dddc4..6298f78 100644 --- a/src/providers/schema.ts +++ b/src/providers/schema.ts @@ -56,9 +56,8 @@ export default class DatabaseTreeProvider implements vscode.TreeDataProvider setTimeout(resolve, ms)); } -function waitForEvent(context: Context, eventType: EventType) { - return new Promise(resolve => context.on("event", ({ type }) => { - if (type === eventType) { - resolve(""); - }; - })); +/** + * Waits for an event to happen inside the context. + * @param context + * @param eventType + * @returns when the event is received. + */ +function waitForEvent(context: Context, eventType: EventType): Promise { + return new Promise((res) => { + context.on("event", (data) => { + const { type } = data; + if (type === eventType) { + console.log("LA DATAX: ",data); + res(data); + }; + }); + }); } suite('Extension Test Suite', () => { @@ -51,25 +67,36 @@ suite('Extension Test Suite', () => { assert.ok(profile === undefined); - // Main test profile + // Alternative test profile. await config.addAndSaveProfile( - "test", + "test_alt", new AppPassword(randomUUID().replace("-", ""), randomUUID().replace("-", "")), "aws/us-east-1", "http://localhost:3000", "http://localhost:3000" ); - // Alternative test profile + // Alternative invalid test profile. + // The API should return a 401 for this userId. await config.addAndSaveProfile( - "test_alt", + "invalid_profile", + new AppPassword("52881e4b8c724ec1bcc6f9d22155821b", "52881e4b8c724ec1bcc6f9d22155821b"), + "aws/us-east-1", + "http://localhost:3000", + "http://localhost:3000" + ); + + // Main test profile. + // This will be assigned as the default one. + await config.addAndSaveProfile( + "test", new AppPassword(randomUUID().replace("-", ""), randomUUID().replace("-", "")), "aws/us-east-1", "http://localhost:3000", "http://localhost:3000" ); - assert.equal(2, (config.getProfileNames() || []).length); + assert.equal(3, (config.getProfileNames() || []).length); }); test('Test extension activation', async () => { @@ -141,14 +168,15 @@ suite('Extension Test Suite', () => { await listenEnvironmentChange; }).timeout(10000); - // test('Change schema', async () => { - // const listenEnvironmentChange = waitForEvent(context, EventType.environmentChange); - // const schemaName = context.getSchema()?.name; - // const altSchemaName = context.getSchemas()?.find(x => x.name !== schemaName); - // assert.ok(typeof altSchemaName?.name === "string"); - // context.setSchema(altSchemaName.name); - // await listenEnvironmentChange; - // }).timeout(10000); + test('Detect invalid password', async () => { + const listenErrorPromise = waitForEvent(context, EventType.error); + context.setProfile("invalid_profile"); + + await listenErrorPromise; + // TODO: + // const { message } = data; - // Test explorer + // console.log("Recivido: ", data); + // assert.ok(message === "Invalid authentication."); + }).timeout(10000); }); \ No newline at end of file diff --git a/src/test/suite/server.ts b/src/test/suite/server.ts index a915677..a70f8b6 100644 --- a/src/test/suite/server.ts +++ b/src/test/suite/server.ts @@ -24,14 +24,31 @@ export function mockServer(): Promise { const { privateKey, publicKey } = generateKeyPairSync('rsa', keyPairOptions); - var token = sign({ foo: 'bar' }, + var token = sign({ foo: 'bar', email: "materialize" }, { key: privateKey, passphrase } as any, { algorithm: 'RS256', expiresIn: 600, }); - app.post('/identity/resources/auth/v1/api-token', (_, res) => { + app.post('/identity/resources/auth/v1/api-token', (req, res) => { + const clientId = req.body.clientId; + if (clientId === "52881e4b-8c72-4ec1-bcc6-f9d22155821b") { + res.status(401).send({ errors: ["Invalid authentication"] }); + return; + } + + // if (clientId === "52881e4b-8c72-4ec1-bcc6-f9d22155821b") { + // // TODO: Return a token with a missing email. + // res.json({ + // accessToken: token, + // expires: "Mon, 31 Jul 2023 10:59:33 GMT", + // expiresIn: 600, + // refreshToken: "MOCK", + // }); + // return; + // } + res.json({ accessToken: token, expires: "Mon, 31 Jul 2023 10:59:33 GMT", diff --git a/src/utilities/error.ts b/src/utilities/error.ts new file mode 100644 index 0000000..b33ffad --- /dev/null +++ b/src/utilities/error.ts @@ -0,0 +1,72 @@ +export enum Errors { + /** + * Raises when trying to verify an invalid JWT token using JWKS. + */ + verifyCredential = "Failed to verify credentials.", + /** + * Raises when the user claims are invalid. + */ + retrievingEmail = "Failed to retrieve email.", + /** + * Raises when the email is not present in the claims. + */ + emailNotPresentInClaims = "Email is not present in claims.", + /** + * Raises when an issue happens listing the cloud providers. + */ + listingCloudProviders = "Failed to retrieve the cloud providers.", + /** + * Raises when it is not possible to parse the response + * from the API or an error during the request. + */ + retrievingRegion = "Failed to retrieve region.", + /** + * Raises when a user tries to access a disabled region. + */ + disabledRegion = "Selected region is disabled.", + /** + * Raises when a user tries to access an invalid provider. + * e.g.: aws/us-central-77 + * + * When using this error, the ${region} must be replaced + * with the invalid provider name. + */ + invalidProvider = "Selected region '${region}' is invalid.", + /** + * Raises when the app-password in the configuration file, + * or provided by the console has an unexpected amount of characters. + */ + invalidLengthAppPassword = "Invalid amount of characters in the app-password.", + /** + * Raises when the app-password UUIDs (userId or secret) is not valid. + */ + invalidUuid = "Parsing the app password fields as UUIDs fails.", + /** + * Raises when the app-password is invalid. Can happen if the app-password + * UUIDs (userId or secret) are incorrect. + */ + invalidAppPassword = "App-password format is invalid.", + /** + * Raises when loading an environment without setting up the cloud or admin client. + */ + unconfiguredClients = "The clients are not yet setup.", + /** + * Raises when loading an environment without setting up a profile. + */ + unconfiguredProfile = "A profile is not yet set up.", + /** + * Raises when the user switches to a database that does not exists + * anymore. + */ + databaseIsNotAvailable = "The selected database is not available anymore.", + /** + * Raises when the user switches to a cluster that does not exists + * anymore. + */ + clusterIsNotAvailable = "The selected cluster is not available anymore.", + /** + * Raises when the user switches to a schema that does not exists + * anymore. + */ + schemaIsNotAvailable = "The selected schema is not available anymore." +}