diff --git a/package.json b/package.json index ecd8d6c..4352936 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "scripts": { "clean": "rm -Rf build", "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", "test": "jest" } diff --git a/src/server.ts b/src/server.ts index 50dcdb7..19a19f8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,8 @@ import { PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, LOGIN_ROUTE, - REGISTER_ROUTE, + CREATE_REGISTRATION_ROUTE, + REMOVE_REGISTRATION_ROUTE } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; @@ -95,7 +96,8 @@ function server( services, bonobService: service, 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) => { if (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) => { res.render("login", { bonobService: service, diff --git a/src/smapi.ts b/src/smapi.ts index 7c6297f..d7d2432 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -23,7 +23,8 @@ import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; 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 STRINGS_ROUTE = "/sonos/strings.xml"; export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml"; diff --git a/src/sonos.ts b/src/sonos.ts index 67023db..455b43d 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -61,14 +61,14 @@ export const bonobService = ( ): Service => ({ name, sid, - uri: bonobUrl.append({pathname: SOAP_PATH }).href(), - secureUri: bonobUrl.append({pathname: SOAP_PATH }).href(), + uri: bonobUrl.append({ pathname: SOAP_PATH }).href(), + secureUri: bonobUrl.append({ pathname: SOAP_PATH }).href(), strings: { - uri: bonobUrl.append({pathname: STRINGS_ROUTE }).href(), + uri: bonobUrl.append({ pathname: STRINGS_ROUTE }).href(), version: PRESENTATION_AND_STRINGS_VERSION, }, presentation: { - uri: bonobUrl.append({pathname: PRESENTATION_MAP_ROUTE }).href(), + uri: bonobUrl.append({ pathname: PRESENTATION_MAP_ROUTE }).href(), version: PRESENTATION_AND_STRINGS_VERSION, }, pollInterval: 1200, @@ -78,12 +78,14 @@ export const bonobService = ( export interface Sonos { devices: () => Promise; services: () => Promise; + remove: (sid: number) => Promise; register: (service: Service) => Promise; } export const SONOS_DISABLED: Sonos = { devices: () => Promise.resolve([]), services: () => Promise.resolve([]), + remove: (_: number) => Promise.resolve(true), register: (_: Service) => Promise.resolve(true), }; @@ -111,6 +113,11 @@ export const asDevice = (sonosDevice: SonosDevice): Device => ({ port: sonosDevice.Port, }); +export const asRemoveCustomdForm = (csrfToken: string, sid: number) => ({ + csrfToken, + sid: `${sid}` +}); + export const asCustomdForm = (csrfToken: string, service: Service) => ({ csrfToken, 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 { devices: async () => sonosDevices().then((it) => it.map(asDevice)), @@ -170,43 +215,9 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { ) .then((it) => it.map(asService)), - register: async (service: Service) => { - const anyDevice = await sonosDevices().then((devices) => head(devices)); + remove: async (sid: number) => post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)), - if (!anyDevice) { - 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); - }, + register: async (service: Service) => post("register", (csrfToken) => asCustomdForm(csrfToken, service)), }; } diff --git a/tests/server.test.ts b/tests/server.test.ts index 8c361c0..108a294 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -224,6 +224,7 @@ describe("server", () => { devices: () => Promise.resolve([device1, device2]), services: () => Promise.resolve([service1, service2, service3, service4]), + remove: () => Promise.resolve(false), register: () => Promise.resolve(false), }; @@ -285,6 +286,7 @@ describe("server", () => { const fakeSonos: Sonos = { devices: () => Promise.resolve([]), services: () => Promise.resolve([service1, service2, bonobService]), + remove: () => Promise.resolve(false), register: () => Promise.resolve(false), }; @@ -340,6 +342,7 @@ describe("server", () => { describe("/register", () => { const sonos = { register: jest.fn(), + remove: jest.fn(), }; const theService = aService({ name: "We can all live a life of service", @@ -352,35 +355,71 @@ describe("server", () => { new InMemoryMusicService() ); - describe("when is successful", () => { - it("should return a nice message", async () => { - sonos.register.mockResolvedValue(true); - - const res = await request(server) - .post(bonobUrl.append({ pathname: "/register" }).path()) - .send(); - - 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); + describe("registering", () => { + describe("when is successful", () => { + it("should return a nice message", async () => { + sonos.register.mockResolvedValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/registration/add" }).path()) + .send(); + + 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); + }); + }); + + 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", () => { - it("should return a failure message", async () => { - sonos.register.mockResolvedValue(false); - - const res = await request(server) - .post(bonobUrl.append({ pathname: "/register" }).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("removing a registration", () => { + describe("when is successful", () => { + it("should return a nice message", async () => { + sonos.remove.mockResolvedValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/registration/remove" }).path()) + .send(); + + expect(res.status).toEqual(200); + expect(res.text).toMatch("Successfully removed registration"); + + 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); + }); }); }); }); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 4aee547..bfff457 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -22,6 +22,7 @@ import sonos, { Service, PRESENTATION_AND_STRINGS_VERSION, BONOB_CAPABILITIES, + asRemoveCustomdForm, } from "../src/sonos"; 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", () => { it("should return a disabled client", async () => { 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: ``, + }); + 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: ``, + }); + + 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: ``, + }); + + 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: ``, + }); + mockPost.mockResolvedValue({ status: 500, data: "" }); + + const result = await sonos().remove(sidToRemove); + + expect(result).toEqual(false); + }); + }); + }); }); diff --git a/web/views/index.eta b/web/views/index.eta index 54183bc..33afb17 100644 --- a/web/views/index.eta +++ b/web/views/index.eta @@ -10,7 +10,8 @@ <% } else { %>

No existing service registration

<% } %> -
+
+

Devices

    <% it.devices.forEach(function(d){ %>