UNPKG

@canonical/jujulib

Version:

Juju API client

842 lines (802 loc) 24.2 kB
// Copyright 2018 Canonical Ltd. // Licensed under the LGPLv3, see LICENSE.txt file for details. "use strict"; import { CallbackError } from "../../generator/interfaces"; import { Client, connect, connectAndLogin, Connection, generateModelURL, RedirectionError, CLIENT_VERSION, } from "../client"; import { BaseFacade, makeBakery, makeConnection, makeWSClass, MockWebSocket, requestEqual, } from "./helpers"; import { toError } from "../utils"; const fail = () => { throw new Error("Fail called"); }; describe("connect", () => { let ws: MockWebSocket; const options = { bakery: makeBakery(true), closeCallback: jest.fn(), facades: [], wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; it("supports supplying callback to login", (done) => { connect("wss://1.2.3.4", options, (err?: CallbackError, juju?: Client) => { expect(err).toBe(null); expect(juju instanceof Client).toBe(true); done(); }); ws.open(); }); it("returns promise, supports no callback", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { expect(juju instanceof Client).toBe(true); done(); }); ws.open(); }); it("handles failure to connect via promise", (done) => { connect("wss://1.2.3.4", options, (err) => { expect(err).toStrictEqual(new Error("cannot connect WebSocket: nope")); done(); }); ws.close("nope"); }); it("connect failure", (done) => { connect("wss://1.2.3.4", options, (err?: CallbackError, juju?: Client) => { expect(err).toStrictEqual( new Error("cannot connect WebSocket: bad wolf") ); expect(juju).toBeFalsy(); done(); }); // Close the WebSocket connection. ws.close("bad wolf"); }); function validateLoginFailure(error: Error, message: string) { requestEqual(ws.lastRequest, { type: "Admin", request: "Login", params: { "auth-tag": "user-who", "client-version": CLIENT_VERSION, credentials: "secret", macaroons: [], nonce: "", "user-data": "", }, version: 3, }); expect(error).toStrictEqual(new Error(message)); } it("handles admin login failures", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { ws.close(""); juju?.login({ username: "who", password: "secret" }).catch((error) => { expect(toError(error).message).toContain("cannot send request"); done(); }); }); ws.open(); }); it("login failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ username: "who", password: "secret" }) .then(() => fail) .catch((error) => { validateLoginFailure(error, "bad wolf"); done(); }); ws.reply({ error: "bad wolf" }); }); ws.open(); }); it("invalid credentials login failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ username: "who", password: "secret" }) .then(() => fail) .catch((error) => { validateLoginFailure( error, "Have you been granted permission to a model on this controller?" ); done(); }); ws.reply({ error: "invalid entity name or password" }); }); ws.open(); }); it("permission denied login failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ username: "who", password: "secret" }) .then(() => fail) .catch((error) => { validateLoginFailure( error, "Ensure that you've been given 'login' permission on this controller." ); done(); }); ws.reply({ error: "permission denied" }); }); ws.open(); }); function validateRedirectionLoginFailure(error: Error) { requestEqual(ws.lastRequest, { type: "Admin", request: "RedirectInfo", params: null, version: 3, }); expect(error).toBeInstanceOf(RedirectionError); } it("login redirection error failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { validateRedirectionLoginFailure(error); done(); }); ws.queueResponses( new Map([ // Reply to the redirectInfo request. [ 2, { response: { "ca-cert": "exampleCert", servers: [ [ { Address: { scope: "exampleScope", type: "exampleType", value: "exampleValue", }, port: 8080, scope: "exampleScope", type: "exampleType", value: "exampleValue", }, ], ], }, }, ], ]) ); // Reply to the login request. ws.reply({ error: "redirection required" }); }); ws.open(); }); it("login generic redirection error failure via promise", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { expect(error).toStrictEqual(new Error("bad wolf")); done(); }); ws.queueResponses( new Map([ // Reply to the redirectInfo request. [2, { error: "bad wolf" }], ]) ); // Reply to the login request. ws.reply({ error: "redirection required" }); }); ws.open(); }); function validateRedirectionLoginSuccess( juju: Client, error: RedirectionError ) { requestEqual(ws.lastRequest, { type: "Admin", request: "RedirectInfo", params: null, version: 3, }); expect(juju.isRedirectionError(error)).toBe(true); expect(error.caCert).toBe("mycert"); expect(error.servers.length).toBe(1); expect(error.servers[0].length).toBe(2); expect(error.servers[0][0].value).toBe("1.2.3.4"); expect(error.servers[0][0].port).toBe(17070); expect(error.servers[0][0].type).toBe("ipv4"); expect(error.servers[0][0].scope).toBe("global"); expect(error.servers[0][1].value).toBe("example.com"); expect(error.servers[0][1].port).toBe(443); expect(error.servers[0][1].type).toBe("hostname"); expect(error.servers[0][1].scope).toBe("global"); } it("login redirection error success", (done) => { connect("wss://1.2.3.4", options, (err?: CallbackError, juju?: Client) => { expect(err).toBe(null); expect(juju).not.toBe(null); juju ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { validateRedirectionLoginSuccess(juju, error); done(); }); ws.queueResponses( new Map([ // Reply to the redirectInfo request. [ 2, { response: { "ca-cert": "mycert", servers: [ [ { value: "1.2.3.4", port: 17070, type: "ipv4", scope: "global", }, { value: "example.com", port: 443, type: "hostname", scope: "global", }, ], ], }, }, ], ]) ); // Reply to the login request. ws.reply({ error: "redirection required" }); }); // Open the WebSocket connection. ws.open(); }); it("login redirection error success via promises", (done) => { connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ macaroons: [] }) .then(() => fail) .catch((error) => { validateRedirectionLoginSuccess(juju, error); done(); }); ws.queueResponses( new Map([ // Reply to the redirectInfo request. [ 2, { response: { "ca-cert": "mycert", servers: [ [ { value: "1.2.3.4", port: 17070, type: "ipv4", scope: "global", }, { value: "example.com", port: 443, type: "hostname", scope: "global", }, ], ], }, }, ], ]) ); // Reply to the login request. ws.reply({ error: "redirection required" }); }); ws.open(); }); function validateLoginDischargeRequiredNoBakery(error: Error) { requestEqual(ws.lastRequest, { type: "Admin", request: "Login", params: { "auth-tag": "user-who", "client-version": CLIENT_VERSION, credentials: "secret", macaroons: [], nonce: "", "user-data": "", }, version: 3, }); expect(error).toStrictEqual( new Error("macaroon discharge required but no bakery instance provided") ); } it("login discharge required no bakery", (done) => { const options = { closeCallback: jest.fn(), wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; connect("wss://1.2.3.4", options, (err?: CallbackError, juju?: Client) => { expect(err).toBe(null); expect(juju).not.toBe(null); juju ?.login({ username: "who", password: "secret" }) .then(() => fail) .catch((err) => { validateLoginDischargeRequiredNoBakery(err); done(); }); // Reply to the login request with a discharge required response. ws.reply({ response: { "discharge-required": { fake: "macaroon" } } }); }); // Open the WebSocket connection. ws.open(); }); it("login discharge required no bakery via promises", (done) => { const options = { closeCallback: jest.fn(), wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ username: "who", password: "secret" }) .then(() => fail) .catch((error) => { validateLoginDischargeRequiredNoBakery(error); done(); }); // Reply to the login request with a discharge required response. ws.reply({ response: { "discharge-required": { fake: "macaroon" } } }); }); ws.open(); }); function validateLoginDischargeRequiredFailure(error: Error) { requestEqual(ws.lastRequest, { type: "Admin", request: "Login", params: { "auth-tag": "", "client-version": CLIENT_VERSION, credentials: "", macaroons: [{ fake: "macaroon" }], nonce: "", "user-data": "", }, version: 3, }); expect(error).toStrictEqual( new Error("macaroon discharge failed: bad wolf") ); } it("login discharge required failure", (done) => { const options = { bakery: makeBakery(false), closeCallback: jest.fn(), wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; connect("wss://1.2.3.4", options, (err?: CallbackError, juju?: Client) => { expect(err).toBe(null); expect(juju).not.toBe(null); juju ?.login({ macaroons: [["m"]] }) .then(() => fail) .catch((err) => { validateLoginDischargeRequiredFailure(err); done(); }); // Reply to the login request with a discharge required response. ws.reply({ response: { "discharge-required": { fake: "macaroon" } } }); }); // Open the WebSocket connection. ws.open(); }); it("login discharge required failure via promises", (done) => { const options = { bakery: makeBakery(false), closeCallback: jest.fn(), wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; connect("wss://1.2.3.4", options).then((juju: Client) => { juju .login({ macaroons: [["m"]] }) .then(() => fail) .catch((error) => { validateLoginDischargeRequiredFailure(error); done(); }); // Reply to the login request with a discharge required response. ws.reply({ response: { "discharge-required": { fake: "macaroon" } } }); }); ws.open(); }); function validateLoginDischargeRequiredSuccess() { requestEqual(ws.lastRequest, { type: "Admin", request: "Login", params: { "auth-tag": "", "client-version": CLIENT_VERSION, credentials: "", macaroons: [{ fake: "macaroon" }], nonce: "", "user-data": "", }, version: 3, }); } it("login discharge required success", (done) => { connect("wss://1.2.3.4", options, (err?: CallbackError, juju?: Client) => { expect(err).toBe(null); expect(juju).not.toBe(null); juju?.login({ macaroons: [["m"]] }).then(() => { validateLoginDischargeRequiredSuccess(); done(); }); ws.queueResponses( new Map([ // Reply to the retried login request properly. [2, { response: { facades: [] } }], ]) ); // Reply to the login request with a discharge required response. ws.reply({ response: { "discharge-required": { fake: "macaroon" } } }); }); // Open the WebSocket connection. ws.open(); }); it("login discharge required success via promise", (done) => { const options = { bakery: makeBakery(true), closeCallback: jest.fn(), wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; connect("wss://1.2.3.4", options).then((juju: Client) => { juju ?.login({ macaroons: [["m"]] }) .then((_conn) => { validateLoginDischargeRequiredSuccess(); done(); }) .catch(() => fail); ws.queueResponses( new Map([ // Reply to the retried login request properly. [2, { response: { facades: [] } }], ]) ); // Reply to the login request with a discharge required response. ws.reply({ response: { "discharge-required": { fake: "macaroon" } } }); }); ws.open(); }); it("connect and enable OIDC login", (done) => { connect( "wss://1.2.3.4", { ...options, loginWithSessionCookie: true, }, (err?: CallbackError, juju?: Client) => { expect(err).toBe(null); juju?.login().then(() => { requestEqual(ws.lastRequest, { type: "Admin", request: "LoginWithSessionCookie", version: 4, }); done(); }); // Reply to the login request. ws.reply({ response: {} }); } ); // Open the WebSocket connection. ws.open(); }); it("connection transport success", (done) => { const options = { closeCallback: jest.fn() }; makeConnection(options, (conn, ws) => { conn?.transport.write( { type: "Test", request: "Test", params: {}, version: 1, }, (resp: Response) => { expect(resp.ok).toBe(true); done(); }, jest.fn() ); // Reply to the transport test request. ws.reply({ response: { ok: true, facades: [] } }); }); }); it("connection transport failure", (done) => { const options = { closeCallback: jest.fn() }; makeConnection( options, (conn: Connection | undefined, ws: MockWebSocket) => { conn?.transport.write( { type: "Test", request: "Test", params: {}, version: 1, }, jest.fn(), (err: CallbackError) => { expect(err).toStrictEqual(new Error("bad wolf")); done(); } ); // Reply to the transport test request. ws.reply({ error: "bad wolf" }); } ); }); it("connection info", (done) => { const options = { closeCallback: jest.fn() }; makeConnection( options, (conn: Connection | undefined, _ws: MockWebSocket) => { expect(conn?.info.controllerTag).toBe( "controller-76b9c391-12be-47fc-8406-c31f2db68ee5" ); expect(conn?.info.serverVersion).toBe("2.42.47"); expect(conn?.info.user).toStrictEqual({ credentials: "creds", "display-name": "who", identity: "user-who@gallifrey", "last-connection": "2018-06-06T01:02:13Z", "controller-access": "timelord", "model-access": "admin", }); done(); } ); }); it("connection info getFacade call", (done) => { const options = { closeCallback: jest.fn(), facades: [ class ClientV2 extends BaseFacade { static NAME = "Client"; static VERSION = 2; }, class MyFacadeV2 extends BaseFacade { static NAME = "MyFacadeV2"; static VERSION = 2; }, ], }; makeConnection( options, (conn: Connection | undefined, _ws: MockWebSocket) => { const client = conn?.info.getFacade?.("client"); expect(client).not.toBe(undefined); expect(client?.constructor.name).toBe("ClientV2"); expect(conn?.info.getFacade?.("allWatcher")).toBe(undefined); expect(conn?.info.getFacade?.("myFacade")).toBe(undefined); // Check properties passed to the instantiated facade. expect(client?._transport).toStrictEqual(conn?.transport); expect(client?._info).toStrictEqual(conn?.info); done(); } ); }); }); describe("connectAndLogin", () => { let ws: MockWebSocket; const url = "wss://1.2.3.4"; const options = { bakery: makeBakery(true), closeCallback: jest.fn(), wsclass: makeWSClass((instance: MockWebSocket) => { ws = instance; }), }; it("connect failure", (done) => { const creds = { macaroons: [] }; connectAndLogin(url, options, creds).catch((error) => { expect(error).toStrictEqual( new Error("cannot connect WebSocket: bad wolf") ); done(); }); // Close the WebSocket connection. ws.close("bad wolf"); }); it("login redirection error failure", (done) => { const creds = { username: "who", password: "tardis" }; connectAndLogin(url, options, creds) .then(() => fail) .catch((error) => { expect(error.message).toBe("cannot connect to model after redirection"); requestEqual(ws.lastRequest, { type: "Admin", request: "RedirectInfo", params: null, version: 3, }); done(); }); ws.queueResponses( new Map([ // Reply to the login request. [1, { error: "redirection required" }], // Reply to the redirectInfo request. [2, { response: { servers: [], "ca-cert": null } }], ]) ); // Open the WebSocket connection. ws.open(); }); it("login redirection error success", (done) => { // If this test is timing out then check that the setTimeout is opening the // model websocket. const creds = { username: "who", password: "tardis" }; let modelWS: MockWebSocket; const options = { bakery: makeBakery(true), closeCallback: jest.fn(), facades: [], wsclass: makeWSClass(jest.fn()), onWSCreated: (ws: unknown) => { // We've mocked the websocket with our own implementation so we need to // override the type to what we expect. const instance = ws as MockWebSocket; // This callback is used to open the websockets and send the responses // as we need to call the open function after the onopen etc. methods // have been overridden. These overrides do not exist when the init // function is passed to makeWSClass(). // // Two websockets get set up up. One for the controller connection, one // for the model. if (instance.url === "wss://1.2.3.4") { instance.queueResponses( new Map([ // Reply to the login request. [1, { error: "redirection required" }], // Reply to the redirectInfo request. [ 2, { response: { "ca-cert": "mycert", servers: [ [ { value: "example.com", port: 443, type: "hostname", scope: "public", }, { value: "1.2.3.4", port: 17070, type: "ipv4", scope: "public", }, ], ], }, }, ], ]) ); // Open the WebSocket connection. instance.open(); } else if (instance.url === "wss://example.com:443/model//api") { modelWS = instance; modelWS.queueResponses( new Map([ // Reply to the new login request. [1, { response: { facades: [] } }], ]) ); modelWS.open(); } }, }; connectAndLogin(url, options, creds).then( (result?: { conn?: Connection; logout: Client["logout"] }) => { expect(result).not.toBe(null); expect(result?.conn).not.toBe(null); expect(result?.logout).not.toBe(null); result?.logout(jest.fn()); // The WebSocket is now closed. expect(modelWS.readyState).toBe(3); done(); } ); }); it("login success", (done) => { const creds = { username: "who", password: "tardis" }; connectAndLogin(url, options, creds).then((result: any) => { expect(result).not.toBe(null); expect(result.conn).not.toBe(null); expect(result.logout).not.toBe(null); result.logout(); // The WebSocket is now closed. expect(ws.readyState).toBe(3); done(); }); ws.queueResponses( new Map([ // Reply to the login request. [1, { response: { facades: [] } }], ]) ); // Open the WebSocket connection. ws.open(); }); it("login success", (done) => { connectAndLogin(url, { ...options, loginWithSessionCookie: true }).then( (result: any) => { result.logout(); // The WebSocket is now closed. expect(ws.readyState).toBe(3); requestEqual(ws.lastRequest, { type: "Admin", request: "LoginWithSessionCookie", version: 4, }); done(); } ); ws.queueResponses( new Map([ // Reply to the login request. [1, { response: { facades: [] } }], ]) ); // Open the WebSocket connection. ws.open(); }); }); describe("generateModelURL", () => { it("returns valid url", () => { expect(generateModelURL("1.2.3.4:123", "abc-123-4")).toBe( "wss://1.2.3.4:123/model/abc-123-4/api" ); }); });