Ability to remove a bonob registration from sonos (#16)

This commit is contained in:
Simon J
2021-08-07 20:11:02 +10:00
committed by GitHub
parent 55a362e01b
commit 20bcf5524f
7 changed files with 319 additions and 72 deletions

View File

@@ -48,7 +48,7 @@
"scripts": { "scripts": {
"clean": "rm -Rf build", "clean": "rm -Rf build",
"build": "tsc", "build": "tsc",
"dev": "BONOB_PORT=4000 BONOB_URL=http://$(hostname):4000 BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=false BONOB_SONOS_AUTO_REGISTER=false nodemon ./src/app.ts", "dev": "BONOB_PORT=4000 BONOB_URL=http://$(hostname):4000 BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=false nodemon ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://$(hostname):4000", "register-dev": "ts-node ./src/register.ts http://$(hostname):4000",
"test": "jest" "test": "jest"
} }

View File

@@ -12,7 +12,8 @@ import {
PRESENTATION_MAP_ROUTE, PRESENTATION_MAP_ROUTE,
SONOS_RECOMMENDED_IMAGE_SIZES, SONOS_RECOMMENDED_IMAGE_SIZES,
LOGIN_ROUTE, LOGIN_ROUTE,
REGISTER_ROUTE, CREATE_REGISTRATION_ROUTE,
REMOVE_REGISTRATION_ROUTE
} from "./smapi"; } from "./smapi";
import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service"; import { MusicService, isSuccess } from "./music_service";
@@ -95,7 +96,8 @@ function server(
services, services,
bonobService: service, bonobService: service,
registeredBonobService, registeredBonobService,
registerRoute: bonobUrl.append({ pathname: REGISTER_ROUTE }).pathname(), createRegistrationRoute: bonobUrl.append({ pathname: CREATE_REGISTRATION_ROUTE }).pathname(),
removeRegistrationRoute: bonobUrl.append({ pathname: REMOVE_REGISTRATION_ROUTE }).pathname(),
}); });
} }
); );
@@ -110,7 +112,7 @@ function server(
}); });
}); });
app.post(REGISTER_ROUTE, (_, res) => { app.post(CREATE_REGISTRATION_ROUTE, (_, res) => {
sonos.register(service).then((success) => { sonos.register(service).then((success) => {
if (success) { if (success) {
res.render("success", { res.render("success", {
@@ -124,6 +126,20 @@ function server(
}); });
}); });
app.post(REMOVE_REGISTRATION_ROUTE, (_, res) => {
sonos.remove(service.sid).then((success) => {
if (success) {
res.render("success", {
message: `Successfully removed registration`,
});
} else {
res.status(500).render("failure", {
message: `Failed to remove registration!`,
});
}
});
});
app.get(LOGIN_ROUTE, (req, res) => { app.get(LOGIN_ROUTE, (req, res) => {
res.render("login", { res.render("login", {
bonobService: service, bonobService: service,

View File

@@ -23,7 +23,8 @@ import { Clock } from "./clock";
import { URLBuilder } from "./url_builder"; import { URLBuilder } from "./url_builder";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
export const REGISTER_ROUTE = "/register"; export const CREATE_REGISTRATION_ROUTE = "/registration/add";
export const REMOVE_REGISTRATION_ROUTE = "/registration/remove";
export const SOAP_PATH = "/ws/sonos"; export const SOAP_PATH = "/ws/sonos";
export const STRINGS_ROUTE = "/sonos/strings.xml"; export const STRINGS_ROUTE = "/sonos/strings.xml";
export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml"; export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml";

View File

@@ -61,14 +61,14 @@ export const bonobService = (
): Service => ({ ): Service => ({
name, name,
sid, sid,
uri: bonobUrl.append({pathname: SOAP_PATH }).href(), uri: bonobUrl.append({ pathname: SOAP_PATH }).href(),
secureUri: bonobUrl.append({pathname: SOAP_PATH }).href(), secureUri: bonobUrl.append({ pathname: SOAP_PATH }).href(),
strings: { strings: {
uri: bonobUrl.append({pathname: STRINGS_ROUTE }).href(), uri: bonobUrl.append({ pathname: STRINGS_ROUTE }).href(),
version: PRESENTATION_AND_STRINGS_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
presentation: { presentation: {
uri: bonobUrl.append({pathname: PRESENTATION_MAP_ROUTE }).href(), uri: bonobUrl.append({ pathname: PRESENTATION_MAP_ROUTE }).href(),
version: PRESENTATION_AND_STRINGS_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
pollInterval: 1200, pollInterval: 1200,
@@ -78,12 +78,14 @@ export const bonobService = (
export interface Sonos { export interface Sonos {
devices: () => Promise<Device[]>; devices: () => Promise<Device[]>;
services: () => Promise<Service[]>; services: () => Promise<Service[]>;
remove: (sid: number) => Promise<boolean>;
register: (service: Service) => Promise<boolean>; register: (service: Service) => Promise<boolean>;
} }
export const SONOS_DISABLED: Sonos = { export const SONOS_DISABLED: Sonos = {
devices: () => Promise.resolve([]), devices: () => Promise.resolve([]),
services: () => Promise.resolve([]), services: () => Promise.resolve([]),
remove: (_: number) => Promise.resolve(true),
register: (_: Service) => Promise.resolve(true), register: (_: Service) => Promise.resolve(true),
}; };
@@ -111,6 +113,11 @@ export const asDevice = (sonosDevice: SonosDevice): Device => ({
port: sonosDevice.Port, port: sonosDevice.Port,
}); });
export const asRemoveCustomdForm = (csrfToken: string, sid: number) => ({
csrfToken,
sid: `${sid}`
});
export const asCustomdForm = (csrfToken: string, service: Service) => ({ export const asCustomdForm = (csrfToken: string, service: Service) => ({
csrfToken, csrfToken,
sid: `${service.sid}`, sid: `${service.sid}`,
@@ -158,6 +165,44 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
}); });
}; };
const post = async (action: string, customdForm: (csrfToken: string) => any) => {
const anyDevice = await sonosDevices().then((devices) => head(devices));
if (!anyDevice) {
logger.warn("Failed to find a device to register with...");
return false;
}
logger.info(
`${action} using sonos device ${anyDevice.Name} @ ${anyDevice.Host}`
);
const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`;
const csrfToken = await axios.get(customd).then((response) =>
parse(response.data)
.querySelectorAll("input")
.find((it) => it.getAttribute("name") == "csrfToken")
?.getAttribute("value")
);
if (!csrfToken) {
logger.warn(
`Failed to find csrfToken at GET -> ${customd}, cannot ${action} service`
);
return false;
}
const form = customdForm(csrfToken)
logger.info(`${action} with sonos @ ${customd}`, { form });
return axios
.post(customd, new URLSearchParams(qs.stringify(form)), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
.then((response) => response.status == 200);
};
return { return {
devices: async () => sonosDevices().then((it) => it.map(asDevice)), devices: async () => sonosDevices().then((it) => it.map(asDevice)),
@@ -170,43 +215,9 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
) )
.then((it) => it.map(asService)), .then((it) => it.map(asService)),
register: async (service: Service) => { remove: async (sid: number) => post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)),
const anyDevice = await sonosDevices().then((devices) => head(devices));
if (!anyDevice) { register: async (service: Service) => post("register", (csrfToken) => asCustomdForm(csrfToken, service)),
logger.warn("Failed to find a device to register with...");
return false;
}
logger.info(
`Registering ${service.name}(SID:${service.sid}) with sonos device ${anyDevice.Name} @ ${anyDevice.Host}`
);
const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`;
const csrfToken = await axios.get(customd).then((response) =>
parse(response.data)
.querySelectorAll("input")
.find((it) => it.getAttribute("name") == "csrfToken")
?.getAttribute("value")
);
if (!csrfToken) {
logger.warn(
`Failed to find csrfToken at GET -> ${customd}, cannot register service`
);
return false;
}
const customdForm = asCustomdForm(csrfToken, service);
logger.info(`Registering with sonos @ ${customd}`, { customdForm });
return axios
.post(customd, new URLSearchParams(qs.stringify(customdForm)), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
.then((response) => response.status == 200);
},
}; };
} }

View File

@@ -224,6 +224,7 @@ describe("server", () => {
devices: () => Promise.resolve([device1, device2]), devices: () => Promise.resolve([device1, device2]),
services: () => services: () =>
Promise.resolve([service1, service2, service3, service4]), Promise.resolve([service1, service2, service3, service4]),
remove: () => Promise.resolve(false),
register: () => Promise.resolve(false), register: () => Promise.resolve(false),
}; };
@@ -285,6 +286,7 @@ describe("server", () => {
const fakeSonos: Sonos = { const fakeSonos: Sonos = {
devices: () => Promise.resolve([]), devices: () => Promise.resolve([]),
services: () => Promise.resolve([service1, service2, bonobService]), services: () => Promise.resolve([service1, service2, bonobService]),
remove: () => Promise.resolve(false),
register: () => Promise.resolve(false), register: () => Promise.resolve(false),
}; };
@@ -340,6 +342,7 @@ describe("server", () => {
describe("/register", () => { describe("/register", () => {
const sonos = { const sonos = {
register: jest.fn(), register: jest.fn(),
remove: jest.fn(),
}; };
const theService = aService({ const theService = aService({
name: "We can all live a life of service", name: "We can all live a life of service",
@@ -352,35 +355,71 @@ describe("server", () => {
new InMemoryMusicService() new InMemoryMusicService()
); );
describe("when is successful", () => { describe("registering", () => {
it("should return a nice message", async () => { describe("when is successful", () => {
sonos.register.mockResolvedValue(true); it("should return a nice message", async () => {
sonos.register.mockResolvedValue(true);
const res = await request(server)
.post(bonobUrl.append({ pathname: "/register" }).path()) const res = await request(server)
.send(); .post(bonobUrl.append({ pathname: "/registration/add" }).path())
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch("Successfully registered"); expect(res.status).toEqual(200);
expect(res.text).toMatch("Successfully registered");
expect(sonos.register.mock.calls.length).toEqual(1);
expect(sonos.register.mock.calls[0][0]).toBe(theService); expect(sonos.register.mock.calls.length).toEqual(1);
expect(sonos.register.mock.calls[0][0]).toBe(theService);
});
});
describe("when is unsuccessful", () => {
it("should return a failure message", async () => {
sonos.register.mockResolvedValue(false);
const res = await request(server)
.post(bonobUrl.append({ pathname: "/registration/add" }).path())
.send();
expect(res.status).toEqual(500);
expect(res.text).toMatch("Registration failed!");
expect(sonos.register.mock.calls.length).toEqual(1);
expect(sonos.register.mock.calls[0][0]).toBe(theService);
});
}); });
}); });
describe("when is unsuccessful", () => { describe("removing a registration", () => {
it("should return a failure message", async () => { describe("when is successful", () => {
sonos.register.mockResolvedValue(false); it("should return a nice message", async () => {
sonos.remove.mockResolvedValue(true);
const res = await request(server)
.post(bonobUrl.append({ pathname: "/register" }).path()) const res = await request(server)
.send(); .post(bonobUrl.append({ pathname: "/registration/remove" }).path())
.send();
expect(res.status).toEqual(500);
expect(res.text).toMatch("Registration failed!"); expect(res.status).toEqual(200);
expect(res.text).toMatch("Successfully removed registration");
expect(sonos.register.mock.calls.length).toEqual(1);
expect(sonos.register.mock.calls[0][0]).toBe(theService); expect(sonos.remove.mock.calls.length).toEqual(1);
expect(sonos.remove.mock.calls[0][0]).toBe(theService.sid);
});
});
describe("when is unsuccessful", () => {
it("should return a failure message", async () => {
sonos.remove.mockResolvedValue(false);
const res = await request(server)
.post(bonobUrl.append({ pathname: "/registration/remove" }).path())
.send();
expect(res.status).toEqual(500);
expect(res.text).toMatch("Failed to remove registration!");
expect(sonos.remove.mock.calls.length).toEqual(1);
expect(sonos.remove.mock.calls[0][0]).toBe(theService.sid);
});
}); });
}); });
}); });

View File

@@ -22,6 +22,7 @@ import sonos, {
Service, Service,
PRESENTATION_AND_STRINGS_VERSION, PRESENTATION_AND_STRINGS_VERSION,
BONOB_CAPABILITIES, BONOB_CAPABILITIES,
asRemoveCustomdForm,
} from "../src/sonos"; } from "../src/sonos";
import { aSonosDevice, aService } from "./builders"; import { aSonosDevice, aService } from "./builders";
@@ -259,6 +260,18 @@ describe("sonos", () => {
}); });
}); });
describe("asRemoveCustomdForm", () => {
it("should return a form", () => {
const csrfToken = uuid();
const sid = 123;
expect(asRemoveCustomdForm(csrfToken, sid)).toEqual({
csrfToken,
sid: "123"
});
});
});
describe("when is disabled", () => { describe("when is disabled", () => {
it("should return a disabled client", async () => { it("should return a disabled client", async () => {
const disabled = sonos(false); const disabled = sonos(false);
@@ -684,4 +697,170 @@ describe("sonos", () => {
}); });
}); });
}); });
describe("removing a service", () => {
const device1 = aSonosDevice({
Name: "d1",
Host: "127.0.0.11",
Port: 111,
});
const device2 = aSonosDevice({
Name: "d2",
});
const POST_CONFIG = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
};
const sidToRemove = 333;
const mockGet = jest.fn();
const mockPost = jest.fn();
beforeEach(() => {
mockGet.mockClear();
mockPost.mockClear();
axios.get = mockGet;
axios.post = mockPost;
});
describe("when successful", () => {
it("should post the remove into the first found sonos device, returning true", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 200, data: "" });
const result = await sonos().remove(sidToRemove);
expect(mockGet).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`
);
expect(mockPost).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`,
new URLSearchParams(qs.stringify(asRemoveCustomdForm(csrfToken, sidToRemove))),
POST_CONFIG
);
expect(result).toEqual(true);
});
});
describe("when cannot find any devices", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const result = await sonos().remove(sidToRemove);
expect(mockGet).not.toHaveBeenCalled();
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when cannot get csrfToken", () => {
describe("when the token is missing", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 200,
data: `<html></html>`,
});
const result = await sonos().remove(sidToRemove);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when the token call returns a non 200", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 400,
data: `<html></html>`,
});
const result = await sonos().remove(sidToRemove);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
});
describe("when posting in the sid to delete fails", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 500, data: "" });
const result = await sonos().remove(sidToRemove);
expect(result).toEqual(false);
});
});
});
}); });

View File

@@ -10,7 +10,8 @@
<% } else { %> <% } else { %>
<h3>No existing service registration</h3> <h3>No existing service registration</h3>
<% } %> <% } %>
<form action="<%= it.registerRoute %>" method="POST"><button>Re-register</button></form> <form action="<%= it.createRegistrationRoute %>" method="POST"><button>Register</button></form>
<form action="<%= it.removeRegistrationRoute %>" method="POST"><button>Remove Registration</button></form>
<h2>Devices</h2> <h2>Devices</h2>
<ul> <ul>
<% it.devices.forEach(function(d){ %> <% it.devices.forEach(function(d){ %>