@canonical/jujulib
Version:
Juju API client
842 lines (802 loc) • 24.2 kB
text/typescript
// Copyright 2018 Canonical Ltd.
// Licensed under the LGPLv3, see LICENSE.txt file for details.
;
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"
);
});
});