Part of AppLink login process

This commit is contained in:
simojenki
2021-02-21 09:35:34 +11:00
parent 302efd2878
commit c26a325ee1
20 changed files with 644 additions and 253 deletions

View File

@@ -5,16 +5,19 @@ import sonos, { bonobService } from "../src/sonos";
import server from "../src/server";
import logger from "../src/logger";
import { InMemoryMusicService } from "builders";
const bonob = bonobService("bonob-test", 247, "http://localhost:1234");
const app = server(sonos("disabled"), bonob);
const WEB_ADDRESS = "http://localhost:1234"
const bonob = bonobService("bonob-test", 247, WEB_ADDRESS, 'Anonymous');
const app = server(sonos("disabled"), bonob, WEB_ADDRESS, new InMemoryMusicService());
getPort().then((port) => {
logger.debug(`Starting on port ${port}`);
app.listen(port);
createClientAsync(`http://localhost:${port}/ws?wsdl`, {
endpoint: `http://localhost:${port}/ws`,
createClientAsync(`${bonob.uri}?wsdl`, {
endpoint: bonob.uri,
}).then((client) => {
client
.getSessionIdAsync(

View File

@@ -1,22 +1,23 @@
import { SonosDevice } from "@svrooij/sonos/lib";
import { v4 as uuid } from 'uuid';
import { v4 as uuid } from "uuid";
import { MusicService, Credentials } from "../src/music_service";
import { Service, Device } from "../src/sonos";
const randomInt = (max: number) => Math.floor(Math.random() * max)
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`
const randomInt = (max: number) => Math.floor(Math.random() * max);
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
export const aService = (fields: Partial<Service> = {}): Service => ({
name: `Test Music Service ${uuid()}`,
sid: randomInt(500),
uri: "https://sonos.testmusic.com/",
secureUri: "https://sonos.testmusic.com/",
uri: "https://sonos-test.example.com/",
secureUri: "https://sonos-test.example.com/",
strings: {
uri: "https://sonos.testmusic.com/strings.xml",
uri: "https://sonos-test.example.com/strings.xml",
version: "22",
},
presentation: {
uri: "https://sonos.testmusic.com/presentation.xml",
uri: "https://sonos-test.example.com/presentation.xml",
version: "33",
},
pollInterval: 1200,
@@ -35,9 +36,7 @@ export function aDevice(fields: Partial<Device> = {}): Device {
};
}
export function aSonosDevice(
fields: Partial<SonosDevice> = {}
): SonosDevice {
export function aSonosDevice(fields: Partial<SonosDevice> = {}): SonosDevice {
return {
Name: `device-${uuid()}`,
GroupName: `group-${uuid()}`,
@@ -46,3 +45,33 @@ export function aSonosDevice(
...fields,
} as SonosDevice;
}
export function getAppLinkMessage() {
return {
householdId: "",
hardware: "",
osVersion: "",
sonosAppName: "",
callbackPath: "",
};
}
export class InMemoryMusicService implements MusicService {
users: Record<string, string> = {};
login({ username, password }: Credentials) {
return username != undefined && password != undefined && this.users[username] == password;
}
hasUser(credentials: Credentials) {
this.users[credentials.username] = credentials.password;
}
hasNoUsers() {
this.users = {};
}
clear() {
this.users = {};
}
}

View File

@@ -2,11 +2,11 @@ import request from "supertest";
import makeServer from "../src/server";
import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
import { aDevice, aService } from './builders';
import { aDevice, aService, InMemoryMusicService } from './builders';
describe("index", () => {
describe("when sonos integration is disabled", () => {
const server = makeServer(SONOS_DISABLED, aService());
const server = makeServer(SONOS_DISABLED, aService(), 'http://localhost:1234', new InMemoryMusicService());
describe("devices list", () => {
it("should be empty", async () => {
@@ -58,7 +58,7 @@ describe("index", () => {
register: () => Promise.resolve(false),
};
const server = makeServer(fakeSonos, missingBonobService);
const server = makeServer(fakeSonos, missingBonobService, 'http://localhost:1234', new InMemoryMusicService());
describe("devices list", () => {
it("should contain the devices returned from sonos", async () => {
@@ -108,7 +108,7 @@ describe("index", () => {
register: () => Promise.resolve(false),
};
const server = makeServer(fakeSonos, bonobService);
const server = makeServer(fakeSonos, bonobService, 'http://localhost:1234', new InMemoryMusicService());
describe("registration status", () => {
it("should be registered", async () => {

124
tests/scenarios.test.ts Normal file
View File

@@ -0,0 +1,124 @@
import { createClientAsync } from "soap";
import { Express } from "express";
import request from "supertest";
import { GetAppLinkResult } from "../src/smapi";
import { InMemoryMusicService, getAppLinkMessage } from "./builders";
import { InMemoryLinkCodes } from "../src/link_codes";
import { Credentials } from "../src/music_service";
import makeServer from "../src/server";
import { Service, bonobService, SONOS_DISABLED } from "../src/sonos";
import supersoap from "./supersoap";
class SonosDriver {
server: Express;
rootUrl: string;
service: Service;
constructor(server: Express, rootUrl: string, service: Service) {
this.server = server;
this.rootUrl = rootUrl;
this.service = service;
}
stripServiceRoot = (url: string) => url.replace(this.rootUrl, "");
async addService() {
expect(this.service.authType).toEqual("AppLink");
await request(this.server)
.get(this.stripServiceRoot(this.service.strings.uri!))
.expect(200);
await request(this.server)
.get(this.stripServiceRoot(this.service.presentation.uri!))
.expect(200);
return createClientAsync(`${this.service.uri}?wsdl`, {
endpoint: this.service.uri,
httpClient: supersoap(this.server, this.rootUrl),
}).then((client) =>
client
.getAppLinkAsync(getAppLinkMessage())
.then(
([result]: [GetAppLinkResult]) =>
result.getAppLinkResult.authorizeAccount.deviceLink
)
.then(({ regUrl, linkCode }: { regUrl: string; linkCode: string }) => ({
login: async ({ username, password }: Credentials) => {
await request(this.server).get(this.stripServiceRoot(regUrl)).expect(200);
return request(this.server)
.post(this.stripServiceRoot(regUrl))
.type("form")
.send({ username, password, linkCode })
.expect(200)
.then(response => ({
expectSuccess: () => {
expect(response.text).toContain("ok")
},
expectFailure: () => {
expect(response.text).toContain("boo")
},
}));
},
}))
);
}
}
describe("scenarios", () => {
const bonobUrl = "http://localhost:1234";
const bonob = bonobService("bonob", 123, bonobUrl);
const musicService = new InMemoryMusicService();
const linkCodes = new InMemoryLinkCodes();
const server = makeServer(
SONOS_DISABLED,
bonob,
bonobUrl,
musicService,
linkCodes
);
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
beforeEach(() => {
musicService.clear();
linkCodes.clear();
});
describe("adding the service", () => {
describe("when the user exists within the music service", () => {
const username = "validuser";
const password = "validpassword";
it("should successfully sign up", async () => {
musicService.hasUser({ username, password });
await sonosDriver
.addService()
.then((it) => it.login({ username, password }))
.then((it) => it.expectSuccess());
expect(linkCodes.count()).toEqual(1);
});
});
describe("when the user doesnt exists within the music service", () => {
const username = "invaliduser";
const password = "invalidpassword";
it("should fail to sign up", async () => {
musicService.hasNoUsers();
await sonosDriver
.addService()
.then((it) => it.login({ username, password }))
.then((it) => it.expectFailure());
expect(linkCodes.count()).toEqual(1);
});
});
});
});

View File

@@ -1,4 +1,5 @@
global.console = {
// log: console.log,
log: jest.fn(), // console.log are ignored in tests
// Keep native behaviour for other methods, use those to print out things in your own tests, not `console.log`

27
tests/smapi.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import request from "supertest";
import { DOMParserImpl } from 'xmldom-ts';
import * as xpath from 'xpath-ts';
import makeServer from "../src/server";
import { SONOS_DISABLED, STRINGS_PATH } from "../src/sonos";
import { aService, InMemoryMusicService } from './builders';
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
const select = xpath.useNamespaces({"sonos": "http://sonos.com/sonosapi"})
describe('strings.xml', () => {
const server = makeServer(SONOS_DISABLED, aService(), 'http://localhost:1234', new InMemoryMusicService());
it("should return xml for the strings", async () => {
const res = await request(server).get(STRINGS_PATH).send();
expect(res.status).toEqual(200);
const xml = parseXML(res.text);
const x = select("//sonos:string[@stringId='AppLinkMessage']/text()", xml) as Node[]
expect(x.length).toEqual(1)
expect(x[0]!.nodeValue).toEqual("Linking sonos with bonob")
});
});

View File

@@ -122,7 +122,7 @@ describe("sonos", () => {
version: "1",
},
pollInterval: 1200,
authType: "Anonymous",
authType: "AppLink",
});
});
});
@@ -145,7 +145,30 @@ describe("sonos", () => {
version: "1",
},
pollInterval: 1200,
authType: "Anonymous",
authType: "AppLink",
});
});
});
describe("when authType is specified", () => {
it("should return a valid bonob service", () => {
expect(
bonobService("some-bonob", 876, "http://bonob.example.com", 'DeviceLink')
).toEqual({
name: "some-bonob",
sid: 876,
uri: `http://bonob.example.com/ws/sonos`,
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: "1",
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: "1",
},
pollInterval: 1200,
authType: "DeviceLink",
});
});
});
@@ -166,7 +189,7 @@ describe("sonos", () => {
version: "27",
},
pollInterval: 5600,
authType: "SpecialAuth",
authType: "UserId",
};
expect(asCustomdForm(csrfToken, service)).toEqual({
@@ -176,7 +199,7 @@ describe("sonos", () => {
uri: "http://aa.example.com",
secureUri: "https://aa.example.com",
pollInterval: "5600",
authType: "SpecialAuth",
authType: "UserId",
stringsVersion: "26",
stringsUri: "http://strings.example.com",
presentationMapVersion: "27",

25
tests/supersoap.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Express } from "express";
import request from "supertest";
function supersoap(server: Express, rootUrl: string) {
return {
request: (
rurl: string,
data: any,
callback: (error: any, res?: any, body?: any) => any,
exheaders?: any
) => {
const withoutHost = rurl.replace(rootUrl, "");
const req =
data == null
? request(server).get(withoutHost).send()
: request(server).post(withoutHost).send(data);
req
.set(exheaders || {})
.then((response) => callback(null, response, response.text))
.catch(callback);
},
}
}
export default supersoap

View File

@@ -1,38 +1,22 @@
import request from "supertest";
import makeServer from "../src/server";
import { SONOS_DISABLED } from "../src/sonos";
import { SONOS_DISABLED, SOAP_PATH } from "../src/sonos";
import { aService } from "./builders";
import { aService, InMemoryMusicService } from "./builders";
import supersoap from './supersoap';
import { createClientAsync } from "soap";
describe("ws", () => {
describe("can call getSessionId", () => {
it("should do something", async () => {
const server = makeServer(SONOS_DISABLED, aService());
const WEB_ADDRESS = 'http://localhost:7653'
const server = makeServer(SONOS_DISABLED, aService(), WEB_ADDRESS, new InMemoryMusicService());
const { username, sessionId } = await createClientAsync(
`http://localhost/ws?wsdl`,
`${WEB_ADDRESS}${SOAP_PATH}?wsdl`,
{
endpoint: `http://localhost/ws`,
httpClient: {
request: (
rurl: string,
data: any,
callback: (error: any, res?: any, body?: any) => any,
exheaders?: any
) => {
const withoutHost = rurl.replace("http://localhost", "");
const req =
data == null
? request(server).get(withoutHost).send()
: request(server).post(withoutHost).send(data);
req
.set(exheaders || {})
.then((response) => callback(null, response, response.text))
.catch(callback);
},
},
endpoint: `${WEB_ADDRESS}${SOAP_PATH}`,
httpClient: supersoap(server, WEB_ADDRESS),
}
).then((client) =>
client