mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
SmapiAuthTokens that expire, with sonos refreshAuthToken functionality (#81)
Bearer token to Authorization header for stream requests Versioned SMAPI Tokens
This commit is contained in:
@@ -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
30
src/api_tokens.ts
Normal 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);
|
||||||
|
}
|
||||||
15
src/app.ts
15
src/app.ts
@@ -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
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
12
src/clock.ts
12
src/clock.ts
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
154
src/server.ts
154
src/server.ts
@@ -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) {
|
||||||
|
|||||||
246
src/smapi.ts
246
src/smapi.ts
@@ -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
153
src/smapi_auth.ts
Normal 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"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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
67
tests/api_tokens.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
188
tests/smapi_auth.test.ts
Normal 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()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user