SmapiAuthTokens that expire, with sonos refreshAuthToken functionality (#81)

Bearer token to Authorization header for stream requests
Versioned SMAPI Tokens
This commit is contained in:
Simon J
2021-12-02 11:03:52 +11:00
committed by GitHub
parent 89340dd454
commit d1300b8119
24 changed files with 1792 additions and 1330 deletions

View File

@@ -1,112 +0,0 @@
import { Dayjs } from "dayjs";
import { v4 as uuid } from "uuid";
import crypto from "crypto";
import { Encryption } from "./encryption";
import logger from "./logger";
import { Clock, SystemClock } from "./clock";
type AccessToken = {
value: string;
authToken: string;
expiry: Dayjs;
};
export interface AccessTokens {
mint(authToken: string): string;
authTokenFor(value: string): string | undefined;
}
export class ExpiringAccessTokens implements AccessTokens {
tokens = new Map<string, AccessToken>();
clock: Clock;
constructor(clock: Clock = SystemClock) {
this.clock = clock;
}
mint(authToken: string): string {
this.clearOutExpired();
const accessToken = {
value: uuid(),
authToken,
expiry: this.clock.now().add(12, "hours"),
};
this.tokens.set(accessToken.value, accessToken);
return accessToken.value;
}
authTokenFor(value: string): string | undefined {
this.clearOutExpired();
return this.tokens.get(value)?.authToken;
}
clearOutExpired() {
Array.from(this.tokens.values())
.filter((it) => it.expiry.isBefore(this.clock.now()))
.forEach((expired) => {
this.tokens.delete(expired.value);
});
}
count = () => this.tokens.size;
}
export class EncryptedAccessTokens implements AccessTokens {
encryption: Encryption;
constructor(encryption: Encryption) {
this.encryption = encryption;
}
mint = (authToken: string): string => this.encryption.encrypt(authToken);
authTokenFor(value: string): string | undefined {
try {
return this.encryption.decrypt(value);
} catch {
logger.warn("Failed to decrypt access token...");
return undefined;
}
}
}
export class AccessTokenPerAuthToken implements AccessTokens {
authTokenToAccessToken = new Map<string, string>();
accessTokenToAuthToken = new Map<string, string>();
mint = (authToken: string): string => {
if (this.authTokenToAccessToken.has(authToken)) {
return this.authTokenToAccessToken.get(authToken)!;
} else {
const accessToken = uuid();
this.authTokenToAccessToken.set(authToken, accessToken);
this.accessTokenToAuthToken.set(accessToken, authToken);
return accessToken;
}
};
authTokenFor = (value: string): string | undefined => this.accessTokenToAuthToken.get(value);
}
export const sha256 = (salt: string) => (authToken: string) => crypto
.createHash("sha256")
.update(`${authToken}${salt}`)
.digest("hex")
export class InMemoryAccessTokens implements AccessTokens {
tokens = new Map<string, string>();
minter;
constructor(minter: (authToken: string) => string) {
this.minter = minter
}
mint = (authToken: string): string => {
const accessToken = this.minter(authToken);
this.tokens.set(accessToken, authToken);
return accessToken;
}
authTokenFor = (value: string): string | undefined => this.tokens.get(value);
}

30
src/api_tokens.ts Normal file
View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
export interface APITokens {
mint(authToken: string): string;
authTokenFor(apiToken: string): string | undefined;
}
export const sha256 = (salt: string) => (value: string) => crypto
.createHash("sha256")
.update(`${value}${salt}`)
.digest("hex")
export class InMemoryAPITokens implements APITokens {
tokens = new Map<string, string>();
minter;
constructor(minter: (authToken: string) => string = sha256('bonob')) {
this.minter = minter
}
mint = (authToken: string): string => {
const accessToken = this.minter(authToken);
this.tokens.set(accessToken, authToken);
return accessToken;
}
authTokenFor = (apiToken: string): string | undefined => this.tokens.get(apiToken);
}

View File

@@ -10,15 +10,16 @@ import {
DEFAULT, DEFAULT,
Subsonic, Subsonic,
} from "./subsonic"; } from "./subsonic";
import { InMemoryAccessTokens, sha256 } from "./access_tokens"; import { InMemoryAPITokens, sha256 } from "./api_tokens";
import { InMemoryLinkCodes } from "./link_codes"; import { InMemoryLinkCodes } from "./link_codes";
import readConfig from "./config"; import readConfig from "./config";
import sonos, { bonobService } from "./sonos"; import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service"; import { MusicService } from "./music_service";
import { SystemClock } from "./clock"; import { SystemClock } from "./clock";
import { jwtSigner } from "./encryption"; import { JWTSmapiLoginTokens } from "./smapi_auth";
const config = readConfig(); const config = readConfig();
const clock = SystemClock;
logger.info(`Starting bonob with config ${JSON.stringify(config)}`); logger.info(`Starting bonob with config ${JSON.stringify(config)}`);
@@ -47,8 +48,8 @@ const subsonic = new Subsonic(
const featureFlagAwareMusicService: MusicService = { const featureFlagAwareMusicService: MusicService = {
generateToken: subsonic.generateToken, generateToken: subsonic.generateToken,
login: (authToken: string) => login: (serviceToken: string) =>
subsonic.login(authToken).then((library) => { subsonic.login(serviceToken).then((library) => {
return { return {
...library, ...library,
scrobble: (id: string) => { scrobble: (id: string) => {
@@ -82,13 +83,13 @@ const app = server(
featureFlagAwareMusicService, featureFlagAwareMusicService,
{ {
linkCodes: () => new InMemoryLinkCodes(), linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)), apiTokens: () => new InMemoryAPITokens(sha256(config.secret)),
clock: SystemClock, clock,
iconColors: config.icons, iconColors: config.icons,
applyContextPath: true, applyContextPath: true,
logRequests: true, logRequests: true,
version, version,
tokenSigner: jwtSigner(config.secret), smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, '1h'),
externalImageResolver: artistImageFetcher externalImageResolver: artistImageFetcher
} }
); );

View File

@@ -14,3 +14,15 @@ export interface Clock {
} }
export const SystemClock = { now: () => dayjs() }; export const SystemClock = { now: () => dayjs() };
export class FixedClock implements Clock {
time: Dayjs;
constructor(time: Dayjs = dayjs()) {
this.time = time;
}
add = (t: number, unit: dayjs.UnitTypeShort) => this.time = this.time.add(t, unit)
now = () => this.time;
}

View File

@@ -4,54 +4,12 @@ import {
randomBytes, randomBytes,
createHash, createHash,
} from "crypto"; } from "crypto";
import jwt from "jsonwebtoken";
import jws from "jws"; import jws from "jws";
const ALGORITHM = "aes-256-cbc"; const ALGORITHM = "aes-256-cbc";
const IV = randomBytes(16); const IV = randomBytes(16);
function isError(thing: any): thing is Error {
return thing.name && thing.message
}
export type Signer = {
sign: (value: string) => string;
verify: (token: string) => string;
};
export const pSigner = (signer: Signer) => ({
sign: (value: string): Promise<string> => {
return new Promise((resolve, reject) => {
try {
return resolve(signer.sign(value));
} catch(e) {
if(isError(e)) reject(e.message)
else reject(`Failed to sign value: ${e}`);
}
});
},
verify: (token: string): Promise<string> => {
return new Promise((resolve, reject) => {
try {
return resolve(signer.verify(token));
}catch(e) {
if(isError(e)) reject(e.message)
else reject(`Failed to verify value: ${e}`);
}
});
}
});
export const jwtSigner = (secret: string) => ({
sign: (value: string) => jwt.sign(value, secret),
verify: (token: string) => {
try {
return jwt.verify(token, secret) as string;
} catch (e) {
throw new Error(`Failed to verify jwt, try re-authorising account within sonos app`);
}
},
});
export type Hash = { export type Hash = {
iv: string; iv: string;

View File

@@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid';
export type Association = { export type Association = {
authToken: string serviceToken: string
userId: string userId: string
nickname: string nickname: string
} }

View File

@@ -5,7 +5,7 @@ export type Credentials = { username: string; password: string };
export function isSuccess( export function isSuccess(
authResult: AuthSuccess | AuthFailure authResult: AuthSuccess | AuthFailure
): authResult is AuthSuccess { ): authResult is AuthSuccess {
return (authResult as AuthSuccess).authToken !== undefined; return (authResult as AuthSuccess).serviceToken !== undefined;
} }
export function isFailure( export function isFailure(
@@ -15,7 +15,7 @@ export function isFailure(
} }
export type AuthSuccess = { export type AuthSuccess = {
authToken: string; serviceToken: string;
userId: string; userId: string;
nickname: string; nickname: string;
}; };
@@ -156,7 +156,7 @@ export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
export interface MusicService { export interface MusicService {
generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>; generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>;
login(authToken: string): Promise<MusicLibrary>; login(serviceToken: string): Promise<MusicLibrary>;
} }
export interface MusicLibrary { export interface MusicLibrary {

View File

@@ -1,4 +1,4 @@
import { option as O } from "fp-ts"; import { either as E } from "fp-ts";
import express, { Express, Request } from "express"; import express, { Express, Request } from "express";
import * as Eta from "eta"; import * as Eta from "eta";
import path from "path"; import path from "path";
@@ -24,7 +24,7 @@ import {
import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service"; import { MusicService, isSuccess } from "./music_service";
import bindSmapiSoapServiceToExpress from "./smapi"; import bindSmapiSoapServiceToExpress from "./smapi";
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import { APITokens, InMemoryAPITokens } from "./api_tokens";
import logger from "./logger"; import logger from "./logger";
import { Clock, SystemClock } from "./clock"; import { Clock, SystemClock } from "./clock";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
@@ -34,9 +34,9 @@ import { Icon, ICONS, festivals, features } from "./icon";
import _, { shuffle } from "underscore"; import _, { shuffle } from "underscore";
import morgan from "morgan"; import morgan from "morgan";
import { takeWithRepeats } from "./utils"; import { takeWithRepeats } from "./utils";
import { jwtSigner, Signer } from "./encryption";
import { parse } from "./burn"; import { parse } from "./burn";
import { axiosImageFetcher, ImageFetcher } from "./subsonic"; import { axiosImageFetcher, ImageFetcher } from "./subsonic";
import { JWTSmapiLoginTokens, SmapiAuthTokens, SmapiToken } from "./smapi_auth";
export const BONOB_ACCESS_TOKEN_HEADER = "bat"; export const BONOB_ACCESS_TOKEN_HEADER = "bat";
@@ -79,7 +79,7 @@ export class RangeBytesFromFilter extends Transform {
export type ServerOpts = { export type ServerOpts = {
linkCodes: () => LinkCodes; linkCodes: () => LinkCodes;
accessTokens: () => AccessTokens; apiTokens: () => APITokens;
clock: Clock; clock: Clock;
iconColors: { iconColors: {
foregroundColor: string | undefined; foregroundColor: string | undefined;
@@ -88,20 +88,24 @@ export type ServerOpts = {
applyContextPath: boolean; applyContextPath: boolean;
logRequests: boolean; logRequests: boolean;
version: string; version: string;
tokenSigner: Signer; smapiAuthTokens: SmapiAuthTokens;
externalImageResolver: ImageFetcher; externalImageResolver: ImageFetcher;
}; };
const DEFAULT_SERVER_OPTS: ServerOpts = { const DEFAULT_SERVER_OPTS: ServerOpts = {
linkCodes: () => new InMemoryLinkCodes(), linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => new AccessTokenPerAuthToken(), apiTokens: () => new InMemoryAPITokens(),
clock: SystemClock, clock: SystemClock,
iconColors: { foregroundColor: undefined, backgroundColor: undefined }, iconColors: { foregroundColor: undefined, backgroundColor: undefined },
applyContextPath: true, applyContextPath: true,
logRequests: false, logRequests: false,
version: "v?", version: "v?",
tokenSigner: jwtSigner(`bonob-${uuid()}`), smapiAuthTokens: new JWTSmapiLoginTokens(
externalImageResolver: axiosImageFetcher SystemClock,
`bonob-${uuid()}`,
"1m"
),
externalImageResolver: axiosImageFetcher,
}; };
function server( function server(
@@ -114,7 +118,8 @@ function server(
const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts }; const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts };
const linkCodes = serverOpts.linkCodes(); const linkCodes = serverOpts.linkCodes();
const accessTokens = serverOpts.accessTokens(); const smapiAuthTokens = serverOpts.smapiAuthTokens;
const apiTokens = serverOpts.apiTokens();
const clock = serverOpts.clock; const clock = serverOpts.clock;
const startUpTime = dayjs(); const startUpTime = dayjs();
@@ -228,30 +233,33 @@ function server(
message: lang("invalidLinkCode"), message: lang("invalidLinkCode"),
}); });
} else { } else {
return musicService.generateToken({ return musicService
username, .generateToken({
password, username,
}).then(authResult => { password,
if (isSuccess(authResult)) { })
linkCodes.associate(linkCode, authResult); .then((authResult) => {
return res.render("success", { if (isSuccess(authResult)) {
lang, linkCodes.associate(linkCode, authResult);
message: lang("loginSuccessful"), return res.render("success", {
}); lang,
} else { message: lang("loginSuccessful"),
});
} else {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: authResult.message,
});
}
})
.catch((e) => {
return res.status(403).render("failure", { return res.status(403).render("failure", {
lang, lang,
message: lang("loginFailed"), message: lang("loginFailed"),
cause: authResult.message, cause: `Unexpected error occured - ${e}`,
}); });
}
}).catch(e => {
return res.status(403).render("failure", {
lang,
message: lang("loginFailed"),
cause: `Unexpected error occured - ${e}`,
}); });
});
} }
}); });
@@ -276,23 +284,36 @@ function server(
const nowPlayingRatingsMatch = (value: number) => { const nowPlayingRatingsMatch = (value: number) => {
const rating = ratingFromInt(value); const rating = ratingFromInt(value);
const nextLove = { ...rating, love: !rating.love }; const nextLove = { ...rating, love: !rating.love };
const nextStar = { ...rating, stars: (rating.stars === 5 ? 0 : rating.stars + 1) } const nextStar = {
...rating,
stars: rating.stars === 5 ? 0 : rating.stars + 1,
};
const loveRatingIcon = bonobUrl.append({pathname: rating.love ? '/love-selected.svg' : '/love-unselected.svg'}).href(); const loveRatingIcon = bonobUrl
const starsRatingIcon = bonobUrl.append({pathname: `/star${rating.stars}.svg`}).href(); .append({
pathname: rating.love ? "/love-selected.svg" : "/love-unselected.svg",
})
.href();
const starsRatingIcon = bonobUrl
.append({ pathname: `/star${rating.stars}.svg` })
.href();
return `<Match propname="rating" value="${value}"> return `<Match propname="rating" value="${value}">
<Ratings> <Ratings>
<Rating Id="${ratingAsInt(nextLove)}" AutoSkip="NEVER" OnSuccessStringId="LOVE_SUCCESS" StringId="LOVE"> <Rating Id="${ratingAsInt(
nextLove
)}" AutoSkip="NEVER" OnSuccessStringId="LOVE_SUCCESS" StringId="LOVE">
<Icon Controller="universal" LastModified="${LastModified}" Uri="${loveRatingIcon}" /> <Icon Controller="universal" LastModified="${LastModified}" Uri="${loveRatingIcon}" />
</Rating> </Rating>
<Rating Id="${-ratingAsInt(nextStar)}" AutoSkip="NEVER" OnSuccessStringId="STAR_SUCCESS" StringId="STAR"> <Rating Id="${-ratingAsInt(
nextStar
)}" AutoSkip="NEVER" OnSuccessStringId="STAR_SUCCESS" StringId="STAR">
<Icon Controller="universal" LastModified="${LastModified}" Uri="${starsRatingIcon}" /> <Icon Controller="universal" LastModified="${LastModified}" Uri="${starsRatingIcon}" />
</Rating> </Rating>
</Ratings> </Ratings>
</Match>` </Match>`;
} };
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?> res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
<Presentation> <Presentation>
<BrowseOptions PageSize="30" /> <BrowseOptions PageSize="30" />
@@ -348,21 +369,32 @@ function server(
const trace = uuid(); const trace = uuid();
logger.info( logger.info(
`${trace} bnb<- ${req.method} ${req.path}?${ `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
JSON.stringify(req.query) req.query
}, headers=${JSON.stringify(req.headers)}` )}, headers=${JSON.stringify({ ...req.headers, "authorization": "***" })}`
); );
const authToken = pipe(
req.query[BONOB_ACCESS_TOKEN_HEADER] as string, const authHeader = E.fromNullable("Missing header");
O.fromNullable, const bearerToken = E.fromNullable("No Bearer token");
O.map((accessToken) => accessTokens.authTokenFor(accessToken)), const serviceToken = pipe(
O.getOrElseW(() => undefined) authHeader(req.headers["authorization"] as string),
E.chain(authorization => pipe(
authorization.match(/Bearer (?<token>.*)/),
bearerToken,
E.map(match => match[1]!)
)),
E.chain(bearerToken => pipe(
smapiAuthTokens.verify(bearerToken as unknown as SmapiToken),
E.mapLeft(_ => "Bearer token failed to verify")
)),
E.getOrElseW(() => undefined)
); );
if (!authToken) {
if (!serviceToken) {
return res.status(401).send(); return res.status(401).send();
} else { } else {
return musicService return musicService
.login(authToken) .login(serviceToken)
.then((it) => .then((it) =>
it it
.stream({ .stream({
@@ -382,7 +414,7 @@ function server(
contentType contentType
.split(";") .split(";")
.map((it) => it.trim()) .map((it) => it.trim())
.map((it) => sonosifyMimeType(it)) .map(sonosifyMimeType)
.join("; "); .join("; ");
const respondWith = ({ const respondWith = ({
@@ -532,27 +564,31 @@ function server(
]; ];
app.get("/art/:burns/size/:size", (req, res) => { app.get("/art/:burns/size/:size", (req, res) => {
const authToken = accessTokens.authTokenFor( const serviceToken = apiTokens.authTokenFor(
req.query[BONOB_ACCESS_TOKEN_HEADER] as string req.query[BONOB_ACCESS_TOKEN_HEADER] as string
); );
const urns = req.params["burns"]!.split("&").map(parse); const urns = req.params["burns"]!.split("&").map(parse);
const size = Number.parseInt(req.params["size"]!); const size = Number.parseInt(req.params["size"]!);
if (!authToken) { if (!serviceToken) {
return res.status(401).send(); return res.status(401).send();
} else if (!(size > 0)) { } else if (!(size > 0)) {
return res.status(400).send(); return res.status(400).send();
} }
return musicService return musicService
.login(authToken) .login(serviceToken)
.then((musicLibrary) => Promise.all(urns.map((it) => { .then((musicLibrary) =>
if(it.system == "external") { Promise.all(
return serverOpts.externalImageResolver(it.resource); urns.map((it) => {
} else { if (it.system == "external") {
return musicLibrary.coverArt(it, size); return serverOpts.externalImageResolver(it.resource);
} } else {
}))) return musicLibrary.coverArt(it, size);
}
})
)
)
.then((coverArts) => coverArts.filter((it) => it)) .then((coverArts) => coverArts.filter((it) => it))
.then(shuffle) .then(shuffle)
.then((coverArts) => { .then((coverArts) => {
@@ -603,10 +639,10 @@ function server(
bonobUrl, bonobUrl,
linkCodes, linkCodes,
musicService, musicService,
accessTokens, apiTokens,
clock, clock,
i8n, i8n,
serverOpts.tokenSigner serverOpts.smapiAuthTokens
); );
if (serverOpts.applyContextPath) { if (serverOpts.applyContextPath) {

View File

@@ -3,8 +3,8 @@ import { Express, Request } from "express";
import { listen } from "soap"; import { listen } from "soap";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import path from "path"; import path from "path";
import { option as O, either as E } from "fp-ts";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
import { option as O } from "fp-ts";
import logger from "./logger"; import logger from "./logger";
@@ -21,14 +21,20 @@ import {
slice2, slice2,
Track, Track,
} from "./music_service"; } from "./music_service";
import { AccessTokens } from "./access_tokens"; import { APITokens } from "./api_tokens";
import { Clock } from "./clock"; import { Clock } from "./clock";
import { URLBuilder } from "./url_builder"; import { URLBuilder } from "./url_builder";
import { asLANGs, I8N } from "./i8n"; import { asLANGs, I8N } from "./i8n";
import { ICON, iconForGenre } from "./icon"; import { ICON, iconForGenre } from "./icon";
import _, { uniq } from "underscore"; import _, { uniq } from "underscore";
import { pSigner, Signer } from "./encryption";
import { BUrn, formatForURL } from "./burn"; import { BUrn, formatForURL } from "./burn";
import {
InvalidTokenError,
isSmapiRefreshTokenResultFault,
MissingLoginTokenError,
SmapiAuthTokens,
smapiTokenAsString,
} from "./smapi_auth";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
export const CREATE_REGISTRATION_ROUTE = "/registration/add"; export const CREATE_REGISTRATION_ROUTE = "/registration/add";
@@ -60,6 +66,7 @@ const WSDL_FILE = path.resolve(
export type Credentials = { export type Credentials = {
loginToken: { loginToken: {
token: string; token: string;
key: string;
householdId: string; householdId: string;
}; };
deviceId: string; deviceId: string;
@@ -150,12 +157,19 @@ export function searchResult(
class SonosSoap { class SonosSoap {
linkCodes: LinkCodes; linkCodes: LinkCodes;
bonobUrl: URLBuilder; bonobUrl: URLBuilder;
tokenSigner: Signer smapiAuthTokens: SmapiAuthTokens;
clock: Clock;
constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes, tokenSigner: Signer) { constructor(
bonobUrl: URLBuilder,
linkCodes: LinkCodes,
smapiAuthTokens: SmapiAuthTokens,
clock: Clock
) {
this.bonobUrl = bonobUrl; this.bonobUrl = bonobUrl;
this.linkCodes = linkCodes; this.linkCodes = linkCodes;
this.tokenSigner = tokenSigner this.smapiAuthTokens = smapiAuthTokens;
this.clock = clock;
} }
getAppLink(): GetAppLinkResult { getAppLink(): GetAppLinkResult {
@@ -184,10 +198,13 @@ class SonosSoap {
}): GetDeviceAuthTokenResult { }): GetDeviceAuthTokenResult {
const association = this.linkCodes.associationFor(linkCode); const association = this.linkCodes.associationFor(linkCode);
if (association) { if (association) {
const smapiAuthToken = this.smapiAuthTokens.issue(
association.serviceToken
);
return { return {
getDeviceAuthTokenResult: { getDeviceAuthTokenResult: {
authToken: this.tokenSigner.sign(association.authToken), authToken: smapiAuthToken.token,
privateKey: "", privateKey: smapiAuthToken.key,
userInfo: { userInfo: {
nickname: association.nickname, nickname: association.nickname,
userIdHashCode: crypto userIdHashCode: crypto
@@ -249,13 +266,18 @@ export const playlistAlbumArtURL = (
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
playlist: Playlist playlist: Playlist
) => { ) => {
const burns: BUrn[] = uniq(playlist.entries.filter(it => it.coverArt != undefined), it => it.album.id).map((it) => it.coverArt!); const burns: BUrn[] = uniq(
console.log(`### playlist ${playlist.name} burns -> ${JSON.stringify(burns)}`) playlist.entries.filter((it) => it.coverArt != undefined),
(it) => it.album.id
).map((it) => it.coverArt!);
if (burns.length == 0) { if (burns.length == 0) {
return iconArtURI(bonobUrl, "error"); return iconArtURI(bonobUrl, "error");
} else { } else {
return bonobUrl.append({ return bonobUrl.append({
pathname: `/art/${burns.slice(0, 9).map(it => encodeURIComponent(formatForURL(it))).join("&")}/size/180`, pathname: `/art/${burns
.slice(0, 9)
.map((it) => encodeURIComponent(formatForURL(it)))
.join("&")}/size/180`,
}); });
} }
}; };
@@ -263,12 +285,17 @@ export const playlistAlbumArtURL = (
export const defaultAlbumArtURI = ( export const defaultAlbumArtURI = (
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
{ coverArt }: { coverArt: BUrn | undefined } { coverArt }: { coverArt: BUrn | undefined }
) => pipe( ) =>
coverArt, pipe(
O.fromNullable, coverArt,
O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })), O.fromNullable,
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) O.map((it) =>
); bonobUrl.append({
pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`,
})
),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
bonobUrl.append({ bonobUrl.append({
@@ -278,12 +305,17 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
export const defaultArtistArtURI = ( export const defaultArtistArtURI = (
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
artist: ArtistSummary artist: ArtistSummary
) => pipe( ) =>
artist.image, pipe(
O.fromNullable, artist.image,
O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })), O.fromNullable,
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) O.map((it) =>
); bonobUrl.append({
pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`,
})
),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
export const sonosifyMimeType = (mimeType: string) => export const sonosifyMimeType = (mimeType: string) =>
mimeType == "audio/x-flac" ? "audio/flac" : mimeType; mimeType == "audio/x-flac" ? "audio/flac" : mimeType;
@@ -312,7 +344,7 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({
album: track.album.name, album: track.album.name,
albumId: `album:${track.album.id}`, albumId: `album:${track.album.id}`,
albumArtist: track.artist.name, albumArtist: track.artist.name,
albumArtistId: track.artist.id? `artist:${track.artist.id}` : undefined, albumArtistId: track.artist.id ? `artist:${track.artist.id}` : undefined,
albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(), albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(),
artist: track.artist.name, artist: track.artist.name,
artistId: track.artist.id ? `artist:${track.artist.id}` : undefined, artistId: track.artist.id ? `artist:${track.artist.id}` : undefined,
@@ -353,12 +385,12 @@ function bindSmapiSoapServiceToExpress(
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
linkCodes: LinkCodes, linkCodes: LinkCodes,
musicService: MusicService, musicService: MusicService,
accessTokens: AccessTokens, apiKeys: APITokens,
clock: Clock, clock: Clock,
i8n: I8N, i8n: I8N,
tokenSigner: Signer, smapiAuthTokens: SmapiAuthTokens
) { ) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, tokenSigner); const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock);
const urlWithToken = (accessToken: string) => const urlWithToken = (accessToken: string) =>
bonobUrl.append({ bonobUrl.append({
@@ -367,31 +399,47 @@ function bindSmapiSoapServiceToExpress(
}, },
}); });
const auth = async ( const auth = (credentials?: Credentials) => {
credentials?: Credentials const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
) => { return pipe(
if (!credentials) { credentialsFrom(credentials),
throw { E.chain((credentials) =>
Fault: { pipe(
faultcode: "Client.LoginUnsupported", smapiAuthTokens.verify({
faultstring: "Missing credentials...", token: credentials.loginToken.token,
}, key: credentials.loginToken.key,
}; }),
} E.map((serviceToken) => ({
serviceToken,
credentials,
}))
)
),
E.map(({ serviceToken, credentials }) => ({
serviceToken,
credentials,
apiKey: apiKeys.mint(serviceToken),
}))
);
};
return pSigner(tokenSigner) const login = async (credentials?: Credentials) => {
.verify(credentials.loginToken.token) const tokens = pipe(
.then(authToken => ({ authToken, accessToken: accessTokens.mint(authToken) })) auth(credentials),
.then((tokens) => musicService.login(tokens.authToken).then(musicLibrary => ({ ...tokens, musicLibrary }))) E.getOrElseW((e) => {
.catch((_) => { throw e.toSmapiFault(smapiAuthTokens);
throw { })
Fault: { );
faultcode: "Client.LoginUnauthorized",
faultstring: "Failed to authenticate, try Reauthorising your account in the sonos app", return musicService
}, .login(tokens.serviceToken)
}; .then((musicLibrary) => ({ ...tokens, musicLibrary }))
}); .catch((_) => {
}; throw new InvalidTokenError("Failed to login").toSmapiFault(
smapiAuthTokens
);
});
};
const soapyService = listen( const soapyService = listen(
app, app,
@@ -410,31 +458,65 @@ function bindSmapiSoapServiceToExpress(
pollInterval: 60, pollInterval: 60,
}, },
}), }),
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) =>
pipe(
auth(soapyHeaders?.credentials),
E.map(({ serviceToken }) => smapiAuthTokens.issue(serviceToken)),
E.map((newToken) => ({
authToken: newToken.token,
privateKey: newToken.key,
})),
E.orElse((fault) =>
pipe(
fault.toSmapiFault(smapiAuthTokens),
E.fromPredicate(isSmapiRefreshTokenResultFault, (_) => fault),
E.map((it) => it.Fault.detail.refreshAuthTokenResult)
)
),
E.map((newToken) => ({
refreshAuthTokenResult: {
authToken: newToken.authToken,
privateKey: newToken.privateKey,
},
})),
E.getOrElseW((fault) => {
throw fault.toSmapiFault(smapiAuthTokens);
})
),
getMediaURI: async ( getMediaURI: async (
{ id }: { id: string }, { id }: { id: string },
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(({ accessToken, type, typeId }) => ({ .then(({ credentials, type, typeId }) => ({
getMediaURIResult: bonobUrl getMediaURIResult: bonobUrl
.append({ .append({
pathname: `/stream/${type}/${typeId}`, pathname: `/stream/${type}/${typeId}`,
searchParams: { bat: accessToken },
}) })
.href(), .href(),
httpHeaders: [
{
httpHeader: {
header: "Authorization",
value: `Bearer ${smapiTokenAsString(
credentials.loginToken
)}`,
},
},
],
})), })),
getMediaMetadata: async ( getMediaMetadata: async (
{ id }: { id: string }, { id }: { id: string },
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(async ({ musicLibrary, accessToken, typeId }) => .then(async ({ musicLibrary, apiKey, typeId }) =>
musicLibrary.track(typeId!).then((it) => ({ musicLibrary.track(typeId!).then((it) => ({
getMediaMetadataResult: track(urlWithToken(accessToken), it), getMediaMetadataResult: track(urlWithToken(apiKey), it),
})) }))
), ),
search: async ( search: async (
@@ -442,16 +524,16 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(async ({ musicLibrary, accessToken }) => { .then(async ({ musicLibrary, apiKey }) => {
switch (id) { switch (id) {
case "albums": case "albums":
return musicLibrary.searchAlbums(term).then((it) => return musicLibrary.searchAlbums(term).then((it) =>
searchResult({ searchResult({
count: it.length, count: it.length,
mediaCollection: it.map((albumSummary) => mediaCollection: it.map((albumSummary) =>
album(urlWithToken(accessToken), albumSummary) album(urlWithToken(apiKey), albumSummary)
), ),
}) })
); );
@@ -460,7 +542,7 @@ function bindSmapiSoapServiceToExpress(
searchResult({ searchResult({
count: it.length, count: it.length,
mediaCollection: it.map((artistSummary) => mediaCollection: it.map((artistSummary) =>
artist(urlWithToken(accessToken), artistSummary) artist(urlWithToken(apiKey), artistSummary)
), ),
}) })
); );
@@ -469,7 +551,7 @@ function bindSmapiSoapServiceToExpress(
searchResult({ searchResult({
count: it.length, count: it.length,
mediaCollection: it.map((aTrack) => mediaCollection: it.map((aTrack) =>
album(urlWithToken(accessToken), aTrack.album) album(urlWithToken(apiKey), aTrack.album)
), ),
}) })
); );
@@ -487,9 +569,9 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(async ({ musicLibrary, accessToken, type, typeId }) => { .then(async ({ musicLibrary, apiKey, type, typeId }) => {
const paging = { _index: index, _count: count }; const paging = { _index: index, _count: count };
switch (type) { switch (type) {
case "artist": case "artist":
@@ -503,7 +585,7 @@ function bindSmapiSoapServiceToExpress(
index: paging._index, index: paging._index,
total, total,
mediaCollection: page.map((it) => mediaCollection: page.map((it) =>
album(urlWithToken(accessToken), it) album(urlWithToken(apiKey), it)
), ),
relatedBrowse: relatedBrowse:
artist.similarArtists.filter((it) => it.inLibrary) artist.similarArtists.filter((it) => it.inLibrary)
@@ -521,7 +603,7 @@ function bindSmapiSoapServiceToExpress(
case "track": case "track":
return musicLibrary.track(typeId).then((it) => ({ return musicLibrary.track(typeId).then((it) => ({
getExtendedMetadataResult: { getExtendedMetadataResult: {
mediaMetadata: track(urlWithToken(accessToken), it), mediaMetadata: track(urlWithToken(apiKey), it),
}, },
})); }));
case "album": case "album":
@@ -533,7 +615,7 @@ function bindSmapiSoapServiceToExpress(
userContent: false, userContent: false,
renameable: false, renameable: false,
}, },
...album(urlWithToken(accessToken), it), ...album(urlWithToken(apiKey), it),
}, },
// <mediaCollection readonly="true"> // <mediaCollection readonly="true">
// </mediaCollection> // </mediaCollection>
@@ -559,9 +641,9 @@ function bindSmapiSoapServiceToExpress(
soapyHeaders: SoapyHeaders, soapyHeaders: SoapyHeaders,
{ headers }: Pick<Request, "headers"> { headers }: Pick<Request, "headers">
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(({ musicLibrary, accessToken, type, typeId }) => { .then(({ musicLibrary, apiKey, type, typeId }) => {
const paging = { _index: index, _count: count }; const paging = { _index: index, _count: count };
const acceptLanguage = headers["accept-language"]; const acceptLanguage = headers["accept-language"];
logger.debug( logger.debug(
@@ -573,7 +655,7 @@ function bindSmapiSoapServiceToExpress(
musicLibrary.albums(q).then((result) => { musicLibrary.albums(q).then((result) => {
return getMetadataResult({ return getMetadataResult({
mediaCollection: result.results.map((it) => mediaCollection: result.results.map((it) =>
album(urlWithToken(accessToken), it) album(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total: result.total, total: result.total,
@@ -684,7 +766,7 @@ function bindSmapiSoapServiceToExpress(
return musicLibrary.artists(paging).then((result) => { return musicLibrary.artists(paging).then((result) => {
return getMetadataResult({ return getMetadataResult({
mediaCollection: result.results.map((it) => mediaCollection: result.results.map((it) =>
artist(urlWithToken(accessToken), it) artist(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total: result.total, total: result.total,
@@ -759,7 +841,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({
mediaCollection: page.map((it) => mediaCollection: page.map((it) =>
playlist(urlWithToken(accessToken), it) playlist(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total, total,
@@ -773,7 +855,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({
mediaMetadata: page.map((it) => mediaMetadata: page.map((it) =>
track(urlWithToken(accessToken), it) track(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total, total,
@@ -787,7 +869,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({
mediaCollection: page.map((it) => mediaCollection: page.map((it) =>
album(urlWithToken(accessToken), it) album(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total, total,
@@ -804,7 +886,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({
mediaCollection: page.map((it) => mediaCollection: page.map((it) =>
artist(urlWithToken(accessToken), it) artist(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total, total,
@@ -817,7 +899,7 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({
mediaMetadata: page.map((it) => mediaMetadata: page.map((it) =>
track(urlWithToken(accessToken), it) track(urlWithToken(apiKey), it)
), ),
index: paging._index, index: paging._index,
total, total,
@@ -832,7 +914,7 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(({ musicLibrary }) => .then(({ musicLibrary }) =>
musicLibrary musicLibrary
.createPlaylist(title) .createPlaylist(title)
@@ -858,7 +940,7 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
.then((_) => ({ deleteContainerResult: {} })), .then((_) => ({ deleteContainerResult: {} })),
addToContainer: async ( addToContainer: async (
@@ -866,7 +948,7 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(({ musicLibrary, typeId }) => .then(({ musicLibrary, typeId }) =>
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
@@ -877,7 +959,7 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then((it) => ({ .then((it) => ({
...it, ...it,
@@ -900,7 +982,7 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(({ musicLibrary, typeId }) => .then(({ musicLibrary, typeId }) =>
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
@@ -912,7 +994,7 @@ function bindSmapiSoapServiceToExpress(
_, _,
soapyHeaders: SoapyHeaders soapyHeaders: SoapyHeaders
) => ) =>
auth(soapyHeaders?.credentials) login(soapyHeaders?.credentials)
.then(splitId(id)) .then(splitId(id))
.then(({ musicLibrary, type, typeId }) => { .then(({ musicLibrary, type, typeId }) => {
switch (type) { switch (type) {

153
src/smapi_auth.ts Normal file
View File

@@ -0,0 +1,153 @@
import { Either, left, right } from "fp-ts/lib/Either";
import jwt from "jsonwebtoken";
import { v4 as uuid } from "uuid";
import { b64Decode, b64Encode } from "./b64";
import { Clock } from "./clock";
export type SmapiFault = { Fault: { faultcode: string, faultstring: string } }
export type SmapiRefreshTokenResultFault = SmapiFault & { Fault: { detail: { refreshAuthTokenResult: { authToken: string, privateKey: string } }} }
function isError(thing: any): thing is Error {
return thing.name && thing.message
}
export function isSmapiRefreshTokenResultFault(fault: SmapiFault): fault is SmapiRefreshTokenResultFault {
return (fault.Fault as any).detail?.refreshAuthTokenResult != undefined;
}
export type SmapiToken = {
token: string;
key: string;
};
export interface ToSmapiFault {
toSmapiFault(smapiAuthTokens: SmapiAuthTokens): SmapiFault
}
export class MissingLoginTokenError extends Error implements ToSmapiFault {
_tag = "MissingLoginTokenError";
constructor() {
super("Missing Login Token");
}
toSmapiFault = (_: SmapiAuthTokens) => ({
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
})
}
export class InvalidTokenError extends Error implements ToSmapiFault {
_tag = "InvalidTokenError";
constructor(message: string) {
super(message);
}
toSmapiFault = (_: SmapiAuthTokens) => ({
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Failed to authenticate, try Re-Authorising your account in the sonos app",
},
})
}
export class ExpiredTokenError extends Error implements ToSmapiFault {
_tag = "ExpiredTokenError";
authToken: string;
expiredAt: number;
constructor(authToken: string, expiredAt: number) {
super("SMAPI token has expired");
this.authToken = authToken;
this.expiredAt = expiredAt;
}
toSmapiFault = (smapiAuthTokens: SmapiAuthTokens) => {
const newToken = smapiAuthTokens.issue(this.authToken)
return {
Fault: {
faultcode: "Client.TokenRefreshRequired",
faultstring: "Token has expired",
detail: {
refreshAuthTokenResult: {
authToken: newToken.token,
privateKey: newToken.key,
},
},
}
};
}
}
export function isExpiredTokenError(thing: any): thing is ExpiredTokenError {
return thing._tag == "ExpiredTokenError";
}
export type SmapiAuthTokens = {
issue: (serviceToken: string) => SmapiToken;
verify: (smapiToken: SmapiToken) => Either<ToSmapiFault, string>;
};
type TokenExpiredError = {
name: string,
message: string,
expiredAt: number
}
function isTokenExpiredError(thing: any): thing is TokenExpiredError {
return thing.name == 'TokenExpiredError';
}
export const smapiTokenAsString = (smapiToken: SmapiToken) => b64Encode(JSON.stringify({
token: smapiToken.token,
key: smapiToken.key
}));
export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString));
export const SMAPI_TOKEN_VERSION = "1";
export class JWTSmapiLoginTokens implements SmapiAuthTokens {
private readonly clock: Clock;
private readonly secret: string;
private readonly expiresIn: string;
private readonly version: string;
private readonly keyGenerator: () => string;
constructor(clock: Clock, secret: string, expiresIn: string, keyGenerator: () => string = uuid, version: string = SMAPI_TOKEN_VERSION) {
this.clock = clock;
this.secret = secret;
this.expiresIn = expiresIn;
this.version = version;
this.keyGenerator = keyGenerator;
}
issue = (serviceToken: string) => {
const key = this.keyGenerator();
return {
token: jwt.sign(
{ serviceToken, iat: this.clock.now().unix() },
this.secret + this.version + key,
{ expiresIn: this.expiresIn }
),
key,
};
};
verify = (smapiToken: SmapiToken): Either<ToSmapiFault, string> => {
try {
return right((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key) as any).serviceToken);
} catch (e) {
if(isTokenExpiredError(e)) {
const x = ((jwt.verify(smapiToken.token, this.secret + this.version + smapiToken.key, { ignoreExpiration: true })) as any).serviceToken;
return left(new ExpiredTokenError(x, e.expiredAt))
} else if(isError(e))
return left(new InvalidTokenError(e.message));
else
return left(new InvalidTokenError("Failed to verify token"))
}
};
}

View File

@@ -443,7 +443,7 @@ export class Subsonic implements MusicService {
generateToken = async (credentials: Credentials) => generateToken = async (credentials: Credentials) =>
this.getJSON(credentials, "/rest/ping.view") this.getJSON(credentials, "/rest/ping.view")
.then(() => ({ .then(() => ({
authToken: b64Encode(JSON.stringify(credentials)), serviceToken: b64Encode(JSON.stringify(credentials)),
userId: credentials.username, userId: credentials.username,
nickname: credentials.username, nickname: credentials.username,
})) }))

View File

@@ -1,263 +0,0 @@
import { v4 as uuid } from "uuid";
import dayjs from "dayjs";
import {
AccessTokenPerAuthToken,
EncryptedAccessTokens,
ExpiringAccessTokens,
InMemoryAccessTokens,
sha256
} from "../src/access_tokens";
import { Encryption } from "../src/encryption";
describe("ExpiringAccessTokens", () => {
let now = dayjs();
const accessTokens = new ExpiringAccessTokens({ now: () => now });
describe("tokens", () => {
it("they should be unique", () => {
const authToken = uuid();
expect(accessTokens.mint(authToken)).not.toEqual(
accessTokens.mint(authToken)
);
});
});
describe("tokens that dont exist", () => {
it("should return undefined", () => {
expect(accessTokens.authTokenFor("doesnt exist")).toBeUndefined();
});
});
describe("tokens that have not expired", () => {
it("should be able to return them", () => {
const authToken = uuid();
const accessToken = accessTokens.mint(authToken);
expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken);
});
it("should be able to have many per authToken", () => {
const authToken = uuid();
const accessToken1 = accessTokens.mint(authToken);
const accessToken2 = accessTokens.mint(authToken);
expect(accessTokens.authTokenFor(accessToken1)).toEqual(authToken);
expect(accessTokens.authTokenFor(accessToken2)).toEqual(authToken);
});
});
describe("tokens that have expired", () => {
describe("retrieving it", () => {
it("should return undefined", () => {
const authToken = uuid();
now = dayjs();
const accessToken = accessTokens.mint(authToken);
now = now.add(12, "hours").add(1, "second");
expect(accessTokens.authTokenFor(accessToken)).toBeUndefined();
});
});
describe("should be cleared out", () => {
const authToken1 = uuid();
const authToken2 = uuid();
now = dayjs();
const accessToken1_1 = accessTokens.mint(authToken1);
const accessToken2_1 = accessTokens.mint(authToken2);
expect(accessTokens.count()).toEqual(2);
expect(accessTokens.authTokenFor(accessToken1_1)).toEqual(authToken1);
expect(accessTokens.authTokenFor(accessToken2_1)).toEqual(authToken2);
now = now.add(12, "hours").add(1, "second");
const accessToken1_2 = accessTokens.mint(authToken1);
expect(accessTokens.count()).toEqual(1);
expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1);
now = now.add(6, "hours");
const accessToken2_2 = accessTokens.mint(authToken2);
expect(accessTokens.count()).toEqual(2);
expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1);
expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2);
now = now.add(6, "hours").add(1, "minute");
expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2);
expect(accessTokens.count()).toEqual(1);
now = now.add(6, "hours").add(1, "minute");
expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined();
expect(accessTokens.authTokenFor(accessToken2_2)).toBeUndefined();
expect(accessTokens.count()).toEqual(0);
});
});
});
describe("EncryptedAccessTokens", () => {
const encryption = {
encrypt: jest.fn(),
decrypt: jest.fn(),
};
const accessTokens = new EncryptedAccessTokens(
(encryption as unknown) as Encryption
);
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe("encrypt and decrypt", () => {
it("should be able to round trip the token", () => {
const authToken = `the token - ${uuid()}`;
const hash = "the encrypted token";
encryption.encrypt.mockReturnValue(hash);
encryption.decrypt.mockReturnValue(authToken);
const accessToken = accessTokens.mint(authToken);
expect(accessToken).not.toContain(authToken);
expect(accessToken).toEqual(hash);
expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken);
expect(encryption.encrypt).toHaveBeenCalledWith(authToken);
expect(encryption.decrypt).toHaveBeenCalledWith(hash);
});
});
describe("when the token is a valid Hash but doesnt decrypt", () => {
it("should return undefined", () => {
const hash = "valid hash";
encryption.decrypt.mockImplementation(() => {
throw "Boooooom decryption failed!!!";
});
expect(
accessTokens.authTokenFor(hash)
).toBeUndefined();
});
});
describe("when the token is not even a valid hash", () => {
it("should return undefined", () => {
encryption.decrypt.mockImplementation(() => {
throw "Boooooom decryption failed!!!";
});
expect(accessTokens.authTokenFor("some rubbish")).toBeUndefined();
});
});
});
describe("AccessTokenPerAuthToken", () => {
const accessTokens = new AccessTokenPerAuthToken();
it("should return the same access token for the same auth token", () => {
const authToken = "token1";
const accessToken1 = accessTokens.mint(authToken);
const accessToken2 = accessTokens.mint(authToken);
expect(accessToken1).not.toEqual(authToken);
expect(accessToken1).toEqual(accessToken2);
});
describe("when there is an auth token for the access token", () => {
it("should be able to retrieve it", () => {
const authToken = uuid();
const accessToken = accessTokens.mint(authToken);
expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken);
});
});
describe("when there is no auth token for the access token", () => {
it("should return undefined", () => {
expect(accessTokens.authTokenFor(uuid())).toBeUndefined();
});
});
});
describe('sha256 minter', () => {
it('should return the same value for the same salt and authToken', () => {
const authToken = uuid();
const token1 = sha256("salty")(authToken);
const token2 = sha256("salty")(authToken);
expect(token1).not.toEqual(authToken);
expect(token1).toEqual(token2);
});
it('should returrn different values for the same salt but different authTokens', () => {
const authToken1 = uuid();
const authToken2 = uuid();
const token1 = sha256("salty")(authToken1);
const token2= sha256("salty")(authToken2);
expect(token1).not.toEqual(token2);
});
it('should return different values for the same authToken but different salts', () => {
const authToken = uuid();
const token1 = sha256("salt1")(authToken);
const token2= sha256("salt2")(authToken);
expect(token1).not.toEqual(token2);
});
});
describe("InMemoryAccessTokens", () => {
const reverseAuthToken = (authToken: string) => authToken.split("").reverse().join("");
const accessTokens = new InMemoryAccessTokens(reverseAuthToken);
it("should return the same access token for the same auth token", () => {
const authToken = "token1";
const accessToken1 = accessTokens.mint(authToken);
const accessToken2 = accessTokens.mint(authToken);
expect(accessToken1).not.toEqual(authToken);
expect(accessToken1).toEqual(accessToken2);
});
describe("when there is an auth token for the access token", () => {
it("should be able to retrieve it", () => {
const authToken = uuid();
const accessToken = accessTokens.mint(authToken);
expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken);
});
});
describe("when there is no auth token for the access token", () => {
it("should return undefined", () => {
expect(accessTokens.authTokenFor(uuid())).toBeUndefined();
});
});
});

67
tests/api_tokens.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import { v4 as uuid } from "uuid";
import {
InMemoryAPITokens,
sha256
} from "../src/api_tokens";
describe('sha256 minter', () => {
it('should return the same value for the same salt and authToken', () => {
const authToken = uuid();
const token1 = sha256("salty")(authToken);
const token2 = sha256("salty")(authToken);
expect(token1).not.toEqual(authToken);
expect(token1).toEqual(token2);
});
it('should returrn different values for the same salt but different authTokens', () => {
const authToken1 = uuid();
const authToken2 = uuid();
const token1 = sha256("salty")(authToken1);
const token2= sha256("salty")(authToken2);
expect(token1).not.toEqual(token2);
});
it('should return different values for the same authToken but different salts', () => {
const authToken = uuid();
const token1 = sha256("salt1")(authToken);
const token2= sha256("salt2")(authToken);
expect(token1).not.toEqual(token2);
});
});
describe("InMemoryAPITokens", () => {
const reverseAuthToken = (authToken: string) => authToken.split("").reverse().join("");
const accessTokens = new InMemoryAPITokens(reverseAuthToken);
it("should return the same access token for the same auth token", () => {
const authToken = "token1";
const accessToken1 = accessTokens.mint(authToken);
const accessToken2 = accessTokens.mint(authToken);
expect(accessToken1).not.toEqual(authToken);
expect(accessToken1).toEqual(accessToken2);
});
describe("when there is an auth token for the access token", () => {
it("should be able to retrieve it", () => {
const authToken = uuid();
const accessToken = accessTokens.mint(authToken);
expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken);
});
});
describe("when there is no auth token for the access token", () => {
it("should return undefined", () => {
expect(accessTokens.authTokenFor(uuid())).toBeUndefined();
});
});
});

View File

@@ -90,10 +90,11 @@ export function getAppLinkMessage() {
}; };
} }
export function someCredentials(token: string): Credentials { export function someCredentials({ token, key } : { token: string, key: string }): Credentials {
return { return {
loginToken: { loginToken: {
token, token,
key,
householdId: "hh1", householdId: "hh1",
}, },
deviceId: "d1", deviceId: "d1",

View File

@@ -1,5 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import libxmljs from "libxmljs2"; import libxmljs from "libxmljs2";
import { FixedClock } from "../src/clock";
import { import {
contains, contains,
@@ -556,12 +557,11 @@ describe("festivals", () => {
backgroundColor: "black", backgroundColor: "black",
foregroundColor: "black", foregroundColor: "black",
}); });
let now = dayjs(); const clock = new FixedClock(dayjs());
const clock = { now: () => now };
describe("on a day that isn't festive", () => { describe("on a day that isn't festive", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2022/10/12"); clock.time = dayjs("2022/10/12");
}); });
it("should use the given colors", () => { it("should use the given colors", () => {
@@ -587,7 +587,7 @@ describe("festivals", () => {
describe("on christmas day", () => { describe("on christmas day", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2022/12/25"); clock.time = dayjs("2022/12/25");
}); });
it("should use the christmas theme colors", () => { it("should use the christmas theme colors", () => {
@@ -613,7 +613,7 @@ describe("festivals", () => {
describe("on halloween", () => { describe("on halloween", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2022/10/31"); clock.time = dayjs("2022/10/31");
}); });
it("should use the given colors", () => { it("should use the given colors", () => {
@@ -638,7 +638,7 @@ describe("festivals", () => {
describe("on may 4", () => { describe("on may 4", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2022/5/4"); clock.time = dayjs("2022/5/4");
}); });
it("should use the undefined colors, so no color", () => { it("should use the undefined colors, so no color", () => {
@@ -664,7 +664,7 @@ describe("festivals", () => {
describe("on cny", () => { describe("on cny", () => {
describe("2022", () => { describe("2022", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2022/02/01"); clock.time = dayjs("2022/02/01");
}); });
it("should use the cny theme", () => { it("should use the cny theme", () => {
@@ -689,7 +689,7 @@ describe("festivals", () => {
describe("2023", () => { describe("2023", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2023/01/22"); clock.time = dayjs("2023/01/22");
}); });
it("should use the cny theme", () => { it("should use the cny theme", () => {
@@ -714,7 +714,7 @@ describe("festivals", () => {
describe("2024", () => { describe("2024", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2024/02/10"); clock.time = dayjs("2024/02/10");
}); });
it("should use the cny theme", () => { it("should use the cny theme", () => {
@@ -740,7 +740,7 @@ describe("festivals", () => {
describe("on holi", () => { describe("on holi", () => {
beforeEach(() => { beforeEach(() => {
now = dayjs("2022/03/18"); clock.time = dayjs("2022/03/18");
}); });
it("should use the given colors", () => { it("should use the given colors", () => {

View File

@@ -33,7 +33,7 @@ describe("InMemoryMusicService", () => {
expect(token.userId).toEqual(credentials.username); expect(token.userId).toEqual(credentials.username);
expect(token.nickname).toEqual(credentials.username); expect(token.nickname).toEqual(credentials.username);
const musicLibrary = service.login(token.authToken); const musicLibrary = service.login(token.serviceToken);
expect(musicLibrary).toBeDefined(); expect(musicLibrary).toBeDefined();
}); });
@@ -47,7 +47,7 @@ describe("InMemoryMusicService", () => {
service.clear(); service.clear();
return expect(service.login(token.authToken)).rejects.toEqual( return expect(service.login(token.serviceToken)).rejects.toEqual(
"Invalid auth token" "Invalid auth token"
); );
}); });
@@ -63,7 +63,7 @@ describe("InMemoryMusicService", () => {
service.hasUser(user); service.hasUser(user);
const token = (await service.generateToken(user)) as AuthSuccess; const token = (await service.generateToken(user)) as AuthSuccess;
musicLibrary = (await service.login(token.authToken)) as MusicLibrary; musicLibrary = (await service.login(token.serviceToken)) as MusicLibrary;
}); });
describe("artists", () => { describe("artists", () => {

View File

@@ -41,7 +41,7 @@ export class InMemoryMusicService implements MusicService {
this.users[username] == password this.users[username] == password
) { ) {
return Promise.resolve({ return Promise.resolve({
authToken: b64Encode(JSON.stringify({ username, password })), serviceToken: b64Encode(JSON.stringify({ username, password })),
userId: username, userId: username,
nickname: username, nickname: username,
}); });
@@ -50,8 +50,8 @@ export class InMemoryMusicService implements MusicService {
} }
} }
login(token: string): Promise<MusicLibrary> { login(serviceToken: string): Promise<MusicLibrary> {
const credentials = JSON.parse(b64Decode(token)) as Credentials; const credentials = JSON.parse(b64Decode(serviceToken)) as Credentials;
if (this.users[credentials.username] != credentials.password) if (this.users[credentials.username] != credentials.password)
return Promise.reject("Invalid auth token"); return Promise.reject("Invalid auth token");

View File

@@ -18,7 +18,7 @@ describe("InMemoryLinkCodes", () => {
describe('when token is valid', () => { describe('when token is valid', () => {
it('should associate the token', () => { it('should associate the token', () => {
const linkCode = linkCodes.mint(); const linkCode = linkCodes.mint();
const association = { authToken: "token123", nickname: "bob", userId: "1" }; const association = { serviceToken: "token123", nickname: "bob", userId: "1" };
linkCodes.associate(linkCode, association); linkCodes.associate(linkCode, association);
@@ -29,7 +29,7 @@ describe("InMemoryLinkCodes", () => {
describe('when token is valid', () => { describe('when token is valid', () => {
it('should throw an error', () => { it('should throw an error', () => {
const invalidLinkCode = "invalidLinkCode"; const invalidLinkCode = "invalidLinkCode";
const association = { authToken: "token456", nickname: "bob", userId: "1" }; const association = { serviceToken: "token456", nickname: "bob", userId: "1" };
expect(() => linkCodes.associate(invalidLinkCode, association)).toThrow(`Invalid linkCode ${invalidLinkCode}`) expect(() => linkCodes.associate(invalidLinkCode, association)).toThrow(`Invalid linkCode ${invalidLinkCode}`)
}); });

View File

@@ -33,9 +33,10 @@ class LoggedInSonosDriver {
this.client = client; this.client = client;
this.token = token; this.token = token;
this.client.addSoapHeader({ this.client.addSoapHeader({
credentials: someCredentials( credentials: someCredentials({
this.token.getDeviceAuthTokenResult.authToken token: this.token.getDeviceAuthTokenResult.authToken,
), key: this.token.getDeviceAuthTokenResult.privateKey
}),
}); });
} }
@@ -272,7 +273,7 @@ describe("scenarios", () => {
bonobUrl, bonobUrl,
musicService, musicService,
{ {
linkCodes: () => linkCodes linkCodes: () => linkCodes,
} }
); );

View File

@@ -3,6 +3,7 @@ import dayjs from "dayjs";
import request from "supertest"; import request from "supertest";
import Image from "image-js"; import Image from "image-js";
import fs from "fs"; import fs from "fs";
import { either as E } from "fp-ts";
import path from "path"; import path from "path";
import { MusicService } from "../src/music_service"; import { MusicService } from "../src/music_service";
@@ -16,7 +17,7 @@ import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
import { aDevice, aService } from "./builders"; import { aDevice, aService } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import { AccessTokens, ExpiringAccessTokens } from "../src/access_tokens"; import { APITokens, InMemoryAPITokens } from "../src/api_tokens";
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
import { Response } from "express"; import { Response } from "express";
import { Transform } from "stream"; import { Transform } from "stream";
@@ -25,6 +26,7 @@ import i8n, { randomLang } from "../src/i8n";
import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi"; import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi";
import { Clock, SystemClock } from "../src/clock"; import { Clock, SystemClock } from "../src/clock";
import { formatForURL } from "../src/burn"; import { formatForURL } from "../src/burn";
import { ExpiredTokenError, SmapiAuthTokens } from "../src/smapi_auth";
describe("rangeFilterFor", () => { describe("rangeFilterFor", () => {
describe("invalid range header string", () => { describe("invalid range header string", () => {
@@ -579,7 +581,7 @@ describe("server", () => {
associate: jest.fn(), associate: jest.fn(),
associationFor: jest.fn(), associationFor: jest.fn(),
}; };
const accessTokens = { const apiTokens = {
mint: jest.fn(), mint: jest.fn(),
authTokenFor: jest.fn(), authTokenFor: jest.fn(),
}; };
@@ -594,7 +596,7 @@ describe("server", () => {
musicService as unknown as MusicService, musicService as unknown as MusicService,
{ {
linkCodes: () => linkCodes as unknown as LinkCodes, linkCodes: () => linkCodes as unknown as LinkCodes,
accessTokens: () => accessTokens as unknown as AccessTokens, apiTokens: () => apiTokens as unknown as APITokens,
clock, clock,
} }
); );
@@ -628,14 +630,14 @@ describe("server", () => {
const username = "jane"; const username = "jane";
const password = "password100"; const password = "password100";
const linkCode = `linkCode-${uuid()}`; const linkCode = `linkCode-${uuid()}`;
const authToken = { const authSuccess = {
authToken: `authtoken-${uuid()}`, serviceToken: `serviceToken-${uuid()}`,
userId: `${username}-uid`, userId: `${username}-uid`,
nickname: `${username}-nickname`, nickname: `${username}-nickname`,
}; };
linkCodes.has.mockReturnValue(true); linkCodes.has.mockReturnValue(true);
musicService.generateToken.mockResolvedValue(authToken); musicService.generateToken.mockResolvedValue(authSuccess);
linkCodes.associate.mockReturnValue(true); linkCodes.associate.mockReturnValue(true);
const res = await request(server) const res = await request(server)
@@ -654,7 +656,7 @@ describe("server", () => {
expect(linkCodes.has).toHaveBeenCalledWith(linkCode); expect(linkCodes.has).toHaveBeenCalledWith(linkCode);
expect(linkCodes.associate).toHaveBeenCalledWith( expect(linkCodes.associate).toHaveBeenCalledWith(
linkCode, linkCode,
authToken authSuccess
); );
}); });
}); });
@@ -731,8 +733,10 @@ describe("server", () => {
scrobble: jest.fn(), scrobble: jest.fn(),
nowPlaying: jest.fn(), nowPlaying: jest.fn(),
}; };
let now = dayjs(); const smapiAuthTokens = {
const accessTokens = new ExpiringAccessTokens({ now: () => now }); verify: jest.fn(),
}
const apiTokens = new InMemoryAPITokens();
const server = makeServer( const server = makeServer(
jest.fn() as unknown as Sonos, jest.fn() as unknown as Sonos,
@@ -741,17 +745,14 @@ describe("server", () => {
musicService as unknown as MusicService, musicService as unknown as MusicService,
{ {
linkCodes: () => new InMemoryLinkCodes(), linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => accessTokens, apiTokens: () => apiTokens,
smapiAuthTokens: smapiAuthTokens as unknown as SmapiAuthTokens
} }
); );
const authToken = uuid(); const serviceToken = uuid();
const trackId = uuid(); const trackId = uuid();
let accessToken: string; const smapiAuthToken = `smapiAuthToken-${uuid()}`;
beforeEach(() => {
accessToken = accessTokens.mint(authToken);
});
const streamContent = (content: string) => ({ const streamContent = (content: string) => ({
pipe: (_: Transform) => { pipe: (_: Transform) => {
@@ -764,7 +765,7 @@ describe("server", () => {
}); });
describe("HEAD requests", () => { describe("HEAD requests", () => {
describe("when there is no access-token", () => { describe("when there is no Bearer token", () => {
it("should return a 401", async () => { it("should return a 401", async () => {
const res = await request(server).head( const res = await request(server).head(
bonobUrl.append({ pathname: `/stream/track/${trackId}` }).path() bonobUrl.append({ pathname: `/stream/track/${trackId}` }).path()
@@ -774,24 +775,27 @@ describe("server", () => {
}); });
}); });
describe("when the access-token has expired", () => { describe("when the Bearer token has expired", () => {
it("should return a 401", async () => { it("should return a 401", async () => {
now = now.add(1, "day"); smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(smapiAuthToken, 0)))
const res = await request(server).head( const res = await request(server).head(
bonobUrl bonobUrl
.append({ .append({
pathname: `/stream/track/${trackId}`, pathname: `/stream/track/${trackId}`
searchParams: { bat: accessToken },
}) })
.path() .path(),
); ).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
}); });
}); });
describe("when the access-token is valid", () => { describe("when the Bearer token is valid", () => {
beforeEach(() => {
smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken));
});
describe("and the track exists", () => { describe("and the track exists", () => {
it("should return a 200", async () => { it("should return a 200", async () => {
const trackStream = { const trackStream = {
@@ -810,9 +814,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.head( .head(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .append({ pathname: `/stream/track/${trackId}`})
.path() .path()
); ).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(trackStream.status); expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual( expect(res.headers["content-type"]).toEqual(
@@ -836,9 +840,10 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.head(bonobUrl .head(bonobUrl
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .append({ pathname: `/stream/track/${trackId}` })
.path() .path()
); )
.set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(404); expect(res.status).toEqual(404);
expect(res.body).toEqual({}); expect(res.body).toEqual({});
@@ -848,7 +853,7 @@ describe("server", () => {
}); });
describe("GET requests", () => { describe("GET requests", () => {
describe("when there is no access-token", () => { describe("when there is no Bearer token", () => {
it("should return a 401", async () => { it("should return a 401", async () => {
const res = await request(server).get( const res = await request(server).get(
bonobUrl.append({ pathname: `/stream/track/${trackId}` }).path() bonobUrl.append({ pathname: `/stream/track/${trackId}` }).path()
@@ -858,296 +863,305 @@ describe("server", () => {
}); });
}); });
describe("when the access-token has expired", () => { describe("when the Bearer token has expired", () => {
it("should return a 401", async () => { it("should return a 401", async () => {
now = now.add(1, "day"); smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(smapiAuthToken, 0)))
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .append({ pathname: `/stream/track/${trackId}` })
.path() .path()
); ).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
}); });
}); });
describe("when the track doesnt exist", () => { describe("when the Bearer token is valid", () => {
it("should return a 404", async () => { beforeEach(() => {
const stream = { smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken));
status: 404,
headers: {},
stream: streamContent(""),
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
const res = await request(server)
.get(
bonobUrl
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path()
);
expect(res.status).toEqual(404);
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
}); });
});
describe("when sonos does not ask for a range", () => {
describe("when the music service does not return a content-range, content-length or accept-ranges", () => {
it("should return a 200 with the data, without adding the undefined headers", async () => {
const content = "some-track";
describe("when the track doesnt exist", () => {
it("should return a 404", async () => {
const stream = { const stream = {
status: 200, status: 404,
headers: { headers: {},
// audio/x-flac should be mapped to audio/flac stream: streamContent(""),
"content-type": "audio/x-flac; charset=utf-8",
},
stream: streamContent(content),
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .append({ pathname: `/stream/track/${trackId}` })
.path() .path()
); ).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(404);
expect(res.headers["content-type"]).toEqual(
"audio/flac; charset=utf-8" expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
);
expect(res.header["accept-ranges"]).toBeUndefined();
expect(res.headers["content-length"]).toEqual(
`${content.length}`
);
expect(Object.keys(res.headers)).not.toContain("content-range");
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
}); });
}); });
describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => { describe("when sonos does not ask for a range", () => {
it("should return a 200 with the data, without adding the undefined headers", async () => { describe("when the music service does not return a content-range, content-length or accept-ranges", () => {
const stream = { it("should return a 200 with the data, without adding the undefined headers", async () => {
status: 200, const content = "some-track";
headers: {
"content-type": "audio/mp3", const stream = {
"content-length": undefined, status: 200,
"accept-ranges": undefined, headers: {
"content-range": undefined, // audio/x-flac should be mapped to audio/flac
}, "content-type": "audio/x-flac; charset=utf-8",
stream: streamContent(""), },
}; stream: streamContent(content),
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.nowPlaying.mockResolvedValue(true); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server)
.get( const res = await request(server)
bonobUrl .get(
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) bonobUrl
.path() .append({ pathname: `/stream/track/${trackId}` })
.path()
).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual(
"audio/flac; charset=utf-8"
); );
expect(res.header["accept-ranges"]).toBeUndefined();
expect(res.status).toEqual(stream.status); expect(res.headers["content-length"]).toEqual(
expect(res.headers["content-type"]).toEqual( `${content.length}`
"audio/mp3; charset=utf-8"
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
);
expect(Object.keys(res.headers)).not.toContain("content-range");
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
});
});
describe("when the music service returns a 200", () => {
it("should return a 200 with the data", async () => {
const stream = {
status: 200,
headers: {
"content-type": "audio/mp3",
"content-length": "222",
"accept-ranges": "bytes",
},
stream: streamContent(""),
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server)
.get(
bonobUrl
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path()
); );
expect(Object.keys(res.headers)).not.toContain("content-range");
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual( expect(musicService.login).toHaveBeenCalledWith(serviceToken);
`${stream.headers["content-type"]}; charset=utf-8` expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
expect(res.header["accept-ranges"]).toEqual( });
stream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toBeUndefined();
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
}); });
});
describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => {
describe("when the music service returns a 206", () => { it("should return a 200 with the data, without adding the undefined headers", async () => {
it("should return a 206 with the data", async () => { const stream = {
const stream = { status: 200,
status: 206, headers: {
headers: { "content-type": "audio/mp3",
"content-type": "audio/ogg", "content-length": undefined,
"content-length": "333", "accept-ranges": undefined,
"accept-ranges": "bytez", "content-range": undefined,
"content-range": "100-200", },
}, stream: streamContent(""),
stream: streamContent(""), };
};
musicService.login.mockResolvedValue(musicLibrary);
musicService.login.mockResolvedValue(musicLibrary); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.nowPlaying.mockResolvedValue(true);
musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server)
const res = await request(server) .get(
.get( bonobUrl
bonobUrl .append({ pathname: `/stream/track/${trackId}` })
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path()
.path() ).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual(
"audio/mp3; charset=utf-8"
); );
expect(res.header["accept-ranges"]).toEqual(
expect(res.status).toEqual(stream.status); stream.headers["accept-ranges"]
expect(res.header["content-type"]).toEqual( );
`${stream.headers["content-type"]}; charset=utf-8` expect(Object.keys(res.headers)).not.toContain("content-range");
);
expect(res.header["accept-ranges"]).toEqual( expect(musicService.login).toHaveBeenCalledWith(serviceToken);
stream.headers["accept-ranges"] expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
expect(res.header["content-range"]).toEqual( });
stream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
}); });
});
}); describe("when the music service returns a 200", () => {
it("should return a 200 with the data", async () => {
describe("when sonos does ask for a range", () => { const stream = {
describe("when the music service returns a 200", () => { status: 200,
it("should return a 200 with the data", async () => { headers: {
const stream = { "content-type": "audio/mp3",
status: 200, "content-length": "222",
headers: { "accept-ranges": "bytes",
"content-type": "audio/mp3", },
"content-length": "222", stream: streamContent(""),
"accept-ranges": "none", };
},
stream: streamContent(""), musicService.login.mockResolvedValue(musicLibrary);
}; musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true);
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); const res = await request(server)
musicLibrary.nowPlaying.mockResolvedValue(true); .get(
bonobUrl
const requestedRange = "40-"; .append({ pathname: `/stream/track/${trackId}` })
.path()
const res = await request(server) ).set('Authorization', `Bearer ${smapiAuthToken}`);
.get(
bonobUrl expect(res.status).toEqual(stream.status);
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) expect(res.header["content-type"]).toEqual(
.path() `${stream.headers["content-type"]}; charset=utf-8`
) );
.set("Range", requestedRange); expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
expect(res.status).toEqual(stream.status); );
expect(res.header["content-type"]).toEqual( expect(res.header["content-range"]).toBeUndefined();
`${stream.headers["content-type"]}; charset=utf-8`
); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
expect(res.header["accept-ranges"]).toEqual( expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
stream.headers["accept-ranges"] expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
); });
expect(res.header["content-range"]).toBeUndefined(); });
expect(musicService.login).toHaveBeenCalledWith(authToken); describe("when the music service returns a 206", () => {
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); it("should return a 206 with the data", async () => {
expect(musicLibrary.stream).toHaveBeenCalledWith({ const stream = {
trackId, status: 206,
range: requestedRange, headers: {
"content-type": "audio/ogg",
"content-length": "333",
"accept-ranges": "bytez",
"content-range": "100-200",
},
stream: streamContent(""),
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server)
.get(
bonobUrl
.append({ pathname: `/stream/track/${trackId}` })
.path()
).set('Authorization', `Bearer ${smapiAuthToken}`);
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual(
`${stream.headers["content-type"]}; charset=utf-8`
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toEqual(
stream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId });
}); });
}); });
}); });
describe("when the music service returns a 206", () => { describe("when sonos does ask for a range", () => {
it("should return a 206 with the data", async () => { describe("when the music service returns a 200", () => {
const stream = { it("should return a 200 with the data", async () => {
status: 206, const stream = {
headers: { status: 200,
"content-type": "audio/ogg", headers: {
"content-length": "333", "content-type": "audio/mp3",
"accept-ranges": "bytez", "content-length": "222",
"content-range": "100-200", "accept-ranges": "none",
}, },
stream: streamContent(""), stream: streamContent(""),
}; };
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true); musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server) const requestedRange = "40-";
.get(
bonobUrl const res = await request(server)
.append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .get(
.path() bonobUrl
) .append({ pathname: `/stream/track/${trackId}` })
.set("Range", "4000-5000"); .path()
)
expect(res.status).toEqual(stream.status); .set('Authorization', `Bearer ${smapiAuthToken}`)
expect(res.header["content-type"]).toEqual( .set("Range", requestedRange);
`${stream.headers["content-type"]}; charset=utf-8`
); expect(res.status).toEqual(stream.status);
expect(res.header["accept-ranges"]).toEqual( expect(res.header["content-type"]).toEqual(
stream.headers["accept-ranges"] `${stream.headers["content-type"]}; charset=utf-8`
); );
expect(res.header["content-range"]).toEqual( expect(res.header["accept-ranges"]).toEqual(
stream.headers["content-range"] stream.headers["accept-ranges"]
); );
expect(res.header["content-range"]).toBeUndefined();
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
expect(musicLibrary.stream).toHaveBeenCalledWith({ expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
trackId, expect(musicLibrary.stream).toHaveBeenCalledWith({
range: "4000-5000", trackId,
range: requestedRange,
});
});
});
describe("when the music service returns a 206", () => {
it("should return a 206 with the data", async () => {
const stream = {
status: 206,
headers: {
"content-type": "audio/ogg",
"content-length": "333",
"accept-ranges": "bytez",
"content-range": "100-200",
},
stream: streamContent(""),
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.nowPlaying.mockResolvedValue(true);
const res = await request(server)
.get(
bonobUrl
.append({ pathname: `/stream/track/${trackId}` })
.path()
)
.set('Authorization', `Bearer ${smapiAuthToken}`)
.set("Range", "4000-5000");
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual(
`${stream.headers["content-type"]}; charset=utf-8`
);
expect(res.header["accept-ranges"]).toEqual(
stream.headers["accept-ranges"]
);
expect(res.header["content-range"]).toEqual(
stream.headers["content-range"]
);
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
expect(musicLibrary.stream).toHaveBeenCalledWith({
trackId,
range: "4000-5000",
});
}); });
}); });
}); });
}); });
}); });
}); });
@@ -1158,8 +1172,7 @@ describe("server", () => {
const musicLibrary = { const musicLibrary = {
coverArt: jest.fn(), coverArt: jest.fn(),
}; };
let now = dayjs(); const apiTokens = new InMemoryAPITokens();
const accessTokens = new ExpiringAccessTokens({ now: () => now });
const server = makeServer( const server = makeServer(
jest.fn() as unknown as Sonos, jest.fn() as unknown as Sonos,
@@ -1168,13 +1181,13 @@ describe("server", () => {
musicService as unknown as MusicService, musicService as unknown as MusicService,
{ {
linkCodes: () => new InMemoryLinkCodes(), linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => accessTokens, apiTokens: () => apiTokens,
} }
); );
const authToken = uuid(); const serviceToken = uuid();
const albumId = uuid(); const albumId = uuid();
let accessToken: string; let apiToken: string;
const coverArtResponse = ( const coverArtResponse = (
opt: Partial<{ status: number; contentType: string; data: Buffer }> opt: Partial<{ status: number; contentType: string; data: Buffer }>
@@ -1186,7 +1199,7 @@ describe("server", () => {
}); });
beforeEach(() => { beforeEach(() => {
accessToken = accessTokens.mint(authToken); apiToken = apiTokens.mint(serviceToken);
}); });
describe("when there is no access-token", () => { describe("when there is no access-token", () => {
@@ -1197,18 +1210,6 @@ describe("server", () => {
}); });
}); });
describe("when the access-token has expired", () => {
it("should return a 401", async () => {
now = now.add(1, "day");
const res = await request(server).get(
`/art/${encodeURIComponent(formatForURL({ system: "subsonic", resource: "art:whatever" }))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
);
expect(res.status).toEqual(401);
});
});
describe("when there is a valid access token", () => { describe("when there is a valid access token", () => {
describe("art", () => { describe("art", () => {
["0", "-1", "foo"].forEach((size) => { ["0", "-1", "foo"].forEach((size) => {
@@ -1219,9 +1220,9 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
const res = await request(server) const res = await request(server)
.get( .get(
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(400); expect(res.status).toEqual(400);
}); });
@@ -1241,16 +1242,16 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(coverArt.status); expect(res.status).toEqual(coverArt.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
coverArt.contentType coverArt.contentType
); );
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
coverArtURN, coverArtURN,
180 180
@@ -1267,9 +1268,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(404); expect(res.status).toEqual(404);
}); });
@@ -1310,14 +1311,14 @@ describe("server", () => {
.get( .get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&" "&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png"); expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((it) => { urns.forEach((it) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 200); expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 200);
}); });
@@ -1348,9 +1349,9 @@ describe("server", () => {
.get( .get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&" "&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
@@ -1373,9 +1374,9 @@ describe("server", () => {
.get( .get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&" "&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(404); expect(res.status).toEqual(404);
}); });
@@ -1409,14 +1410,14 @@ describe("server", () => {
.get( .get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&" "&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png"); expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((it) => { urns.forEach((it) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180); expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180);
}); });
@@ -1465,14 +1466,14 @@ describe("server", () => {
.get( .get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&" "&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png"); expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((urn) => { urns.forEach((urn) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(urn, 180); expect(musicLibrary.coverArt).toHaveBeenCalledWith(urn, 180);
}); });
@@ -1513,14 +1514,14 @@ describe("server", () => {
.get( .get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join( `/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&" "&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png"); expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((it) => { urns.forEach((it) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180); expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180);
}); });
@@ -1540,9 +1541,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(404); expect(res.status).toEqual(404);
}); });
@@ -1557,9 +1558,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` `/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(500); expect(res.status).toEqual(500);
}); });
@@ -1583,7 +1584,7 @@ describe("server", () => {
jest.fn() as unknown as MusicService, jest.fn() as unknown as MusicService,
{ {
linkCodes: () => new InMemoryLinkCodes(), linkCodes: () => new InMemoryLinkCodes(),
accessTokens: () => jest.fn() as unknown as AccessTokens, apiTokens: () => jest.fn() as unknown as APITokens,
clock, clock,
iconColors, iconColors,
} }

File diff suppressed because it is too large Load Diff

188
tests/smapi_auth.test.ts Normal file
View File

@@ -0,0 +1,188 @@
import { v4 as uuid } from "uuid";
import jwt from "jsonwebtoken";
import {
ExpiredTokenError,
InvalidTokenError,
isSmapiRefreshTokenResultFault,
JWTSmapiLoginTokens,
smapiTokenAsString,
smapiTokenFromString,
SMAPI_TOKEN_VERSION,
} from "../src/smapi_auth";
import { either as E } from "fp-ts";
import { FixedClock } from "../src/clock";
import dayjs from "dayjs";
import { b64Encode } from "../src/b64";
describe("smapiTokenAsString", () => {
it("can round trip token to and from string", () => {
const smapiToken = { token: uuid(), key: uuid(), someOtherStuff: 'this needs to be explicitly ignored' };
const asString = smapiTokenAsString(smapiToken)
expect(asString).toEqual(b64Encode(JSON.stringify({
token: smapiToken.token,
key: smapiToken.key,
})));
expect(smapiTokenFromString(asString)).toEqual({
token: smapiToken.token,
key: smapiToken.key
});
});
});
describe("isSmapiRefreshTokenResultFault", () => {
it("should return true for a refreshAuthTokenResult fault", () => {
const faultWithRefreshAuthToken = {
Fault: {
faultcode: "",
faultstring: "",
detail: {
refreshAuthTokenResult: {
authToken: "x",
privateKey: "x",
},
},
},
};
expect(isSmapiRefreshTokenResultFault(faultWithRefreshAuthToken)).toEqual(
true
);
});
it("should return false when is not a refreshAuthTokenResult", () => {
expect(isSmapiRefreshTokenResultFault({ Fault: { faultcode: "", faultstring:" " }})).toEqual(
false
);
});
});
describe("auth", () => {
describe("JWTSmapiLoginTokens", () => {
const clock = new FixedClock(dayjs());
const expiresIn = "1h";
const secret = `secret-${uuid()}`;
const smapiLoginTokens = new JWTSmapiLoginTokens(clock, secret, expiresIn);
describe("issuing a new token", () => {
it("should issue a token that can then be verified", () => {
const serviceToken = uuid();
const smapiToken = smapiLoginTokens.issue(serviceToken);
expect(smapiToken.token).toEqual(
jwt.sign(
{
serviceToken,
iat: Math.floor(clock.now().toDate().getDate() / 1000),
},
secret + SMAPI_TOKEN_VERSION + smapiToken.key,
{ expiresIn }
)
);
expect(smapiToken.token).not.toContain(serviceToken);
expect(smapiToken.token).not.toContain(secret);
expect(smapiToken.token).not.toContain(":");
const roundTripped = smapiLoginTokens.verify(smapiToken);
expect(roundTripped).toEqual(E.right(serviceToken));
});
});
describe("when verifying the token fails", () => {
describe("due to the version changing", () => {
it("should return an error", () => {
const authToken = uuid();
const v1SmapiTokens = new JWTSmapiLoginTokens(
clock,
secret,
expiresIn,
() => uuid(),
"1"
);
const v2SmapiTokens = new JWTSmapiLoginTokens(
clock,
secret,
expiresIn,
() => uuid(),
"2"
);
const v1Token = v1SmapiTokens.issue(authToken);
expect(v1SmapiTokens.verify(v1Token)).toEqual(E.right(authToken));
const result = v2SmapiTokens.verify(v1Token);
expect(result).toEqual(
E.left(new InvalidTokenError("invalid signature"))
);
});
});
describe("due to secret changing", () => {
it("should return an error", () => {
const authToken = uuid();
const smapiToken = new JWTSmapiLoginTokens(
clock,
"A different secret",
expiresIn
).issue(authToken);
const result = smapiLoginTokens.verify(smapiToken);
expect(result).toEqual(
E.left(new InvalidTokenError("invalid signature"))
);
});
});
describe("due to key changing", () => {
it("should return an error", () => {
const authToken = uuid();
const smapiToken = smapiLoginTokens.issue(authToken);
const result = smapiLoginTokens.verify({
...smapiToken,
key: "some other key",
});
expect(result).toEqual(
E.left(new InvalidTokenError("invalid signature"))
);
});
});
});
describe("when the token has expired", () => {
it("should return an ExpiredTokenError, with the authToken", () => {
const authToken = uuid();
const now = dayjs();
const tokenIssuedAt = now.subtract(31, "seconds");
const tokensWith30SecondExpiry = new JWTSmapiLoginTokens(
clock,
uuid(),
"30s"
);
clock.time = tokenIssuedAt;
const expiredToken = tokensWith30SecondExpiry.issue(authToken);
clock.time = now;
const result = tokensWith30SecondExpiry.verify(expiredToken);
expect(result).toEqual(
E.left(
new ExpiredTokenError(
authToken,
tokenIssuedAt.add(30, "seconds").unix()
)
)
);
});
});
});
});

View File

@@ -725,7 +725,7 @@ describe("Subsonic", () => {
password, password,
})) as AuthSuccess; })) as AuthSuccess;
expect(token.authToken).toBeDefined(); expect(token.serviceToken).toBeDefined();
expect(token.nickname).toEqual(username); expect(token.nickname).toEqual(username);
expect(token.userId).toEqual(username); expect(token.userId).toEqual(username);
@@ -763,7 +763,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.genres()); .then((it) => it.genres());
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -793,7 +793,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.genres()); .then((it) => it.genres());
expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]);
@@ -826,7 +826,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.genres()); .then((it) => it.genres());
expect(result).toEqual([ expect(result).toEqual([
@@ -884,7 +884,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -946,7 +946,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1002,7 +1002,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1056,7 +1056,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1113,7 +1113,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1167,7 +1167,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1222,7 +1222,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1278,7 +1278,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1332,7 +1332,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1384,7 +1384,7 @@ describe("Subsonic", () => {
const result: Artist = await navidrome const result: Artist = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artist(artist.id!)); .then((it) => it.artist(artist.id!));
expect(result).toEqual({ expect(result).toEqual({
@@ -1449,7 +1449,7 @@ describe("Subsonic", () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artists({ _index: 0, _count: 100 })); .then((it) => it.artists({ _index: 0, _count: 100 }));
expect(artists).toEqual({ expect(artists).toEqual({
@@ -1478,7 +1478,7 @@ describe("Subsonic", () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artists({ _index: 0, _count: 100 })); .then((it) => it.artists({ _index: 0, _count: 100 }));
expect(artists).toEqual({ expect(artists).toEqual({
@@ -1519,7 +1519,7 @@ describe("Subsonic", () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artists({ _index: 0, _count: 100 })); .then((it) => it.artists({ _index: 0, _count: 100 }));
const expectedResults = [{ const expectedResults = [{
@@ -1561,7 +1561,7 @@ describe("Subsonic", () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artists({ _index: 0, _count: 100 })); .then((it) => it.artists({ _index: 0, _count: 100 }));
const expectedResults = [artist1, artist2, artist3, artist4].map( const expectedResults = [artist1, artist2, artist3, artist4].map(
@@ -1597,7 +1597,7 @@ describe("Subsonic", () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.artists({ _index: 1, _count: 2 })); .then((it) => it.artists({ _index: 1, _count: 2 }));
const expectedResults = [artist2, artist3].map((it) => ({ const expectedResults = [artist2, artist3].map((it) => ({
@@ -1659,7 +1659,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -1714,7 +1714,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -1768,7 +1768,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -1813,7 +1813,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -1858,7 +1858,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -1912,7 +1912,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -1965,7 +1965,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2033,7 +2033,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2087,7 +2087,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2163,7 +2163,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2223,7 +2223,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2282,7 +2282,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2351,7 +2351,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2418,7 +2418,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2483,7 +2483,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
@@ -2541,7 +2541,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.album(album.id)); .then((it) => it.album(album.id));
expect(result).toEqual(album); expect(result).toEqual(album);
@@ -2622,7 +2622,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.tracks(album.id)); .then((it) => it.tracks(album.id));
expect(result).toEqual([track1, track2, track3, track4]); expect(result).toEqual([track1, track2, track3, track4]);
@@ -2672,7 +2672,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.tracks(album.id)); .then((it) => it.tracks(album.id));
expect(result).toEqual([track]); expect(result).toEqual([track]);
@@ -2710,7 +2710,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.tracks(album.id)); .then((it) => it.tracks(album.id));
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -2761,7 +2761,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.track(track.id)); .then((it) => it.track(track.id));
expect(result).toEqual({ expect(result).toEqual({
@@ -2811,7 +2811,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.track(track.id)); .then((it) => it.track(track.id));
expect(result).toEqual({ expect(result).toEqual({
@@ -2886,7 +2886,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.stream({ trackId, range: undefined })); .then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({ expect(result.headers).toEqual({
@@ -2928,7 +2928,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.stream({ trackId, range: undefined })); .then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({ expect(result.headers).toEqual({
@@ -2972,7 +2972,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.stream({ trackId, range: undefined })); .then((it) => it.stream({ trackId, range: undefined }));
expect(result.headers).toEqual({ expect(result.headers).toEqual({
@@ -3021,7 +3021,7 @@ describe("Subsonic", () => {
const musicLibrary = await navidrome const musicLibrary = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)); .then((it) => navidrome.login(it.serviceToken));
return expect( return expect(
musicLibrary.stream({ trackId, range: undefined }) musicLibrary.stream({ trackId, range: undefined })
@@ -3046,7 +3046,7 @@ describe("Subsonic", () => {
const musicLibrary = await navidrome const musicLibrary = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)); .then((it) => navidrome.login(it.serviceToken));
return expect( return expect(
musicLibrary.stream({ trackId, range: undefined }) musicLibrary.stream({ trackId, range: undefined })
@@ -3087,7 +3087,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.stream({ trackId, range })); .then((it) => it.stream({ trackId, range }));
expect(result.headers).toEqual({ expect(result.headers).toEqual({
@@ -3140,7 +3140,7 @@ describe("Subsonic", () => {
await navidrome await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.stream({ trackId, range: undefined })); .then((it) => it.stream({ trackId, range: undefined }));
expect(streamClientApplication).toHaveBeenCalledWith(track); expect(streamClientApplication).toHaveBeenCalledWith(track);
@@ -3185,7 +3185,7 @@ describe("Subsonic", () => {
await navidrome await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.stream({ trackId, range })); .then((it) => it.stream({ trackId, range }));
expect(streamClientApplication).toHaveBeenCalledWith(track); expect(streamClientApplication).toHaveBeenCalledWith(track);
@@ -3227,7 +3227,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(coverArtURN)); .then((it) => it.coverArt(coverArtURN));
expect(result).toEqual({ expect(result).toEqual({
@@ -3266,7 +3266,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(coverArtURN, size)); .then((it) => it.coverArt(coverArtURN, size));
expect(result).toEqual({ expect(result).toEqual({
@@ -3297,7 +3297,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt({ system: "external", resource: "http://localhost:404" }, size)); .then((it) => it.coverArt({ system: "external", resource: "http://localhost:404" }, size));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -3316,7 +3316,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(covertArtURN, 190)); .then((it) => it.coverArt(covertArtURN, 190));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -3343,7 +3343,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(covertArtURN)); .then((it) => it.coverArt(covertArtURN));
expect(result).toEqual({ expect(result).toEqual({
@@ -3376,7 +3376,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(covertArtURN)); .then((it) => it.coverArt(covertArtURN));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -3406,7 +3406,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(covertArtURN, size)); .then((it) => it.coverArt(covertArtURN, size));
expect(result).toEqual({ expect(result).toEqual({
@@ -3440,7 +3440,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.coverArt(covertArtURN, size)); .then((it) => it.coverArt(covertArtURN, size));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -3457,7 +3457,7 @@ describe("Subsonic", () => {
navidrome navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.rate(trackId, rating)); .then((it) => it.rate(trackId, rating));
const artist = anArtist(); const artist = anArtist();
@@ -3705,7 +3705,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.scrobble(id)); .then((it) => it.scrobble(id));
expect(result).toEqual(true); expect(result).toEqual(true);
@@ -3737,7 +3737,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.scrobble(id)); .then((it) => it.scrobble(id));
expect(result).toEqual(false); expect(result).toEqual(false);
@@ -3766,7 +3766,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.nowPlaying(id)); .then((it) => it.nowPlaying(id));
expect(result).toEqual(true); expect(result).toEqual(true);
@@ -3798,7 +3798,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.nowPlaying(id)); .then((it) => it.nowPlaying(id));
expect(result).toEqual(false); expect(result).toEqual(false);
@@ -3829,7 +3829,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchArtists("foo")); .then((it) => it.searchArtists("foo"));
expect(result).toEqual([artistToArtistSummary(artist1)]); expect(result).toEqual([artistToArtistSummary(artist1)]);
@@ -3863,7 +3863,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchArtists("foo")); .then((it) => it.searchArtists("foo"));
expect(result).toEqual([ expect(result).toEqual([
@@ -3895,7 +3895,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchArtists("foo")); .then((it) => it.searchArtists("foo"));
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -3934,7 +3934,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchAlbums("foo")); .then((it) => it.searchAlbums("foo"));
expect(result).toEqual([albumToAlbumSummary(album)]); expect(result).toEqual([albumToAlbumSummary(album)]);
@@ -3984,7 +3984,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchAlbums("moo")); .then((it) => it.searchAlbums("moo"));
expect(result).toEqual([ expect(result).toEqual([
@@ -4016,7 +4016,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchAlbums("foo")); .then((it) => it.searchAlbums("foo"));
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -4065,7 +4065,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchTracks("foo")); .then((it) => it.searchTracks("foo"));
expect(result).toEqual([track]); expect(result).toEqual([track]);
@@ -4140,7 +4140,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchTracks("moo")); .then((it) => it.searchTracks("moo"));
expect(result).toEqual([track1, track2]); expect(result).toEqual([track1, track2]);
@@ -4169,7 +4169,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.searchTracks("foo")); .then((it) => it.searchTracks("foo"));
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -4203,7 +4203,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.playlists()); .then((it) => it.playlists());
expect(result).toEqual([playlist]); expect(result).toEqual([playlist]);
@@ -4231,7 +4231,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.playlists()); .then((it) => it.playlists());
expect(result).toEqual(playlists); expect(result).toEqual(playlists);
@@ -4254,7 +4254,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.playlists()); .then((it) => it.playlists());
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -4282,7 +4282,7 @@ describe("Subsonic", () => {
navidrome navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.playlist(id)) .then((it) => it.playlist(id))
).rejects.toEqual("Subsonic error:data not found"); ).rejects.toEqual("Subsonic error:data not found");
}); });
@@ -4338,7 +4338,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.playlist(id)); .then((it) => it.playlist(id));
expect(result).toEqual({ expect(result).toEqual({
@@ -4375,7 +4375,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.playlist(playlist.id)); .then((it) => it.playlist(playlist.id));
expect(result).toEqual(playlist); expect(result).toEqual(playlist);
@@ -4406,7 +4406,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.createPlaylist(name)); .then((it) => it.createPlaylist(name));
expect(result).toEqual({ id, name }); expect(result).toEqual({ id, name });
@@ -4433,7 +4433,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.deletePlaylist(id)); .then((it) => it.deletePlaylist(id));
expect(result).toEqual(true); expect(result).toEqual(true);
@@ -4461,7 +4461,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.addToPlaylist(playlistId, trackId)); .then((it) => it.addToPlaylist(playlistId, trackId));
expect(result).toEqual(true); expect(result).toEqual(true);
@@ -4489,7 +4489,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.removeFromPlaylist(playlistId, indicies)); .then((it) => it.removeFromPlaylist(playlistId, indicies));
expect(result).toEqual(true); expect(result).toEqual(true);
@@ -4539,7 +4539,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.similarSongs(id)); .then((it) => it.similarSongs(id));
expect(result).toEqual([track1]); expect(result).toEqual([track1]);
@@ -4612,7 +4612,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.similarSongs(id)); .then((it) => it.similarSongs(id));
expect(result).toEqual([track1, track2, track3]); expect(result).toEqual([track1, track2, track3]);
@@ -4642,7 +4642,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.similarSongs(id)); .then((it) => it.similarSongs(id));
expect(result).toEqual([]); expect(result).toEqual([]);
@@ -4673,7 +4673,7 @@ describe("Subsonic", () => {
navidrome navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.similarSongs(id)) .then((it) => it.similarSongs(id))
).rejects.toEqual("Subsonic error:data not found"); ).rejects.toEqual("Subsonic error:data not found");
}); });
@@ -4715,7 +4715,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.topSongs(artistId)); .then((it) => it.topSongs(artistId));
expect(result).toEqual([track1]); expect(result).toEqual([track1]);
@@ -4785,7 +4785,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.topSongs(artistId)); .then((it) => it.topSongs(artistId));
expect(result).toEqual([track1, track2, track3]); expect(result).toEqual([track1, track2, track3]);
@@ -4827,7 +4827,7 @@ describe("Subsonic", () => {
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.serviceToken))
.then((it) => it.topSongs(artistId)); .then((it) => it.topSongs(artistId));
expect(result).toEqual([]); expect(result).toEqual([]);

View File

@@ -1,23 +1,23 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "es2019", "target": "es2019",
"baseUrl": "./", "baseUrl": "./",
"module": "commonjs", "module": "commonjs",
"experimentalDecorators": true, "experimentalDecorators": true,
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"isolatedModules": false, "isolatedModules": false,
"strict": true, "strict": true,
"noImplicitAny": false, "noImplicitAny": false,
"typeRoots" : [ "typeRoots" : [
"../typings", "../typings",
"../node_modules/@types" "../node_modules/@types"
]
},
"exclude": [
"../node_modules"
],
"include": [
"./**/*.ts"
] ]
} },
"exclude": [
"../node_modules"
],
"include": [
"./**/*.ts"
]
}