diff --git a/src/access_tokens.ts b/src/access_tokens.ts deleted file mode 100644 index 8029527..0000000 --- a/src/access_tokens.ts +++ /dev/null @@ -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(); - 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(); - accessTokenToAuthToken = new Map(); - - 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(); - 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); -} diff --git a/src/api_tokens.ts b/src/api_tokens.ts new file mode 100644 index 0000000..b72a0a6 --- /dev/null +++ b/src/api_tokens.ts @@ -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(); + 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); +} diff --git a/src/app.ts b/src/app.ts index ab46b73..40f458c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,15 +10,16 @@ import { DEFAULT, Subsonic, } from "./subsonic"; -import { InMemoryAccessTokens, sha256 } from "./access_tokens"; +import { InMemoryAPITokens, sha256 } from "./api_tokens"; import { InMemoryLinkCodes } from "./link_codes"; import readConfig from "./config"; import sonos, { bonobService } from "./sonos"; import { MusicService } from "./music_service"; import { SystemClock } from "./clock"; -import { jwtSigner } from "./encryption"; +import { JWTSmapiLoginTokens } from "./smapi_auth"; const config = readConfig(); +const clock = SystemClock; logger.info(`Starting bonob with config ${JSON.stringify(config)}`); @@ -47,8 +48,8 @@ const subsonic = new Subsonic( const featureFlagAwareMusicService: MusicService = { generateToken: subsonic.generateToken, - login: (authToken: string) => - subsonic.login(authToken).then((library) => { + login: (serviceToken: string) => + subsonic.login(serviceToken).then((library) => { return { ...library, scrobble: (id: string) => { @@ -82,13 +83,13 @@ const app = server( featureFlagAwareMusicService, { linkCodes: () => new InMemoryLinkCodes(), - accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)), - clock: SystemClock, + apiTokens: () => new InMemoryAPITokens(sha256(config.secret)), + clock, iconColors: config.icons, applyContextPath: true, logRequests: true, version, - tokenSigner: jwtSigner(config.secret), + smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, '1h'), externalImageResolver: artistImageFetcher } ); diff --git a/src/clock.ts b/src/clock.ts index 39e806e..77b16d4 100644 --- a/src/clock.ts +++ b/src/clock.ts @@ -14,3 +14,15 @@ export interface Clock { } 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; +} \ No newline at end of file diff --git a/src/encryption.ts b/src/encryption.ts index 24b9342..6e83e1e 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -4,54 +4,12 @@ import { randomBytes, createHash, } from "crypto"; -import jwt from "jsonwebtoken"; + import jws from "jws"; const ALGORITHM = "aes-256-cbc"; 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 => { - 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 => { - 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 = { iv: string; diff --git a/src/link_codes.ts b/src/link_codes.ts index 45e017d..2e3ddf1 100644 --- a/src/link_codes.ts +++ b/src/link_codes.ts @@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'; export type Association = { - authToken: string + serviceToken: string userId: string nickname: string } diff --git a/src/music_service.ts b/src/music_service.ts index e291aa1..5e2bd3e 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -5,7 +5,7 @@ export type Credentials = { username: string; password: string }; export function isSuccess( authResult: AuthSuccess | AuthFailure ): authResult is AuthSuccess { - return (authResult as AuthSuccess).authToken !== undefined; + return (authResult as AuthSuccess).serviceToken !== undefined; } export function isFailure( @@ -15,7 +15,7 @@ export function isFailure( } export type AuthSuccess = { - authToken: string; + serviceToken: string; userId: string; nickname: string; }; @@ -156,7 +156,7 @@ export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => export interface MusicService { generateToken(credentials: Credentials): Promise; - login(authToken: string): Promise; + login(serviceToken: string): Promise; } export interface MusicLibrary { diff --git a/src/server.ts b/src/server.ts index 0e878b1..d128ef8 100644 --- a/src/server.ts +++ b/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 * as Eta from "eta"; import path from "path"; @@ -24,7 +24,7 @@ import { import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; import bindSmapiSoapServiceToExpress from "./smapi"; -import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; +import { APITokens, InMemoryAPITokens } from "./api_tokens"; import logger from "./logger"; import { Clock, SystemClock } from "./clock"; import { pipe } from "fp-ts/lib/function"; @@ -34,9 +34,9 @@ import { Icon, ICONS, festivals, features } from "./icon"; import _, { shuffle } from "underscore"; import morgan from "morgan"; import { takeWithRepeats } from "./utils"; -import { jwtSigner, Signer } from "./encryption"; import { parse } from "./burn"; import { axiosImageFetcher, ImageFetcher } from "./subsonic"; +import { JWTSmapiLoginTokens, SmapiAuthTokens, SmapiToken } from "./smapi_auth"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -79,7 +79,7 @@ export class RangeBytesFromFilter extends Transform { export type ServerOpts = { linkCodes: () => LinkCodes; - accessTokens: () => AccessTokens; + apiTokens: () => APITokens; clock: Clock; iconColors: { foregroundColor: string | undefined; @@ -88,20 +88,24 @@ export type ServerOpts = { applyContextPath: boolean; logRequests: boolean; version: string; - tokenSigner: Signer; + smapiAuthTokens: SmapiAuthTokens; externalImageResolver: ImageFetcher; }; const DEFAULT_SERVER_OPTS: ServerOpts = { linkCodes: () => new InMemoryLinkCodes(), - accessTokens: () => new AccessTokenPerAuthToken(), + apiTokens: () => new InMemoryAPITokens(), clock: SystemClock, iconColors: { foregroundColor: undefined, backgroundColor: undefined }, applyContextPath: true, logRequests: false, version: "v?", - tokenSigner: jwtSigner(`bonob-${uuid()}`), - externalImageResolver: axiosImageFetcher + smapiAuthTokens: new JWTSmapiLoginTokens( + SystemClock, + `bonob-${uuid()}`, + "1m" + ), + externalImageResolver: axiosImageFetcher, }; function server( @@ -114,7 +118,8 @@ function server( const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts }; const linkCodes = serverOpts.linkCodes(); - const accessTokens = serverOpts.accessTokens(); + const smapiAuthTokens = serverOpts.smapiAuthTokens; + const apiTokens = serverOpts.apiTokens(); const clock = serverOpts.clock; const startUpTime = dayjs(); @@ -228,30 +233,33 @@ function server( message: lang("invalidLinkCode"), }); } else { - return musicService.generateToken({ - username, - password, - }).then(authResult => { - if (isSuccess(authResult)) { - linkCodes.associate(linkCode, authResult); - return res.render("success", { - lang, - message: lang("loginSuccessful"), - }); - } else { + return musicService + .generateToken({ + username, + password, + }) + .then((authResult) => { + if (isSuccess(authResult)) { + linkCodes.associate(linkCode, authResult); + return res.render("success", { + lang, + 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", { lang, 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 rating = ratingFromInt(value); 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 starsRatingIcon = bonobUrl.append({pathname: `/star${rating.stars}.svg`}).href(); + const loveRatingIcon = bonobUrl + .append({ + pathname: rating.love ? "/love-selected.svg" : "/love-unselected.svg", + }) + .href(); + const starsRatingIcon = bonobUrl + .append({ pathname: `/star${rating.stars}.svg` }) + .href(); return ` - + - + - ` - } - + `; + }; + res.type("application/xml").send(` @@ -348,21 +369,32 @@ function server( const trace = uuid(); logger.info( - `${trace} bnb<- ${req.method} ${req.path}?${ - JSON.stringify(req.query) - }, headers=${JSON.stringify(req.headers)}` + `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify( + req.query + )}, headers=${JSON.stringify({ ...req.headers, "authorization": "***" })}` ); - const authToken = pipe( - req.query[BONOB_ACCESS_TOKEN_HEADER] as string, - O.fromNullable, - O.map((accessToken) => accessTokens.authTokenFor(accessToken)), - O.getOrElseW(() => undefined) + + const authHeader = E.fromNullable("Missing header"); + const bearerToken = E.fromNullable("No Bearer token"); + const serviceToken = pipe( + authHeader(req.headers["authorization"] as string), + E.chain(authorization => pipe( + authorization.match(/Bearer (?.*)/), + 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(); } else { return musicService - .login(authToken) + .login(serviceToken) .then((it) => it .stream({ @@ -382,7 +414,7 @@ function server( contentType .split(";") .map((it) => it.trim()) - .map((it) => sonosifyMimeType(it)) + .map(sonosifyMimeType) .join("; "); const respondWith = ({ @@ -532,27 +564,31 @@ function server( ]; app.get("/art/:burns/size/:size", (req, res) => { - const authToken = accessTokens.authTokenFor( + const serviceToken = apiTokens.authTokenFor( req.query[BONOB_ACCESS_TOKEN_HEADER] as string ); const urns = req.params["burns"]!.split("&").map(parse); const size = Number.parseInt(req.params["size"]!); - if (!authToken) { + if (!serviceToken) { return res.status(401).send(); } else if (!(size > 0)) { return res.status(400).send(); } return musicService - .login(authToken) - .then((musicLibrary) => Promise.all(urns.map((it) => { - if(it.system == "external") { - return serverOpts.externalImageResolver(it.resource); - } else { - return musicLibrary.coverArt(it, size); - } - }))) + .login(serviceToken) + .then((musicLibrary) => + Promise.all( + urns.map((it) => { + if (it.system == "external") { + return serverOpts.externalImageResolver(it.resource); + } else { + return musicLibrary.coverArt(it, size); + } + }) + ) + ) .then((coverArts) => coverArts.filter((it) => it)) .then(shuffle) .then((coverArts) => { @@ -603,10 +639,10 @@ function server( bonobUrl, linkCodes, musicService, - accessTokens, + apiTokens, clock, i8n, - serverOpts.tokenSigner + serverOpts.smapiAuthTokens ); if (serverOpts.applyContextPath) { diff --git a/src/smapi.ts b/src/smapi.ts index 042c7ac..c81f391 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -3,8 +3,8 @@ import { Express, Request } from "express"; import { listen } from "soap"; import { readFileSync } from "fs"; import path from "path"; +import { option as O, either as E } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; -import { option as O } from "fp-ts"; import logger from "./logger"; @@ -21,14 +21,20 @@ import { slice2, Track, } from "./music_service"; -import { AccessTokens } from "./access_tokens"; +import { APITokens } from "./api_tokens"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; import { asLANGs, I8N } from "./i8n"; import { ICON, iconForGenre } from "./icon"; import _, { uniq } from "underscore"; -import { pSigner, Signer } from "./encryption"; import { BUrn, formatForURL } from "./burn"; +import { + InvalidTokenError, + isSmapiRefreshTokenResultFault, + MissingLoginTokenError, + SmapiAuthTokens, + smapiTokenAsString, +} from "./smapi_auth"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -60,6 +66,7 @@ const WSDL_FILE = path.resolve( export type Credentials = { loginToken: { token: string; + key: string; householdId: string; }; deviceId: string; @@ -150,12 +157,19 @@ export function searchResult( class SonosSoap { linkCodes: LinkCodes; 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.linkCodes = linkCodes; - this.tokenSigner = tokenSigner + this.smapiAuthTokens = smapiAuthTokens; + this.clock = clock; } getAppLink(): GetAppLinkResult { @@ -184,10 +198,13 @@ class SonosSoap { }): GetDeviceAuthTokenResult { const association = this.linkCodes.associationFor(linkCode); if (association) { + const smapiAuthToken = this.smapiAuthTokens.issue( + association.serviceToken + ); return { getDeviceAuthTokenResult: { - authToken: this.tokenSigner.sign(association.authToken), - privateKey: "", + authToken: smapiAuthToken.token, + privateKey: smapiAuthToken.key, userInfo: { nickname: association.nickname, userIdHashCode: crypto @@ -249,13 +266,18 @@ export const playlistAlbumArtURL = ( bonobUrl: URLBuilder, playlist: Playlist ) => { - const burns: BUrn[] = uniq(playlist.entries.filter(it => it.coverArt != undefined), it => it.album.id).map((it) => it.coverArt!); - console.log(`### playlist ${playlist.name} burns -> ${JSON.stringify(burns)}`) + const burns: BUrn[] = uniq( + playlist.entries.filter((it) => it.coverArt != undefined), + (it) => it.album.id + ).map((it) => it.coverArt!); if (burns.length == 0) { return iconArtURI(bonobUrl, "error"); } else { 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 = ( bonobUrl: URLBuilder, { coverArt }: { coverArt: BUrn | undefined } -) => pipe( - coverArt, - O.fromNullable, - O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })), - O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) -); +) => + pipe( + coverArt, + O.fromNullable, + O.map((it) => + bonobUrl.append({ + pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`, + }) + ), + O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) + ); export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => bonobUrl.append({ @@ -278,12 +305,17 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => export const defaultArtistArtURI = ( bonobUrl: URLBuilder, artist: ArtistSummary -) => pipe( - artist.image, - O.fromNullable, - O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })), - O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) -); +) => + pipe( + artist.image, + O.fromNullable, + O.map((it) => + bonobUrl.append({ + pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`, + }) + ), + O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) + ); export const sonosifyMimeType = (mimeType: string) => mimeType == "audio/x-flac" ? "audio/flac" : mimeType; @@ -312,7 +344,7 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({ album: track.album.name, albumId: `album:${track.album.id}`, 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(), artist: track.artist.name, artistId: track.artist.id ? `artist:${track.artist.id}` : undefined, @@ -353,12 +385,12 @@ function bindSmapiSoapServiceToExpress( bonobUrl: URLBuilder, linkCodes: LinkCodes, musicService: MusicService, - accessTokens: AccessTokens, + apiKeys: APITokens, clock: Clock, 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) => bonobUrl.append({ @@ -367,31 +399,47 @@ function bindSmapiSoapServiceToExpress( }, }); - const auth = async ( - credentials?: Credentials - ) => { - if (!credentials) { - throw { - Fault: { - faultcode: "Client.LoginUnsupported", - faultstring: "Missing credentials...", - }, - }; - } + const auth = (credentials?: Credentials) => { + const credentialsFrom = E.fromNullable(new MissingLoginTokenError()); + return pipe( + credentialsFrom(credentials), + E.chain((credentials) => + pipe( + smapiAuthTokens.verify({ + 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) - .verify(credentials.loginToken.token) - .then(authToken => ({ authToken, accessToken: accessTokens.mint(authToken) })) - .then((tokens) => musicService.login(tokens.authToken).then(musicLibrary => ({ ...tokens, musicLibrary }))) - .catch((_) => { - throw { - Fault: { - faultcode: "Client.LoginUnauthorized", - faultstring: "Failed to authenticate, try Reauthorising your account in the sonos app", - }, - }; - }); - }; + const login = async (credentials?: Credentials) => { + const tokens = pipe( + auth(credentials), + E.getOrElseW((e) => { + throw e.toSmapiFault(smapiAuthTokens); + }) + ); + + return musicService + .login(tokens.serviceToken) + .then((musicLibrary) => ({ ...tokens, musicLibrary })) + .catch((_) => { + throw new InvalidTokenError("Failed to login").toSmapiFault( + smapiAuthTokens + ); + }); + }; const soapyService = listen( app, @@ -410,31 +458,65 @@ function bindSmapiSoapServiceToExpress( 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 ( { id }: { id: string }, _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) - .then(({ accessToken, type, typeId }) => ({ + .then(({ credentials, type, typeId }) => ({ getMediaURIResult: bonobUrl .append({ pathname: `/stream/${type}/${typeId}`, - searchParams: { bat: accessToken }, }) .href(), + httpHeaders: [ + { + httpHeader: { + header: "Authorization", + value: `Bearer ${smapiTokenAsString( + credentials.loginToken + )}`, + }, + }, + ], })), getMediaMetadata: async ( { id }: { id: string }, _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) - .then(async ({ musicLibrary, accessToken, typeId }) => + .then(async ({ musicLibrary, apiKey, typeId }) => musicLibrary.track(typeId!).then((it) => ({ - getMediaMetadataResult: track(urlWithToken(accessToken), it), + getMediaMetadataResult: track(urlWithToken(apiKey), it), })) ), search: async ( @@ -442,16 +524,16 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) - .then(async ({ musicLibrary, accessToken }) => { + .then(async ({ musicLibrary, apiKey }) => { switch (id) { case "albums": return musicLibrary.searchAlbums(term).then((it) => searchResult({ count: it.length, mediaCollection: it.map((albumSummary) => - album(urlWithToken(accessToken), albumSummary) + album(urlWithToken(apiKey), albumSummary) ), }) ); @@ -460,7 +542,7 @@ function bindSmapiSoapServiceToExpress( searchResult({ count: it.length, mediaCollection: it.map((artistSummary) => - artist(urlWithToken(accessToken), artistSummary) + artist(urlWithToken(apiKey), artistSummary) ), }) ); @@ -469,7 +551,7 @@ function bindSmapiSoapServiceToExpress( searchResult({ count: it.length, mediaCollection: it.map((aTrack) => - album(urlWithToken(accessToken), aTrack.album) + album(urlWithToken(apiKey), aTrack.album) ), }) ); @@ -487,9 +569,9 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) - .then(async ({ musicLibrary, accessToken, type, typeId }) => { + .then(async ({ musicLibrary, apiKey, type, typeId }) => { const paging = { _index: index, _count: count }; switch (type) { case "artist": @@ -503,7 +585,7 @@ function bindSmapiSoapServiceToExpress( index: paging._index, total, mediaCollection: page.map((it) => - album(urlWithToken(accessToken), it) + album(urlWithToken(apiKey), it) ), relatedBrowse: artist.similarArtists.filter((it) => it.inLibrary) @@ -521,7 +603,7 @@ function bindSmapiSoapServiceToExpress( case "track": return musicLibrary.track(typeId).then((it) => ({ getExtendedMetadataResult: { - mediaMetadata: track(urlWithToken(accessToken), it), + mediaMetadata: track(urlWithToken(apiKey), it), }, })); case "album": @@ -533,7 +615,7 @@ function bindSmapiSoapServiceToExpress( userContent: false, renameable: false, }, - ...album(urlWithToken(accessToken), it), + ...album(urlWithToken(apiKey), it), }, // // @@ -559,9 +641,9 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders, { headers }: Pick ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) - .then(({ musicLibrary, accessToken, type, typeId }) => { + .then(({ musicLibrary, apiKey, type, typeId }) => { const paging = { _index: index, _count: count }; const acceptLanguage = headers["accept-language"]; logger.debug( @@ -573,7 +655,7 @@ function bindSmapiSoapServiceToExpress( musicLibrary.albums(q).then((result) => { return getMetadataResult({ mediaCollection: result.results.map((it) => - album(urlWithToken(accessToken), it) + album(urlWithToken(apiKey), it) ), index: paging._index, total: result.total, @@ -684,7 +766,7 @@ function bindSmapiSoapServiceToExpress( return musicLibrary.artists(paging).then((result) => { return getMetadataResult({ mediaCollection: result.results.map((it) => - artist(urlWithToken(accessToken), it) + artist(urlWithToken(apiKey), it) ), index: paging._index, total: result.total, @@ -759,7 +841,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => - playlist(urlWithToken(accessToken), it) + playlist(urlWithToken(apiKey), it) ), index: paging._index, total, @@ -773,7 +855,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaMetadata: page.map((it) => - track(urlWithToken(accessToken), it) + track(urlWithToken(apiKey), it) ), index: paging._index, total, @@ -787,7 +869,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => - album(urlWithToken(accessToken), it) + album(urlWithToken(apiKey), it) ), index: paging._index, total, @@ -804,7 +886,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => - artist(urlWithToken(accessToken), it) + artist(urlWithToken(apiKey), it) ), index: paging._index, total, @@ -817,7 +899,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaMetadata: page.map((it) => - track(urlWithToken(accessToken), it) + track(urlWithToken(apiKey), it) ), index: paging._index, total, @@ -832,7 +914,7 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary .createPlaylist(title) @@ -858,7 +940,7 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) .then((_) => ({ deleteContainerResult: {} })), addToContainer: async ( @@ -866,7 +948,7 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) @@ -877,7 +959,7 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) .then((it) => ({ ...it, @@ -900,7 +982,7 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) @@ -912,7 +994,7 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders ) => - auth(soapyHeaders?.credentials) + login(soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, type, typeId }) => { switch (type) { diff --git a/src/smapi_auth.ts b/src/smapi_auth.ts new file mode 100644 index 0000000..c9059f0 --- /dev/null +++ b/src/smapi_auth.ts @@ -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; +}; + +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 => { + 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")) + } + }; +} diff --git a/src/subsonic.ts b/src/subsonic.ts index 2ab889b..f4afe28 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -443,7 +443,7 @@ export class Subsonic implements MusicService { generateToken = async (credentials: Credentials) => this.getJSON(credentials, "/rest/ping.view") .then(() => ({ - authToken: b64Encode(JSON.stringify(credentials)), + serviceToken: b64Encode(JSON.stringify(credentials)), userId: credentials.username, nickname: credentials.username, })) diff --git a/tests/access_tokens.test.ts b/tests/access_tokens.test.ts deleted file mode 100644 index 1742289..0000000 --- a/tests/access_tokens.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/tests/api_tokens.test.ts b/tests/api_tokens.test.ts new file mode 100644 index 0000000..4bad1e5 --- /dev/null +++ b/tests/api_tokens.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/builders.ts b/tests/builders.ts index 9bcaedc..83c1c7e 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -90,10 +90,11 @@ export function getAppLinkMessage() { }; } -export function someCredentials(token: string): Credentials { +export function someCredentials({ token, key } : { token: string, key: string }): Credentials { return { loginToken: { token, + key, householdId: "hh1", }, deviceId: "d1", diff --git a/tests/icon.test.ts b/tests/icon.test.ts index fdbfbf3..b3613e9 100644 --- a/tests/icon.test.ts +++ b/tests/icon.test.ts @@ -1,5 +1,6 @@ import dayjs from "dayjs"; import libxmljs from "libxmljs2"; +import { FixedClock } from "../src/clock"; import { contains, @@ -556,12 +557,11 @@ describe("festivals", () => { backgroundColor: "black", foregroundColor: "black", }); - let now = dayjs(); - const clock = { now: () => now }; + const clock = new FixedClock(dayjs()); describe("on a day that isn't festive", () => { beforeEach(() => { - now = dayjs("2022/10/12"); + clock.time = dayjs("2022/10/12"); }); it("should use the given colors", () => { @@ -587,7 +587,7 @@ describe("festivals", () => { describe("on christmas day", () => { beforeEach(() => { - now = dayjs("2022/12/25"); + clock.time = dayjs("2022/12/25"); }); it("should use the christmas theme colors", () => { @@ -613,7 +613,7 @@ describe("festivals", () => { describe("on halloween", () => { beforeEach(() => { - now = dayjs("2022/10/31"); + clock.time = dayjs("2022/10/31"); }); it("should use the given colors", () => { @@ -638,7 +638,7 @@ describe("festivals", () => { describe("on may 4", () => { beforeEach(() => { - now = dayjs("2022/5/4"); + clock.time = dayjs("2022/5/4"); }); it("should use the undefined colors, so no color", () => { @@ -664,7 +664,7 @@ describe("festivals", () => { describe("on cny", () => { describe("2022", () => { beforeEach(() => { - now = dayjs("2022/02/01"); + clock.time = dayjs("2022/02/01"); }); it("should use the cny theme", () => { @@ -689,7 +689,7 @@ describe("festivals", () => { describe("2023", () => { beforeEach(() => { - now = dayjs("2023/01/22"); + clock.time = dayjs("2023/01/22"); }); it("should use the cny theme", () => { @@ -714,7 +714,7 @@ describe("festivals", () => { describe("2024", () => { beforeEach(() => { - now = dayjs("2024/02/10"); + clock.time = dayjs("2024/02/10"); }); it("should use the cny theme", () => { @@ -740,7 +740,7 @@ describe("festivals", () => { describe("on holi", () => { beforeEach(() => { - now = dayjs("2022/03/18"); + clock.time = dayjs("2022/03/18"); }); it("should use the given colors", () => { diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index d358eb2..67714fb 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -33,7 +33,7 @@ describe("InMemoryMusicService", () => { expect(token.userId).toEqual(credentials.username); expect(token.nickname).toEqual(credentials.username); - const musicLibrary = service.login(token.authToken); + const musicLibrary = service.login(token.serviceToken); expect(musicLibrary).toBeDefined(); }); @@ -47,7 +47,7 @@ describe("InMemoryMusicService", () => { service.clear(); - return expect(service.login(token.authToken)).rejects.toEqual( + return expect(service.login(token.serviceToken)).rejects.toEqual( "Invalid auth token" ); }); @@ -63,7 +63,7 @@ describe("InMemoryMusicService", () => { service.hasUser(user); 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", () => { diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 7c88066..49c62fb 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -41,7 +41,7 @@ export class InMemoryMusicService implements MusicService { this.users[username] == password ) { return Promise.resolve({ - authToken: b64Encode(JSON.stringify({ username, password })), + serviceToken: b64Encode(JSON.stringify({ username, password })), userId: username, nickname: username, }); @@ -50,8 +50,8 @@ export class InMemoryMusicService implements MusicService { } } - login(token: string): Promise { - const credentials = JSON.parse(b64Decode(token)) as Credentials; + login(serviceToken: string): Promise { + const credentials = JSON.parse(b64Decode(serviceToken)) as Credentials; if (this.users[credentials.username] != credentials.password) return Promise.reject("Invalid auth token"); diff --git a/tests/link_codes.test.ts b/tests/link_codes.test.ts index 83c967e..0c9e0e4 100644 --- a/tests/link_codes.test.ts +++ b/tests/link_codes.test.ts @@ -18,7 +18,7 @@ describe("InMemoryLinkCodes", () => { describe('when token is valid', () => { it('should associate the token', () => { const linkCode = linkCodes.mint(); - const association = { authToken: "token123", nickname: "bob", userId: "1" }; + const association = { serviceToken: "token123", nickname: "bob", userId: "1" }; linkCodes.associate(linkCode, association); @@ -29,7 +29,7 @@ describe("InMemoryLinkCodes", () => { describe('when token is valid', () => { it('should throw an error', () => { 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}`) }); diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index fdde064..fd7170c 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -33,9 +33,10 @@ class LoggedInSonosDriver { this.client = client; this.token = token; this.client.addSoapHeader({ - credentials: someCredentials( - this.token.getDeviceAuthTokenResult.authToken - ), + credentials: someCredentials({ + token: this.token.getDeviceAuthTokenResult.authToken, + key: this.token.getDeviceAuthTokenResult.privateKey + }), }); } @@ -272,7 +273,7 @@ describe("scenarios", () => { bonobUrl, musicService, { - linkCodes: () => linkCodes + linkCodes: () => linkCodes, } ); diff --git a/tests/server.test.ts b/tests/server.test.ts index b259779..327a050 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -3,6 +3,7 @@ import dayjs from "dayjs"; import request from "supertest"; import Image from "image-js"; import fs from "fs"; +import { either as E } from "fp-ts"; import path from "path"; import { MusicService } from "../src/music_service"; @@ -16,7 +17,7 @@ import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; import { aDevice, aService } from "./builders"; 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 { Response } from "express"; import { Transform } from "stream"; @@ -25,6 +26,7 @@ import i8n, { randomLang } from "../src/i8n"; import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi"; import { Clock, SystemClock } from "../src/clock"; import { formatForURL } from "../src/burn"; +import { ExpiredTokenError, SmapiAuthTokens } from "../src/smapi_auth"; describe("rangeFilterFor", () => { describe("invalid range header string", () => { @@ -579,7 +581,7 @@ describe("server", () => { associate: jest.fn(), associationFor: jest.fn(), }; - const accessTokens = { + const apiTokens = { mint: jest.fn(), authTokenFor: jest.fn(), }; @@ -594,7 +596,7 @@ describe("server", () => { musicService as unknown as MusicService, { linkCodes: () => linkCodes as unknown as LinkCodes, - accessTokens: () => accessTokens as unknown as AccessTokens, + apiTokens: () => apiTokens as unknown as APITokens, clock, } ); @@ -628,14 +630,14 @@ describe("server", () => { const username = "jane"; const password = "password100"; const linkCode = `linkCode-${uuid()}`; - const authToken = { - authToken: `authtoken-${uuid()}`, + const authSuccess = { + serviceToken: `serviceToken-${uuid()}`, userId: `${username}-uid`, nickname: `${username}-nickname`, }; linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockResolvedValue(authToken); + musicService.generateToken.mockResolvedValue(authSuccess); linkCodes.associate.mockReturnValue(true); const res = await request(server) @@ -654,7 +656,7 @@ describe("server", () => { expect(linkCodes.has).toHaveBeenCalledWith(linkCode); expect(linkCodes.associate).toHaveBeenCalledWith( linkCode, - authToken + authSuccess ); }); }); @@ -731,8 +733,10 @@ describe("server", () => { scrobble: jest.fn(), nowPlaying: jest.fn(), }; - let now = dayjs(); - const accessTokens = new ExpiringAccessTokens({ now: () => now }); + const smapiAuthTokens = { + verify: jest.fn(), + } + const apiTokens = new InMemoryAPITokens(); const server = makeServer( jest.fn() as unknown as Sonos, @@ -741,17 +745,14 @@ describe("server", () => { musicService as unknown as MusicService, { linkCodes: () => new InMemoryLinkCodes(), - accessTokens: () => accessTokens, + apiTokens: () => apiTokens, + smapiAuthTokens: smapiAuthTokens as unknown as SmapiAuthTokens } ); - const authToken = uuid(); + const serviceToken = uuid(); const trackId = uuid(); - let accessToken: string; - - beforeEach(() => { - accessToken = accessTokens.mint(authToken); - }); + const smapiAuthToken = `smapiAuthToken-${uuid()}`; const streamContent = (content: string) => ({ pipe: (_: Transform) => { @@ -764,7 +765,7 @@ describe("server", () => { }); describe("HEAD requests", () => { - describe("when there is no access-token", () => { + describe("when there is no Bearer token", () => { it("should return a 401", async () => { const res = await request(server).head( 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 () => { - now = now.add(1, "day"); + smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(smapiAuthToken, 0))) const res = await request(server).head( bonobUrl .append({ - pathname: `/stream/track/${trackId}`, - searchParams: { bat: accessToken }, + pathname: `/stream/track/${trackId}` }) - .path() - ); + .path(), + ).set('Authorization', `Bearer ${smapiAuthToken}`); 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", () => { it("should return a 200", async () => { const trackStream = { @@ -810,9 +814,9 @@ describe("server", () => { const res = await request(server) .head( bonobUrl - .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) + .append({ pathname: `/stream/track/${trackId}`}) .path() - ); + ).set('Authorization', `Bearer ${smapiAuthToken}`); expect(res.status).toEqual(trackStream.status); expect(res.headers["content-type"]).toEqual( @@ -836,9 +840,10 @@ describe("server", () => { const res = await request(server) .head(bonobUrl - .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) + .append({ pathname: `/stream/track/${trackId}` }) .path() - ); + ) + .set('Authorization', `Bearer ${smapiAuthToken}`); expect(res.status).toEqual(404); expect(res.body).toEqual({}); @@ -848,7 +853,7 @@ describe("server", () => { }); describe("GET requests", () => { - describe("when there is no access-token", () => { + describe("when there is no Bearer token", () => { it("should return a 401", async () => { const res = await request(server).get( 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 () => { - now = now.add(1, "day"); + smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(smapiAuthToken, 0))) const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) + .append({ pathname: `/stream/track/${trackId}` }) .path() - ); + ).set('Authorization', `Bearer ${smapiAuthToken}`); expect(res.status).toEqual(401); }); }); - describe("when the track doesnt exist", () => { - it("should return a 404", async () => { - const stream = { - 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 the Bearer token is valid", () => { + beforeEach(() => { + smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); }); - }); - - 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 = { - status: 200, - headers: { - // audio/x-flac should be mapped to audio/flac - "content-type": "audio/x-flac; charset=utf-8", - }, - stream: streamContent(content), + status: 404, + headers: {}, + 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 } }) + .append({ pathname: `/stream/track/${trackId}` }) .path() - ); - - 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.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); + ).set('Authorization', `Bearer ${smapiAuthToken}`); + + expect(res.status).toEqual(404); + + expect(musicLibrary.nowPlaying).not.toHaveBeenCalled(); expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); }); }); - - describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => { - it("should return a 200 with the data, without adding the undefined headers", async () => { - const stream = { - status: 200, - headers: { - "content-type": "audio/mp3", - "content-length": undefined, - "accept-ranges": undefined, - "content-range": undefined, - }, - 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() + + 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"; + + const stream = { + status: 200, + headers: { + // audio/x-flac should be mapped to audio/flac + "content-type": "audio/x-flac; charset=utf-8", + }, + stream: streamContent(content), + }; + + 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.headers["content-type"]).toEqual( + "audio/flac; charset=utf-8" ); - - expect(res.status).toEqual(stream.status); - expect(res.headers["content-type"]).toEqual( - "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(res.header["accept-ranges"]).toBeUndefined(); + expect(res.headers["content-length"]).toEqual( + `${content.length}` ); - - 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"]).toBeUndefined(); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + expect(Object.keys(res.headers)).not.toContain("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", () => { - 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}`, searchParams: { bat: accessToken } }) - .path() + + describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => { + it("should return a 200 with the data, without adding the undefined headers", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + "content-length": undefined, + "accept-ranges": undefined, + "content-range": undefined, + }, + 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.headers["content-type"]).toEqual( + "audio/mp3; charset=utf-8" ); - - 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(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(Object.keys(res.headers)).not.toContain("content-range"); + + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); }); - }); - }); - - describe("when sonos does ask for a range", () => { - 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": "none", - }, - stream: streamContent(""), - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); - - const requestedRange = "40-"; - - const res = await request(server) - .get( - bonobUrl - .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) - .path() - ) - .set("Range", requestedRange); - - 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"]).toBeUndefined(); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ - trackId, - range: requestedRange, + + 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}` }) + .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"]).toBeUndefined(); + + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + + 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}`); + + 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", () => { - 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}`, searchParams: { bat: accessToken } }) - .path() - ) - .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(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ - trackId, - range: "4000-5000", + + describe("when sonos does ask for a range", () => { + 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": "none", + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const requestedRange = "40-"; + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set('Authorization', `Bearer ${smapiAuthToken}`) + .set("Range", requestedRange); + + 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"]).toBeUndefined(); + + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ + 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 = { coverArt: jest.fn(), }; - let now = dayjs(); - const accessTokens = new ExpiringAccessTokens({ now: () => now }); + const apiTokens = new InMemoryAPITokens(); const server = makeServer( jest.fn() as unknown as Sonos, @@ -1168,13 +1181,13 @@ describe("server", () => { musicService as unknown as MusicService, { linkCodes: () => new InMemoryLinkCodes(), - accessTokens: () => accessTokens, + apiTokens: () => apiTokens, } ); - const authToken = uuid(); + const serviceToken = uuid(); const albumId = uuid(); - let accessToken: string; + let apiToken: string; const coverArtResponse = ( opt: Partial<{ status: number; contentType: string; data: Buffer }> @@ -1186,7 +1199,7 @@ describe("server", () => { }); beforeEach(() => { - accessToken = accessTokens.mint(authToken); + apiToken = apiTokens.mint(serviceToken); }); 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("art", () => { ["0", "-1", "foo"].forEach((size) => { @@ -1219,9 +1220,9 @@ describe("server", () => { musicService.login.mockResolvedValue(musicLibrary); const res = await request(server) .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); }); @@ -1241,16 +1242,16 @@ describe("server", () => { const res = await request(server) .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.header["content-type"]).toEqual( coverArt.contentType ); - expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.coverArt).toHaveBeenCalledWith( coverArtURN, 180 @@ -1267,9 +1268,9 @@ describe("server", () => { const res = await request(server) .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); }); @@ -1310,14 +1311,14 @@ describe("server", () => { .get( `/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.header["content-type"]).toEqual("image/png"); - expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); urns.forEach((it) => { expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 200); }); @@ -1348,9 +1349,9 @@ describe("server", () => { .get( `/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.header["content-type"]).toEqual( @@ -1373,9 +1374,9 @@ describe("server", () => { .get( `/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); }); @@ -1409,14 +1410,14 @@ describe("server", () => { .get( `/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.header["content-type"]).toEqual("image/png"); - expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); urns.forEach((it) => { expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180); }); @@ -1465,14 +1466,14 @@ describe("server", () => { .get( `/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.header["content-type"]).toEqual("image/png"); - expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); urns.forEach((urn) => { expect(musicLibrary.coverArt).toHaveBeenCalledWith(urn, 180); }); @@ -1513,14 +1514,14 @@ describe("server", () => { .get( `/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.header["content-type"]).toEqual("image/png"); - expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); urns.forEach((it) => { expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180); }); @@ -1540,9 +1541,9 @@ describe("server", () => { const res = await request(server) .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); }); @@ -1557,9 +1558,9 @@ describe("server", () => { const res = await request(server) .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); }); @@ -1583,7 +1584,7 @@ describe("server", () => { jest.fn() as unknown as MusicService, { linkCodes: () => new InMemoryLinkCodes(), - accessTokens: () => jest.fn() as unknown as AccessTokens, + apiTokens: () => jest.fn() as unknown as APITokens, clock, iconColors, } diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 0290f5c..4f1c981 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -2,7 +2,7 @@ import crypto from "crypto"; import request from "supertest"; import { Client, createClientAsync } from "soap"; import { v4 as uuid } from "uuid"; - +import { either as E } from "fp-ts"; import { DOMParserImpl } from "xmldom-ts"; import * as xpath from "xpath-ts"; import { randomInt } from "crypto"; @@ -26,10 +26,9 @@ import { sonosifyMimeType, ratingAsInt, ratingFromInt, - Credentials, } from "../src/smapi"; -import { keys as i8nKeys } from '../src/i8n'; +import { keys as i8nKeys } from "../src/i8n"; import { aService, getAppLinkMessage, @@ -52,34 +51,36 @@ import { MusicService, playlistToPlaylistSummary, } from "../src/music_service"; -import { AccessTokens } from "../src/access_tokens"; +import { APITokens } from "../src/api_tokens"; import dayjs from "dayjs"; import url, { URLBuilder } from "../src/url_builder"; import { iconForGenre } from "../src/icon"; -import { jwtSigner } from "../src/encryption"; import { formatForURL } from "../src/burn"; import { range } from "underscore"; +import { FixedClock } from "../src/clock"; +import { ExpiredTokenError, InvalidTokenError, SmapiAuthTokens, SmapiToken, smapiTokenAsString, ToSmapiFault } from "../src/smapi_auth"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); - describe("rating to and from ints", () => { describe("ratingAsInt", () => { [ - { rating: { love: false, stars: 0 }, expectedValue: 100 }, - { rating: { love: true, stars: 0 }, expectedValue: 101 }, - { rating: { love: false, stars: 1 }, expectedValue: 110 }, - { rating: { love: true, stars: 1 }, expectedValue: 111 }, - { rating: { love: false, stars: 2 }, expectedValue: 120 }, - { rating: { love: true, stars: 2 }, expectedValue: 121 }, - { rating: { love: false, stars: 3 }, expectedValue: 130 }, - { rating: { love: true, stars: 3 }, expectedValue: 131 }, - { rating: { love: false, stars: 4 }, expectedValue: 140 }, - { rating: { love: true, stars: 4 }, expectedValue: 141 }, - { rating: { love: false, stars: 5 }, expectedValue: 150 }, - { rating: { love: true, stars: 5 }, expectedValue: 151 }, + { rating: { love: false, stars: 0 }, expectedValue: 100 }, + { rating: { love: true, stars: 0 }, expectedValue: 101 }, + { rating: { love: false, stars: 1 }, expectedValue: 110 }, + { rating: { love: true, stars: 1 }, expectedValue: 111 }, + { rating: { love: false, stars: 2 }, expectedValue: 120 }, + { rating: { love: true, stars: 2 }, expectedValue: 121 }, + { rating: { love: false, stars: 3 }, expectedValue: 130 }, + { rating: { love: true, stars: 3 }, expectedValue: 131 }, + { rating: { love: false, stars: 4 }, expectedValue: 140 }, + { rating: { love: true, stars: 4 }, expectedValue: 141 }, + { rating: { love: false, stars: 5 }, expectedValue: 150 }, + { rating: { love: true, stars: 5 }, expectedValue: 151 }, ].forEach(({ rating, expectedValue }) => { - it(`should map ${JSON.stringify(rating)} to a ${expectedValue} and back`, () => { + it(`should map ${JSON.stringify( + rating + )} to a ${expectedValue} and back`, () => { const actualValue = ratingAsInt(rating); expect(actualValue).toEqual(expectedValue); expect(ratingFromInt(actualValue)).toEqual(rating); @@ -170,8 +171,8 @@ describe("service config", () => { `string(/Presentation/BrowseOptions/@PageSize)`, xml ); - - expect(pageSize).toEqual('30'); + + expect(pageSize).toEqual("30"); }); it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => { @@ -205,36 +206,36 @@ describe("service config", () => { describe("NowPlayingRatings", () => { it("should have Matches with propname = rating", async () => { const xml = await presentationMapXml(); - + const matchElements = xpath.select( `/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match`, xml ) as Element[]; - + expect(matchElements.length).toBe(12); - + matchElements.forEach((match) => { expect(match.getAttributeNode("propname")?.value).toEqual( "rating" ); }); }); - + it("should have Rating stringIds that are in strings.xml", async () => { const xml = await presentationMapXml(); - + const ratingElements = xpath.select( `/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match/Ratings/Rating`, xml ) as Element[]; - + expect(ratingElements.length).toBeGreaterThan(1); - + ratingElements.forEach((rating) => { const OnSuccessStringId = rating.getAttributeNode("OnSuccessStringId")!.value; const StringId = rating.getAttributeNode("StringId")!.value; - + expect(i8nKeys()).toContain(OnSuccessStringId); expect(i8nKeys()).toContain(StringId); }); @@ -242,17 +243,20 @@ describe("service config", () => { it("should have Rating Ids that are valid ratings as ints", async () => { const xml = await presentationMapXml(); - + const ratingElements = xpath.select( `/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match/Ratings/Rating`, xml ) as Element[]; - + expect(ratingElements.length).toBeGreaterThan(1); - + ratingElements.forEach((ratingElement) => { - - const rating = ratingFromInt(Math.abs(Number.parseInt(ratingElement.getAttributeNode("Id")!.value))) + const rating = ratingFromInt( + Math.abs( + Number.parseInt(ratingElement.getAttributeNode("Id")!.value) + ) + ); expect(rating.love).toBeDefined(); expect(rating.stars).toBeGreaterThanOrEqual(0); expect(rating.stars).toBeLessThanOrEqual(5); @@ -362,11 +366,11 @@ describe("track", () => { genre: { id: "genre101", name: "some genre" }, }), artist: anArtist({ name: "great artist", id: uuid() }), - coverArt: {system: "subsonic", resource: "887766"}, + coverArt: { system: "subsonic", resource: "887766" }, rating: { love: true, - stars: 5 - } + stars: 5, + }, }); expect(track(bonobUrl, someTrack)).toEqual({ @@ -380,7 +384,9 @@ describe("track", () => { albumId: `album:${someTrack.album.id}`, albumArtist: someTrack.artist.name, albumArtistId: `artist:${someTrack.artist.id}`, - albumArtURI: `http://localhost:4567/foo/art/${encodeURIComponent(formatForURL(someTrack.coverArt!))}/size/180?access-token=1234`, + albumArtURI: `http://localhost:4567/foo/art/${encodeURIComponent( + formatForURL(someTrack.coverArt!) + )}/size/180?access-token=1234`, artist: someTrack.artist.name, artistId: `artist:${someTrack.artist.id}`, duration: someTrack.duration, @@ -418,22 +424,24 @@ describe("track", () => { coverArt: { system: "subsonic", resource: "887766" }, rating: { love: true, - stars: 5 - } + stars: 5, + }, }); - + expect(track(bonobUrl, someTrack)).toEqual({ itemType: "track", id: `track:${someTrack.id}`, mimeType: "audio/flac", title: someTrack.name, - + trackMetadata: { album: someTrack.album.name, albumId: `album:${someTrack.album.id}`, albumArtist: someTrack.artist.name, albumArtistId: undefined, - albumArtURI: `http://localhost:4567/foo/art/${encodeURIComponent(formatForURL(someTrack.coverArt!))}/size/180?access-token=1234`, + albumArtURI: `http://localhost:4567/foo/art/${encodeURIComponent( + formatForURL(someTrack.coverArt!) + )}/size/180?access-token=1234`, artist: someTrack.artist.name, artistId: undefined, duration: someTrack.duration, @@ -449,7 +457,7 @@ describe("track", () => { }, ], }, - }); + }); }); }); }); @@ -513,18 +521,34 @@ describe("playlistAlbumArtURL", () => { describe("when the playlist has external ids", () => { it("should format the url with encrypted urn", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); - const externalArt1 = { system: "external", resource: "http://example.com/image1.jpg" }; - const externalArt2 = { system: "external", resource: "http://example.com/image2.jpg" }; + const externalArt1 = { + system: "external", + resource: "http://example.com/image1.jpg", + }; + const externalArt2 = { + system: "external", + resource: "http://example.com/image2.jpg", + }; const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: externalArt1, album: anAlbumSummary({id: "album1"}) }), - aTrack({ coverArt: externalArt2, album: anAlbumSummary({id: "album2"}) }), + aTrack({ + coverArt: externalArt1, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: externalArt2, + album: anAlbumSummary({ id: "album2" }), + }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(externalArt1))}&${encodeURIComponent(formatForURL(externalArt2))}/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent( + formatForURL(externalArt1) + )}&${encodeURIComponent( + formatForURL(externalArt2) + )}/size/180?search=yes` ); }); }); @@ -534,18 +558,41 @@ describe("playlistAlbumArtURL", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }), - aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album2" }) }), - aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album2" }) }), - aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album2" }) }), + aTrack({ + coverArt: undefined, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt1, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt2, + album: anAlbumSummary({ id: "album2" }), + }), + aTrack({ + coverArt: undefined, + album: anAlbumSummary({ id: "album2" }), + }), + aTrack({ + coverArt: coverArt3, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt4, + album: anAlbumSummary({ id: "album2" }), + }), + aTrack({ + coverArt: undefined, + album: anAlbumSummary({ id: "album2" }), + }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent( + formatForURL(coverArt1) + )}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes` ); }); }); @@ -555,15 +602,29 @@ describe("playlistAlbumArtURL", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }), - aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album2" }) }), + aTrack({ + coverArt: coverArt1, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt2, + album: anAlbumSummary({ id: "album2" }), + }), + aTrack({ + coverArt: coverArt3, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt4, + album: anAlbumSummary({ id: "album2" }), + }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent( + formatForURL(coverArt1) + )}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes` ); }); }); @@ -573,15 +634,31 @@ describe("playlistAlbumArtURL", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }), - aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }), - aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album3" }) }), + aTrack({ + coverArt: coverArt1, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt2, + album: anAlbumSummary({ id: "album2" }), + }), + aTrack({ + coverArt: coverArt3, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt4, + album: anAlbumSummary({ id: "album3" }), + }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent( + formatForURL(coverArt1) + )}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent( + formatForURL(coverArt4) + )}/size/180?search=yes` ); }); }); @@ -591,16 +668,35 @@ describe("playlistAlbumArtURL", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1"} ) }), - aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2"} ) }), - aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album3"} ) }), - aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album4"} ) }), - aTrack({ coverArt: coverArt5, album: anAlbumSummary({id: "album1"} ) }), + aTrack({ + coverArt: coverArt1, + album: anAlbumSummary({ id: "album1" }), + }), + aTrack({ + coverArt: coverArt2, + album: anAlbumSummary({ id: "album2" }), + }), + aTrack({ + coverArt: coverArt3, + album: anAlbumSummary({ id: "album3" }), + }), + aTrack({ + coverArt: coverArt4, + album: anAlbumSummary({ id: "album4" }), + }), + aTrack({ + coverArt: coverArt5, + album: anAlbumSummary({ id: "album1" }), + }), ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( - `http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(formatForURL(coverArt3))}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes` + `http://localhost:1234/context-path/art/${encodeURIComponent( + formatForURL(coverArt1) + )}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent( + formatForURL(coverArt3) + )}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes` ); }); }); @@ -610,21 +706,60 @@ describe("playlistAlbumArtURL", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ entries: [ - aTrack({ coverArt: { system: "subsonic", resource: "1" }, album: anAlbumSummary({ id:"1" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "2" }, album: anAlbumSummary({ id:"2" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "3" }, album: anAlbumSummary({ id:"3" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "4" }, album: anAlbumSummary({ id:"4" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "5" }, album: anAlbumSummary({ id:"5" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "6" }, album: anAlbumSummary({ id:"6" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "7" }, album: anAlbumSummary({ id:"7" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "8" }, album: anAlbumSummary({ id:"8" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "9" }, album: anAlbumSummary({ id:"9" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "10" }, album: anAlbumSummary({ id:"10" }) }), - aTrack({ coverArt: { system: "subsonic", resource: "11" }, album: anAlbumSummary({ id:"11" }) }), + aTrack({ + coverArt: { system: "subsonic", resource: "1" }, + album: anAlbumSummary({ id: "1" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "2" }, + album: anAlbumSummary({ id: "2" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "3" }, + album: anAlbumSummary({ id: "3" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "4" }, + album: anAlbumSummary({ id: "4" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "5" }, + album: anAlbumSummary({ id: "5" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "6" }, + album: anAlbumSummary({ id: "6" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "7" }, + album: anAlbumSummary({ id: "7" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "8" }, + album: anAlbumSummary({ id: "8" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "9" }, + album: anAlbumSummary({ id: "9" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "10" }, + album: anAlbumSummary({ id: "10" }), + }), + aTrack({ + coverArt: { system: "subsonic", resource: "11" }, + album: anAlbumSummary({ id: "11" }), + }), ], }); - const burns = range(1, 10).map(i => encodeURIComponent(formatForURL({ system: "subsonic", resource: `${i}` }))).join("&") + const burns = range(1, 10) + .map((i) => + encodeURIComponent( + formatForURL({ system: "subsonic", resource: `${i}` }) + ) + ) + .join("&"); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( `http://localhost:1234/context-path/art/${burns}/size/180?search=yes` ); @@ -640,28 +775,29 @@ describe("defaultAlbumArtURI", () => { describe("when there is an album coverArt", () => { describe("from subsonic", () => { it("should use it", () => { - const coverArt = { system: "subsonic", resource: "12345" } + const coverArt = { system: "subsonic", resource: "12345" }; expect( - defaultAlbumArtURI( - bonobUrl, - anAlbum({ coverArt }) - ).href() + defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt })).href() ).toEqual( - `http://bonob.example.com:8080/context/art/${encodeURIComponent(formatForURL(coverArt))}/size/180?search=yes` + `http://bonob.example.com:8080/context/art/${encodeURIComponent( + formatForURL(coverArt) + )}/size/180?search=yes` ); }); }); describe("that is external", () => { it("should use encrypt it", () => { - const coverArt = { system: "external", resource: "http://example.com/someimage.jpg" } + const coverArt = { + system: "external", + resource: "http://example.com/someimage.jpg", + }; expect( - defaultAlbumArtURI( - bonobUrl, - anAlbum({ coverArt }) - ).href() + defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt })).href() ).toEqual( - `http://bonob.example.com:8080/context/art/${encodeURIComponent(formatForURL(coverArt))}/size/180?search=yes` + `http://bonob.example.com:8080/context/art/${encodeURIComponent( + formatForURL(coverArt) + )}/size/180?search=yes` ); }); }); @@ -683,7 +819,7 @@ describe("defaultArtistArtURI", () => { it("should return an icon", () => { const bonobUrl = url("http://localhost:1234/something?s=123"); const artist = anArtist({ image: undefined }); - + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( `http://localhost:1234/something/icon/vinyl/size/legacy?s=123` ); @@ -693,11 +829,13 @@ describe("defaultArtistArtURI", () => { describe("when the resource is subsonic", () => { it("should use the resource", () => { const bonobUrl = url("http://localhost:1234/something?s=123"); - const image = { system:"subsonic", resource: "art:1234"}; + const image = { system: "subsonic", resource: "art:1234" }; const artist = anArtist({ image }); - + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( - `http://localhost:1234/something/art/${encodeURIComponent(formatForURL(image))}/size/180?s=123` + `http://localhost:1234/something/art/${encodeURIComponent( + formatForURL(image) + )}/size/180?s=123` ); }); }); @@ -705,11 +843,16 @@ describe("defaultArtistArtURI", () => { describe("when the resource is external", () => { it("should encrypt the resource", () => { const bonobUrl = url("http://localhost:1234/something?s=123"); - const image = { system:"external", resource: "http://example.com/something.jpg"}; + const image = { + system: "external", + resource: "http://example.com/something.jpg", + }; const artist = anArtist({ image }); - + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( - `http://localhost:1234/something/art/${encodeURIComponent(formatForURL(image))}/size/180?s=123` + `http://localhost:1234/something/art/${encodeURIComponent( + formatForURL(image) + )}/size/180?s=123` ); }); }); @@ -747,27 +890,32 @@ describe("wsdl api", () => { nowPlaying: jest.fn(), rate: jest.fn(), }; - const accessTokens = { + const apiTokens = { mint: jest.fn(), authTokenFor: jest.fn(), }; - const clock = { - now: jest.fn(), + + const smapiAuthTokens = { + issue: jest.fn(() => ({ token: `default-smapiToken-${uuid()}`, key: `default-smapiKey-${uuid()}` })), + verify: jest.fn, []>(() => E.right(`default-serviceToken-${uuid()}`)), }; + const clock = new FixedClock(); + const bonobUrlWithoutContextPath = url("http://localhost:222"); const bonobUrlWithContextPath = url("http://localhost:111/path/to/bonob"); [bonobUrlWithoutContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { describe(`bonob with url ${bonobUrl}`, () => { - const tokenSigner = jwtSigner(`smapi-test-secret-${uuid()}`); - const jwtSign = tokenSigner.sign; - - const authToken = `authToken-${uuid()}`; - const accessToken = `accessToken-${uuid()}`; + const serviceToken = `serviceToken-${uuid()}`; + const apiToken = `apiToken-${uuid()}`; + const smapiAuthToken: SmapiToken = { + token: `smapiAuthToken.token-${uuid()}`, + key: `smapiAuthToken.key-${uuid()}` + }; const bonobUrlWithAccessToken = bonobUrl.append({ - searchParams: { bat: accessToken }, + searchParams: { bat: apiToken }, }); const service = bonobService("test-api", 133, bonobUrl, "AppLink"); @@ -778,9 +926,9 @@ describe("wsdl api", () => { musicService as unknown as MusicService, { linkCodes: () => linkCodes as unknown as LinkCodes, - accessTokens: () => accessTokens as unknown as AccessTokens, + apiTokens: () => apiTokens as unknown as APITokens, clock, - tokenSigner + smapiAuthTokens: smapiAuthTokens as unknown as SmapiAuthTokens, } ); @@ -789,6 +937,16 @@ describe("wsdl api", () => { jest.resetAllMocks(); }); + function setupAuthenticatedRequest(ws: Client) { + musicService.login.mockResolvedValue(musicLibrary); + smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); + apiTokens.mint.mockReturnValue(apiToken); + ws.addSoapHeader({ + credentials: someCredentials(smapiAuthToken), + }); + return ws; + } + describe("soap api", () => { describe("getAppLink", () => { it("should do something", async () => { @@ -828,11 +986,12 @@ describe("wsdl api", () => { it("should return a device auth token", async () => { const linkCode = uuid(); const association = { - authToken: "authToken", + serviceToken: "serviceToken", userId: "uid", nickname: "nick", }; linkCodes.associationFor.mockReturnValue(association); + smapiAuthTokens.issue.mockReturnValue(smapiAuthToken); const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, @@ -843,8 +1002,8 @@ describe("wsdl api", () => { expect(result[0]).toEqual({ getDeviceAuthTokenResult: { - authToken: jwtSign(association.authToken), - privateKey: "", + authToken: smapiAuthToken.token, + privateKey: smapiAuthToken.key, userInfo: { nickname: association.nickname, userIdHashCode: crypto @@ -891,7 +1050,7 @@ describe("wsdl api", () => { describe("getLastUpdate", () => { it("should return a result with some timestamps", async () => { const now = dayjs(); - clock.now.mockReturnValue(now); + clock.time = now; const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, @@ -911,21 +1070,102 @@ describe("wsdl api", () => { }); }); + describe("refreshAuthToken", () => { + describe("when no credentials are provided", () => { + itShouldReturnALoginUnsupported((ws) => + ws.refreshAuthTokenAsync({}) + ); + }); + + describe("when token has expired", () => { + it("should return a refreshed auth token", async () => { + const oneDayAgo = clock.time.subtract(1, "d"); + const newSmapiAuthToken = { token: `newToken-${uuid()}`, key: `newKey-${uuid()}` }; + + smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken, oneDayAgo.unix()))); + smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ + credentials: someCredentials(smapiAuthToken), + }); + + const result = await ws.refreshAuthTokenAsync({}); + + expect(result[0]).toEqual({ + refreshAuthTokenResult: { + authToken: newSmapiAuthToken.token, + privateKey: newSmapiAuthToken.key, + }, + }); + }); + }); + + describe("when the token fails to verify", () => { + it("should fail with a sampi fault", async () => { + smapiAuthTokens.verify.mockReturnValue(E.left(new InvalidTokenError("Invalid token"))); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ + credentials: someCredentials(smapiAuthToken), + }); + + await ws.refreshAuthTokenAsync({}) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnauthorized", + faultstring: "Failed to authenticate, try Re-Authorising your account in the sonos app", }); + }); + }); + }); + + describe("when existing auth token has not expired", () => { + it("should return a refreshed auth token", async () => { + const newSmapiAuthToken = { token: `newToken-${uuid()}`, key: `newKey-${uuid()}` }; + smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); + smapiAuthTokens.issue.mockReturnValue(newSmapiAuthToken); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ + credentials: someCredentials(smapiAuthToken), + }); + + const result = await ws.refreshAuthTokenAsync({}); + + expect(result[0]).toEqual({ + refreshAuthTokenResult: { + authToken: newSmapiAuthToken.token, + privateKey: newSmapiAuthToken.key + }, + }); + }); + }); + }); + describe("search", () => { - itShouldHandleInvalidCredentials((ws) => ws.getMetadataAsync({ id: "search", index: 0, count: 0 })); + itShouldHandleInvalidCredentials((ws) => + ws.getMetadataAsync({ id: "search", index: 0, count: 0 }) + ); describe("when valid credentials are provided", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); + setupAuthenticatedRequest(ws); }); describe("searching for albums", () => { @@ -1023,13 +1263,15 @@ describe("wsdl api", () => { }); }); - async function itShouldReturnALoginUnsupported(action: (ws: Client) => Promise) { + async function itShouldReturnALoginUnsupported( + action: (ws: Client) => Promise + ) { it("should return a fault of LoginUnsupported", async () => { const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - + await action(ws) .then(() => fail("shouldnt get here")) .catch((e: any) => { @@ -1038,59 +1280,105 @@ describe("wsdl api", () => { faultstring: "Missing credentials...", }); }); - }) + }); } - async function itShouldReturnAFaultOfLoginUnauthorized(credentials: Credentials, action: (ws: Client) => Promise) { + async function itShouldReturnAFaultOfLoginUnauthorized( + verifyResponse: E.Either, + action: (ws: Client) => Promise + ) { it("should return a fault of LoginUnauthorized", async () => { + smapiAuthTokens.verify.mockReturnValue(verifyResponse); musicService.login.mockRejectedValue("fail!"); const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); + ws.addSoapHeader({ credentials: someCredentials({ token: 'tokenThatFails', key: `keyThatFails` }) }); - ws.addSoapHeader({ - credentials, - }); await action(ws) .then(() => fail("shouldnt get here")) .catch((e: any) => { expect(e.root.Envelope.Body.Fault).toEqual({ faultcode: "Client.LoginUnauthorized", - faultstring: "Failed to authenticate, try Reauthorising your account in the sonos app", + faultstring: + "Failed to authenticate, try Re-Authorising your account in the sonos app", }); }); }); } - function itShouldHandleInvalidCredentials(action: (ws: Client) => Promise) { - describe("when invalid credentials are provided", () => { + function itShouldHandleInvalidCredentials( + action: (ws: Client) => Promise + ) { + describe("when no credentials are provided", () => { itShouldReturnALoginUnsupported(action); }); - describe("when invalid credentials are provided", () => { - itShouldReturnAFaultOfLoginUnauthorized(someCredentials(jwtSign("someAuthToken")), action); + + describe("when the token fails to verify", () => { + itShouldReturnAFaultOfLoginUnauthorized( + E.left(new InvalidTokenError("Token Invalid")), + action + ); }); - describe("when invalid jwt is provided", () => { - itShouldReturnAFaultOfLoginUnauthorized(someCredentials("not a jwt token"), action); + + describe("when token has expired", () => { + it("should return a fault of Client.TokenRefreshRequired with a refreshAuthTokenResult", async () => { + const expiry = dayjs().subtract(1, "d"); + const newToken = { + token: `newToken-${uuid()}`, + key: `newKey-${uuid()}` + }; + + smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken, expiry.unix()))) + smapiAuthTokens.issue.mockReturnValue(newToken) + musicService.login.mockRejectedValue( + "fail, should not call login!" + ); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ + credentials: someCredentials(smapiAuthToken), + }); + await action(ws) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.TokenRefreshRequired", + faultstring: "Token has expired", + detail: { + refreshAuthTokenResult: { + authToken: newToken.token, + privateKey: newToken.key, + }, + }, + }); + }); + + expect(smapiAuthTokens.verify).toHaveBeenCalledWith(smapiAuthToken); + expect(smapiAuthTokens.issue).toHaveBeenCalledWith(serviceToken); + }); }); } describe("getMetadata", () => { - itShouldHandleInvalidCredentials((ws) => ws.getMetadataAsync({ id: "root", index: 0, count: 0 })) + itShouldHandleInvalidCredentials((ws) => + ws.getMetadataAsync({ id: "root", index: 0, count: 0 }) + ); describe("when valid credentials are provided", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); + setupAuthenticatedRequest(ws); }); describe("asking for the root container", () => { @@ -1152,10 +1440,7 @@ describe("wsdl api", () => { { id: "recentlyAdded", title: "Recently added", - albumArtURI: iconArtURI( - bonobUrl, - "recentlyAdded" - ).href(), + albumArtURI: iconArtURI(bonobUrl, "recentlyAdded").href(), itemType: "albumList", }, { @@ -1170,10 +1455,7 @@ describe("wsdl api", () => { { id: "mostPlayed", title: "Most played", - albumArtURI: iconArtURI( - bonobUrl, - "mostPlayed" - ).href(), + albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(), itemType: "albumList", }, ]; @@ -1246,10 +1528,7 @@ describe("wsdl api", () => { { id: "recentlyAdded", title: "Onlangs toegevoegd", - albumArtURI: iconArtURI( - bonobUrl, - "recentlyAdded" - ).href(), + albumArtURI: iconArtURI(bonobUrl, "recentlyAdded").href(), itemType: "albumList", }, { @@ -1264,10 +1543,7 @@ describe("wsdl api", () => { { id: "mostPlayed", title: "Meest afgespeeld", - albumArtURI: iconArtURI( - bonobUrl, - "mostPlayed" - ).href(), + albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(), itemType: "albumList", }, ]; @@ -1484,7 +1760,7 @@ describe("wsdl api", () => { expect(musicLibrary.artist).toHaveBeenCalledWith( artistWithManyAlbums.id ); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); @@ -1520,7 +1796,7 @@ describe("wsdl api", () => { expect(musicLibrary.artist).toHaveBeenCalledWith( artistWithManyAlbums.id ); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); }); @@ -1570,7 +1846,7 @@ describe("wsdl api", () => { _index: index, _count: count, }); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); @@ -1615,7 +1891,7 @@ describe("wsdl api", () => { _index: index, _count: count, }); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); }); @@ -1673,7 +1949,7 @@ describe("wsdl api", () => { }) ); expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); @@ -1703,7 +1979,7 @@ describe("wsdl api", () => { }) ); expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); }); @@ -1728,7 +2004,7 @@ describe("wsdl api", () => { }) ); expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); @@ -1766,7 +2042,7 @@ describe("wsdl api", () => { }) ); expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); }); }); }); @@ -2403,20 +2679,19 @@ describe("wsdl api", () => { }); describe("getExtendedMetadata", () => { - itShouldHandleInvalidCredentials((ws) => ws.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 })) + itShouldHandleInvalidCredentials((ws) => + ws.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 }) + ); describe("when valid credentials are provided", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); + setupAuthenticatedRequest(ws); }); describe("asking for an artist", () => { @@ -2628,7 +2903,12 @@ describe("wsdl api", () => { trackNumber: track.number, }, dynamic: { - property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], + property: [ + { + name: "rating", + value: `${ratingAsInt(track.rating)}`, + }, + ], }, }, }, @@ -2671,7 +2951,12 @@ describe("wsdl api", () => { trackNumber: track.number, }, dynamic: { - property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], + property: [ + { + name: "rating", + value: `${ratingAsInt(track.rating)}`, + }, + ], }, }, }, @@ -2719,20 +3004,19 @@ describe("wsdl api", () => { }); describe("getMediaURI", () => { - itShouldHandleInvalidCredentials((ws) => ws.getMediaURIAsync({ id: "track:123" })) + itShouldHandleInvalidCredentials((ws) => + ws.getMediaURIAsync({ id: "track:123" }) + ); describe("when valid credentials are provided", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); + setupAuthenticatedRequest(ws); }); describe("asking for a URI to stream a track", () => { @@ -2747,20 +3031,28 @@ describe("wsdl api", () => { getMediaURIResult: bonobUrl .append({ pathname: `/stream/track/${trackId}`, - searchParams: { bat: accessToken }, }) .href(), + httpHeaders: { + httpHeader: [ + { + header: "Authorization", + value: `Bearer ${smapiTokenAsString(smapiAuthToken)}`, + }, + ], + }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); }); }); }); }); describe("getMediaMetadata", () => { - itShouldHandleInvalidCredentials((ws) => ws.getMediaMetadataAsync({ id: "track:123" })) + itShouldHandleInvalidCredentials((ws) => + ws.getMediaMetadataAsync({ id: "track:123" }) + ); describe("when valid credentials are provided", () => { let ws: Client; @@ -2768,15 +3060,12 @@ describe("wsdl api", () => { const someTrack = aTrack(); beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - musicLibrary.track.mockResolvedValue(someTrack); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); + setupAuthenticatedRequest(ws); + musicLibrary.track.mockResolvedValue(someTrack); }); describe("asking for media metadata for a track", () => { @@ -2788,13 +3077,13 @@ describe("wsdl api", () => { expect(root[0]).toEqual({ getMediaMetadataResult: track( bonobUrl.with({ - searchParams: { bat: accessToken }, + searchParams: { bat: apiToken }, }), someTrack ), }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.track).toHaveBeenCalledWith(someTrack.id); }); }); @@ -2805,70 +3094,72 @@ describe("wsdl api", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); }); - itShouldHandleInvalidCredentials((ws) => ws.createContainerAsync({ title: "foobar" })) + itShouldHandleInvalidCredentials((ws) => + ws.createContainerAsync({ title: "foobar" }) + ); describe("when valid credentials are provided", () => { + beforeEach(() => { + setupAuthenticatedRequest(ws); + }); + describe("with only a title", () => { const title = "aNewPlaylist"; const idOfNewPlaylist = uuid(); - + it("should create a playlist", async () => { musicLibrary.createPlaylist.mockResolvedValue({ id: idOfNewPlaylist, name: title, }); - + const result = await ws.createContainerAsync({ title, }); - + expect(result[0]).toEqual({ createContainerResult: { id: `playlist:${idOfNewPlaylist}`, updateId: null, }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); }); }); - + describe("with a title and a seed track", () => { const title = "aNewPlaylist2"; const trackId = "track123"; const idOfNewPlaylist = "playlistId"; - + it("should create a playlist with the track", async () => { musicLibrary.createPlaylist.mockResolvedValue({ id: idOfNewPlaylist, name: title, }); musicLibrary.addToPlaylist.mockResolvedValue(true); - + const result = await ws.createContainerAsync({ title, seedId: `track:${trackId}`, }); - + expect(result[0]).toEqual({ createContainerResult: { id: `playlist:${idOfNewPlaylist}`, updateId: null, }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( idOfNewPlaylist, @@ -2885,29 +3176,31 @@ describe("wsdl api", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); }); - itShouldHandleInvalidCredentials((ws) => ws.deleteContainerAsync({ id: "foobar" })) + itShouldHandleInvalidCredentials((ws) => + ws.deleteContainerAsync({ id: "foobar" }) + ); describe("when valid credentials are provided", () => { + beforeEach(() => { + setupAuthenticatedRequest(ws); + }); + it("should delete the playlist", async () => { musicLibrary.deletePlaylist.mockResolvedValue(true); - + const result = await ws.deleteContainerAsync({ id, }); - + expect(result[0]).toEqual({ deleteContainerResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.deletePlaylist).toHaveBeenCalledWith(id); }); }); @@ -2920,32 +3213,34 @@ describe("wsdl api", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); }); - itShouldHandleInvalidCredentials((ws) => ws.addToContainerAsync({ id: "foobar", parentId: "parentId" })) + itShouldHandleInvalidCredentials((ws) => + ws.addToContainerAsync({ id: "foobar", parentId: "parentId" }) + ); describe("when valid credentials are provided", () => { + beforeEach(() => { + setupAuthenticatedRequest(ws); + }); + it("should add the item to the playlist", async () => { musicLibrary.addToPlaylist.mockResolvedValue(true); - + const result = await ws.addToContainerAsync({ id: `track:${trackId}`, parentId: `parent:${playlistId}`, }); - + expect(result[0]).toEqual({ addToContainerResult: { updateId: null }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( playlistId, trackId @@ -2958,38 +3253,40 @@ describe("wsdl api", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); }); - itShouldHandleInvalidCredentials((ws) => ws.removeFromContainerAsync({ - id: `playlist:123`, - indices: `1,6,9`, - })); + itShouldHandleInvalidCredentials((ws) => + ws.removeFromContainerAsync({ + id: `playlist:123`, + indices: `1,6,9`, + }) + ); describe("when valid credentials are provided", () => { + beforeEach(() => { + setupAuthenticatedRequest(ws); + }); + describe("removing tracks from a playlist", () => { const playlistId = "parent123"; - + it("should remove the track from playlist", async () => { musicLibrary.removeFromPlaylist.mockResolvedValue(true); - + const result = await ws.removeFromContainerAsync({ id: `playlist:${playlistId}`, indices: `1,6,9`, }); - + expect(result[0]).toEqual({ removeFromContainerResult: { updateId: null }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.removeFromPlaylist).toHaveBeenCalledWith( playlistId, [1, 6, 9] @@ -3003,7 +3300,7 @@ describe("wsdl api", () => { const playlist3 = aPlaylist({ id: "p3" }); const playlist4 = aPlaylist({ id: "p4" }); const playlist5 = aPlaylist({ id: "p5" }); - + it("should delete the playlist", async () => { musicLibrary.playlists.mockResolvedValue([ playlist1, @@ -3013,17 +3310,17 @@ describe("wsdl api", () => { playlist5, ]); musicLibrary.deletePlaylist.mockResolvedValue(true); - + const result = await ws.removeFromContainerAsync({ id: `playlists`, indices: `0,2,4`, }); - + expect(result[0]).toEqual({ removeFromContainerResult: { updateId: null }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.deletePlaylist).toHaveBeenCalledTimes(3); expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( 1, @@ -3046,61 +3343,69 @@ describe("wsdl api", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); }); - itShouldHandleInvalidCredentials((ws) => ws.rateItemAsync({ - id: `track:123`, - rating: 4, - })); + itShouldHandleInvalidCredentials((ws) => + ws.rateItemAsync({ + id: `track:123`, + rating: 4, + }) + ); describe("when valid credentials are provided", () => { + beforeEach(() => { + setupAuthenticatedRequest(ws); + }); + describe("rating a track with a positive rating value", () => { const trackId = "123"; const ratingIntValue = 31; - + it("should give the track a love", async () => { musicLibrary.rate.mockResolvedValue(true); - + const result = await ws.rateItemAsync({ id: `track:${trackId}`, rating: ratingIntValue, }); - + expect(result[0]).toEqual({ rateItemResult: { shouldSkip: false }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.rate).toHaveBeenCalledWith(trackId, ratingFromInt(ratingIntValue)); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.rate).toHaveBeenCalledWith( + trackId, + ratingFromInt(ratingIntValue) + ); }); }); - + describe("rating a track with a negative rating value", () => { const trackId = "123"; const ratingIntValue = -20; - + it("should give the track a love", async () => { musicLibrary.rate.mockResolvedValue(true); - + const result = await ws.rateItemAsync({ id: `track:${trackId}`, rating: ratingIntValue, }); - + expect(result[0]).toEqual({ rateItemResult: { shouldSkip: false }, }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.rate).toHaveBeenCalledWith(trackId, ratingFromInt(Math.abs(ratingIntValue))); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.rate).toHaveBeenCalledWith( + trackId, + ratingFromInt(Math.abs(ratingIntValue)) + ); }); }); }); @@ -3110,25 +3415,27 @@ describe("wsdl api", () => { let ws: Client; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary); - accessTokens.mint.mockReturnValue(accessToken); - ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials(jwtSign(authToken)) }); }); - itShouldHandleInvalidCredentials((ws) => ws.setPlayedSecondsAsync({ - id: `track:123`, - seconds: `33`, - })); + itShouldHandleInvalidCredentials((ws) => + ws.setPlayedSecondsAsync({ + id: `track:123`, + seconds: `33`, + }) + ); describe("when valid credentials are provided", () => { + beforeEach(() => { + setupAuthenticatedRequest(ws); + }); + describe("when id is for a track", () => { const trackId = "123456"; - + function itShouldScroble({ trackId, secondsPlayed, @@ -3138,20 +3445,20 @@ describe("wsdl api", () => { }) { it("should scrobble", async () => { musicLibrary.scrobble.mockResolvedValue(true); - + const result = await ws.setPlayedSecondsAsync({ id: `track:${trackId}`, seconds: `${secondsPlayed}`, }); - + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.track).toHaveBeenCalledWith(trackId); expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); }); } - + function itShouldNotScroble({ trackId, secondsPlayed, @@ -3159,95 +3466,95 @@ describe("wsdl api", () => { trackId: string; secondsPlayed: number; }) { - it("should scrobble", async () => { + it("should not scrobble", async () => { const result = await ws.setPlayedSecondsAsync({ id: `track:${trackId}`, seconds: `${secondsPlayed}`, }); - + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.track).toHaveBeenCalledWith(trackId); expect(musicLibrary.scrobble).not.toHaveBeenCalled(); }); } - + describe("when the track length is 30 seconds", () => { beforeEach(() => { musicLibrary.track.mockResolvedValue( aTrack({ id: trackId, duration: 30 }) ); }); - + describe("when the played length is 30 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 30 }); }); - + describe("when the played length is > 30 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 90 }); }); - + describe("when the played length is < 30 seconds", () => { itShouldNotScroble({ trackId, secondsPlayed: 29 }); }); }); - + describe("when the track length is > 30 seconds", () => { beforeEach(() => { musicLibrary.track.mockResolvedValue( aTrack({ id: trackId, duration: 31 }) ); }); - + describe("when the played length is 30 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 30 }); }); - + describe("when the played length is > 30 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 90 }); }); - + describe("when the played length is < 30 seconds", () => { itShouldNotScroble({ trackId, secondsPlayed: 29 }); }); }); - + describe("when the track length is 29 seconds", () => { beforeEach(() => { musicLibrary.track.mockResolvedValue( aTrack({ id: trackId, duration: 29 }) ); }); - + describe("when the played length is 29 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 30 }); }); - + describe("when the played length is > 29 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 30 }); }); - + describe("when the played length is 10 seconds", () => { itShouldScroble({ trackId, secondsPlayed: 10 }); }); - + describe("when the played length is < 10 seconds", () => { itShouldNotScroble({ trackId, secondsPlayed: 9 }); }); }); }); - + describe("when the id is for something that isnt a track", () => { it("should not scrobble", async () => { const result = await ws.setPlayedSecondsAsync({ id: `album:666`, seconds: "100", }); - + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.scrobble).not.toHaveBeenCalled(); }); }); diff --git a/tests/smapi_auth.test.ts b/tests/smapi_auth.test.ts new file mode 100644 index 0000000..f824344 --- /dev/null +++ b/tests/smapi_auth.test.ts @@ -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() + ) + ) + ); + }); + }); + }); +}); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 2bf0c0c..cb1ac38 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -725,7 +725,7 @@ describe("Subsonic", () => { password, })) as AuthSuccess; - expect(token.authToken).toBeDefined(); + expect(token.serviceToken).toBeDefined(); expect(token.nickname).toEqual(username); expect(token.userId).toEqual(username); @@ -763,7 +763,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.genres()); expect(result).toEqual([]); @@ -793,7 +793,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.genres()); expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); @@ -826,7 +826,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.genres()); expect(result).toEqual([ @@ -884,7 +884,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -946,7 +946,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1002,7 +1002,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1056,7 +1056,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1113,7 +1113,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1167,7 +1167,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1222,7 +1222,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1278,7 +1278,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1332,7 +1332,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1384,7 +1384,7 @@ describe("Subsonic", () => { const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.artist(artist.id!)); expect(result).toEqual({ @@ -1449,7 +1449,7 @@ describe("Subsonic", () => { const artists = await navidrome .generateToken({ username, password }) .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 })); expect(artists).toEqual({ @@ -1478,7 +1478,7 @@ describe("Subsonic", () => { const artists = await navidrome .generateToken({ username, password }) .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 })); expect(artists).toEqual({ @@ -1519,7 +1519,7 @@ describe("Subsonic", () => { const artists = await navidrome .generateToken({ username, password }) .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 })); const expectedResults = [{ @@ -1561,7 +1561,7 @@ describe("Subsonic", () => { const artists = await navidrome .generateToken({ username, password }) .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 })); const expectedResults = [artist1, artist2, artist3, artist4].map( @@ -1597,7 +1597,7 @@ describe("Subsonic", () => { const artists = await navidrome .generateToken({ username, password }) .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 })); const expectedResults = [artist2, artist3].map((it) => ({ @@ -1659,7 +1659,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -1714,7 +1714,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -1768,7 +1768,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -1813,7 +1813,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -1858,7 +1858,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -1912,7 +1912,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -1965,7 +1965,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2033,7 +2033,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2087,7 +2087,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2163,7 +2163,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2223,7 +2223,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2282,7 +2282,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2351,7 +2351,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2418,7 +2418,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2483,7 +2483,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.albums(q)); expect(result).toEqual({ @@ -2541,7 +2541,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.album(album.id)); expect(result).toEqual(album); @@ -2622,7 +2622,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.tracks(album.id)); expect(result).toEqual([track1, track2, track3, track4]); @@ -2672,7 +2672,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.tracks(album.id)); expect(result).toEqual([track]); @@ -2710,7 +2710,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.tracks(album.id)); expect(result).toEqual([]); @@ -2761,7 +2761,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.track(track.id)); expect(result).toEqual({ @@ -2811,7 +2811,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.track(track.id)); expect(result).toEqual({ @@ -2886,7 +2886,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.stream({ trackId, range: undefined })); expect(result.headers).toEqual({ @@ -2928,7 +2928,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.stream({ trackId, range: undefined })); expect(result.headers).toEqual({ @@ -2972,7 +2972,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.stream({ trackId, range: undefined })); expect(result.headers).toEqual({ @@ -3021,7 +3021,7 @@ describe("Subsonic", () => { const musicLibrary = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)); + .then((it) => navidrome.login(it.serviceToken)); return expect( musicLibrary.stream({ trackId, range: undefined }) @@ -3046,7 +3046,7 @@ describe("Subsonic", () => { const musicLibrary = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)); + .then((it) => navidrome.login(it.serviceToken)); return expect( musicLibrary.stream({ trackId, range: undefined }) @@ -3087,7 +3087,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.stream({ trackId, range })); expect(result.headers).toEqual({ @@ -3140,7 +3140,7 @@ describe("Subsonic", () => { await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.stream({ trackId, range: undefined })); expect(streamClientApplication).toHaveBeenCalledWith(track); @@ -3185,7 +3185,7 @@ describe("Subsonic", () => { await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.stream({ trackId, range })); expect(streamClientApplication).toHaveBeenCalledWith(track); @@ -3227,7 +3227,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(coverArtURN)); expect(result).toEqual({ @@ -3266,7 +3266,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(coverArtURN, size)); expect(result).toEqual({ @@ -3297,7 +3297,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .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)); expect(result).toBeUndefined(); @@ -3316,7 +3316,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(covertArtURN, 190)); expect(result).toBeUndefined(); @@ -3343,7 +3343,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(covertArtURN)); expect(result).toEqual({ @@ -3376,7 +3376,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(covertArtURN)); expect(result).toBeUndefined(); @@ -3406,7 +3406,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(covertArtURN, size)); expect(result).toEqual({ @@ -3440,7 +3440,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.coverArt(covertArtURN, size)); expect(result).toBeUndefined(); @@ -3457,7 +3457,7 @@ describe("Subsonic", () => { navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.rate(trackId, rating)); const artist = anArtist(); @@ -3705,7 +3705,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.scrobble(id)); expect(result).toEqual(true); @@ -3737,7 +3737,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.scrobble(id)); expect(result).toEqual(false); @@ -3766,7 +3766,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.nowPlaying(id)); expect(result).toEqual(true); @@ -3798,7 +3798,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.nowPlaying(id)); expect(result).toEqual(false); @@ -3829,7 +3829,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchArtists("foo")); expect(result).toEqual([artistToArtistSummary(artist1)]); @@ -3863,7 +3863,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchArtists("foo")); expect(result).toEqual([ @@ -3895,7 +3895,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchArtists("foo")); expect(result).toEqual([]); @@ -3934,7 +3934,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchAlbums("foo")); expect(result).toEqual([albumToAlbumSummary(album)]); @@ -3984,7 +3984,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchAlbums("moo")); expect(result).toEqual([ @@ -4016,7 +4016,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchAlbums("foo")); expect(result).toEqual([]); @@ -4065,7 +4065,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchTracks("foo")); expect(result).toEqual([track]); @@ -4140,7 +4140,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchTracks("moo")); expect(result).toEqual([track1, track2]); @@ -4169,7 +4169,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.searchTracks("foo")); expect(result).toEqual([]); @@ -4203,7 +4203,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.playlists()); expect(result).toEqual([playlist]); @@ -4231,7 +4231,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.playlists()); expect(result).toEqual(playlists); @@ -4254,7 +4254,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.playlists()); expect(result).toEqual([]); @@ -4282,7 +4282,7 @@ describe("Subsonic", () => { navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.playlist(id)) ).rejects.toEqual("Subsonic error:data not found"); }); @@ -4338,7 +4338,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.playlist(id)); expect(result).toEqual({ @@ -4375,7 +4375,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.playlist(playlist.id)); expect(result).toEqual(playlist); @@ -4406,7 +4406,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.createPlaylist(name)); expect(result).toEqual({ id, name }); @@ -4433,7 +4433,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.deletePlaylist(id)); expect(result).toEqual(true); @@ -4461,7 +4461,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.addToPlaylist(playlistId, trackId)); expect(result).toEqual(true); @@ -4489,7 +4489,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.removeFromPlaylist(playlistId, indicies)); expect(result).toEqual(true); @@ -4539,7 +4539,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.similarSongs(id)); expect(result).toEqual([track1]); @@ -4612,7 +4612,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.similarSongs(id)); expect(result).toEqual([track1, track2, track3]); @@ -4642,7 +4642,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.similarSongs(id)); expect(result).toEqual([]); @@ -4673,7 +4673,7 @@ describe("Subsonic", () => { navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.similarSongs(id)) ).rejects.toEqual("Subsonic error:data not found"); }); @@ -4715,7 +4715,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.topSongs(artistId)); expect(result).toEqual([track1]); @@ -4785,7 +4785,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.topSongs(artistId)); expect(result).toEqual([track1, track2, track3]); @@ -4827,7 +4827,7 @@ describe("Subsonic", () => { const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) + .then((it) => navidrome.login(it.serviceToken)) .then((it) => it.topSongs(artistId)); expect(result).toEqual([]); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index c2adfa7..f8941c4 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,23 +1,23 @@ { - "extends": "../tsconfig.json", - "compilerOptions": { - "target": "es2019", - "baseUrl": "./", - "module": "commonjs", - "experimentalDecorators": true, - "strictPropertyInitialization": false, - "isolatedModules": false, - "strict": true, - "noImplicitAny": false, - "typeRoots" : [ - "../typings", - "../node_modules/@types" - ] - }, - "exclude": [ - "../node_modules" - ], - "include": [ - "./**/*.ts" + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "es2019", + "baseUrl": "./", + "module": "commonjs", + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "isolatedModules": false, + "strict": true, + "noImplicitAny": false, + "typeRoots" : [ + "../typings", + "../node_modules/@types" ] - } \ No newline at end of file + }, + "exclude": [ + "../node_modules" + ], + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file