Skip to content

Commit 17f8be9

Browse files
authored
1 parent 88d9951 commit 17f8be9

File tree

10 files changed

+338
-53
lines changed

10 files changed

+338
-53
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
HOST=127.0.0.1

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ In the project directory, first run `yarn install` to set up dependencies, then
122122
**`yarn start`**
123123

124124
Runs the app in the development mode.\
125-
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
125+
Open [http://127.0.0.1:3000](http://127.0.0.1:3000) to view it in the browser.
126126

127127
The page will reload if you make edits.\
128128
You will also see any lint errors in the console.
@@ -184,7 +184,7 @@ To build and run Exportify with docker, run:
184184

185185
**`docker run -p 3000:3000 exportify`**
186186

187-
And then open [http://localhost:3000](http://localhost:3000) to view it in the browser.
187+
And then open [http://127.0.0.1:3000](http://127.0.0.1:3000) to view it in the browser.
188188

189189
## Contributing
190190

src/App.test.tsx

Lines changed: 141 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import React from "react"
22
import i18n from "i18n/config"
3-
import { render, screen } from "@testing-library/react"
3+
import { render, screen, waitFor } from "@testing-library/react"
44
import userEvent from "@testing-library/user-event"
5+
import { setupServer } from "msw/node"
6+
import { rest } from "msw"
7+
import { handlers } from "./mocks/handlers"
58
import App from "./App"
9+
import * as auth from "./auth"
610

711
const { location } = window
812

13+
// Set up MSW server for mocking Spotify API and token exchange
14+
const server = setupServer(...handlers)
15+
916
beforeAll(() => {
17+
server.listen({ onUnhandledRequest: 'warn' })
1018
// @ts-ignore
1119
delete window.location
1220
})
1321

1422
afterAll(() => {
23+
server.close()
1524
window.location = location
1625
})
1726

@@ -22,6 +31,11 @@ beforeAll(() => {
2231

2332
beforeEach(() => {
2433
i18n.changeLanguage("en")
34+
server.resetHandlers()
35+
})
36+
37+
afterEach(() => {
38+
localStorage.clear()
2539
})
2640

2741
describe("i18n", () => {
@@ -54,18 +68,110 @@ describe("logging in", () => {
5468

5569
await userEvent.click(linkElement)
5670

57-
expect(window.location.href).toBe(
58-
"https://accounts.spotify.com/authorize?client_id=9950ac751e34487dbbe027c4fd7f8e99&redirect_uri=%2F%2F&scope=playlist-read-private%20playlist-read-collaborative%20user-library-read&response_type=token&show_dialog=false"
59-
)
71+
// Now uses PKCE flow with authorization code
72+
expect(window.location.href).toMatch(/^https:\/\/accounts\.spotify\.com\/authorize\?/)
73+
expect(window.location.href).toContain('response_type=code')
74+
expect(window.location.href).toContain('client_id=9950ac751e34487dbbe027c4fd7f8e99')
75+
expect(window.location.href).toContain('scope=playlist-read-private')
76+
expect(window.location.href).toContain('code_challenge_method=S256')
77+
expect(window.location.href).toContain('code_challenge=')
78+
expect(window.location.href).toContain('show_dialog=false')
6079
})
6180

62-
describe("post-login state", () => {
63-
beforeAll(() => {
81+
describe("OAuth callback with authorization code", () => {
82+
let setItemSpy: jest.SpyInstance
83+
let replaceStateSpy: jest.SpyInstance
84+
let tokenRequestBody: URLSearchParams | null = null
85+
86+
beforeEach(() => {
87+
tokenRequestBody = null
88+
89+
// Mock localStorage
90+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
91+
if (key === 'code_verifier') return 'TEST_CODE_VERIFIER'
92+
return null
93+
})
94+
setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
95+
replaceStateSpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {})
96+
97+
// Mock window.location with code parameter
6498
// @ts-ignore
65-
window.location = { hash: "#access_token=TEST_ACCESS_TOKEN" }
99+
window.location = {
100+
search: "?code=TEST_AUTH_CODE",
101+
pathname: "/exportify",
102+
protocol: "https:",
103+
host: "exportify.app"
104+
}
105+
106+
// Mock Spotify token endpoint and capture request
107+
server.use(
108+
rest.post('https://accounts.spotify.com/api/token', async (req, res, ctx) => {
109+
// Capture the request body for verification
110+
tokenRequestBody = new URLSearchParams(await req.text())
111+
112+
return res(ctx.json({
113+
access_token: 'EXCHANGED_ACCESS_TOKEN',
114+
token_type: 'Bearer',
115+
expires_in: 3600,
116+
refresh_token: 'REFRESH_TOKEN',
117+
scope: 'playlist-read-private playlist-read-collaborative user-library-read'
118+
}))
119+
})
120+
)
121+
})
122+
123+
afterEach(() => {
124+
jest.restoreAllMocks()
66125
})
67126

68-
test("renders playlist component on return from Spotify with auth token", () => {
127+
test("exchanges authorization code for access token with correct PKCE parameters", async () => {
128+
render(<App />)
129+
130+
// Wait for token exchange to complete
131+
await waitFor(() => {
132+
expect(setItemSpy).toHaveBeenCalledWith('access_token', 'EXCHANGED_ACCESS_TOKEN')
133+
})
134+
135+
// Verify the token endpoint was called with correct parameters
136+
expect(tokenRequestBody).not.toBeNull()
137+
expect(tokenRequestBody?.get('client_id')).toBe('9950ac751e34487dbbe027c4fd7f8e99')
138+
expect(tokenRequestBody?.get('grant_type')).toBe('authorization_code')
139+
expect(tokenRequestBody?.get('code')).toBe('TEST_AUTH_CODE')
140+
expect(tokenRequestBody?.get('code_verifier')).toBe('TEST_CODE_VERIFIER')
141+
expect(tokenRequestBody?.get('redirect_uri')).toBe('https://exportify.app/exportify')
142+
143+
// Verify code is removed from URL
144+
expect(replaceStateSpy).toHaveBeenCalledWith({}, expect.any(String), '/exportify')
145+
146+
// Verify playlist component is rendered
147+
expect(screen.getByTestId('playlistTableSpinner')).toBeInTheDocument()
148+
})
149+
})
150+
151+
describe("post-login state", () => {
152+
let getItemSpy: jest.SpyInstance
153+
154+
beforeEach(() => {
155+
// Mock localStorage for access token
156+
getItemSpy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
157+
if (key === 'access_token') return 'TEST_ACCESS_TOKEN'
158+
return null
159+
})
160+
161+
// @ts-ignore - No code parameter in URL
162+
window.location = {
163+
pathname: "/exportify",
164+
href: "https://www.example.com/exportify",
165+
search: "",
166+
hash: ""
167+
}
168+
})
169+
170+
afterEach(() => {
171+
getItemSpy.mockRestore()
172+
})
173+
174+
test("renders playlist component when access token exists in localStorage", () => {
69175
render(<App />)
70176

71177
expect(screen.getByTestId('playlistTableSpinner')).toBeInTheDocument()
@@ -74,22 +180,42 @@ describe("logging in", () => {
74180
})
75181

76182
describe("logging out", () => {
77-
beforeAll(() => {
78-
// @ts-ignore
79-
window.location = { hash: "#access_token=TEST_ACCESS_TOKEN", href: "https://www.example.com/#access_token=TEST_ACCESS_TOKEN" }
183+
let getItemSpy: jest.SpyInstance
184+
let logoutSpy: jest.SpyInstance
185+
186+
beforeEach(() => {
187+
// Mock localStorage with access token
188+
getItemSpy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
189+
if (key === 'access_token') return 'TEST_ACCESS_TOKEN'
190+
return null
191+
})
192+
193+
// Spy on logout function
194+
logoutSpy = jest.spyOn(auth, 'logout').mockImplementation(() => {})
195+
196+
// @ts-ignore - Simple window.location mock
197+
window.location = {
198+
pathname: "/exportify",
199+
href: "https://www.example.com/exportify",
200+
search: "",
201+
hash: ""
202+
}
203+
})
204+
205+
afterEach(() => {
206+
getItemSpy.mockRestore()
207+
logoutSpy.mockRestore()
80208
})
81209

82210
test("redirects user to login screen which will force a permission request", async () => {
83-
const { rerender } = render(<App />)
211+
render(<App />)
84212

85213
const changeUserElement = screen.getByTitle("Change user")
86214

87215
expect(changeUserElement).toBeInTheDocument()
88216

89217
await userEvent.click(changeUserElement)
90218

91-
expect(window.location.href).toBe("https://www.example.com/?change_user=true")
92-
93-
219+
expect(logoutSpy).toHaveBeenCalledWith(true)
94220
})
95221
})

src/App.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,56 @@
11
import './App.scss'
22
import "./icons"
33

4-
import React, { useState } from 'react'
4+
import React, { useEffect, useState, useRef } from 'react'
55
import { useTranslation, Translation } from "react-i18next"
66
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
77
import "url-search-params-polyfill"
88

99
import Login from 'components/Login'
1010
import PlaylistTable from "components/PlaylistTable"
11-
import { getQueryParam } from "helpers"
1211
import TopMenu from "components/TopMenu"
12+
import { loadAccessToken, exchangeCodeForToken } from "auth"
1313

1414
function App() {
1515
useTranslation()
1616
const [subtitle, setSubtitle] = useState(<Translation>{(t) => t("tagline")}</Translation>)
17+
const searchParams = new URLSearchParams(window.location.search)
18+
19+
const [accessToken, setAccessToken] = useState<string | null>(loadAccessToken())
20+
const hasProcessedCode = useRef(false)
1721

1822
let view
19-
let key = new URLSearchParams(window.location.hash.substring(1))
2023

2124
const onSetSubtitle = (subtitle: any) => {
2225
setSubtitle(subtitle)
2326
}
2427

25-
if (getQueryParam('spotify_error') !== '') {
28+
useEffect(() => {
29+
const code = searchParams.get("code")
30+
if (!code) { return }
31+
32+
// Prevent multiple executions in StrictMode or re-renders
33+
if (hasProcessedCode.current) {
34+
return
35+
}
36+
hasProcessedCode.current = true
37+
38+
exchangeCodeForToken(code).then((accessToken) => {
39+
setAccessToken(accessToken)
40+
41+
// Remove code from query string
42+
window.history.replaceState({}, document.title, window.location.pathname)
43+
})
44+
})
45+
46+
if (searchParams.get('spotify_error')) {
2647
view = <div id="spotifyErrorMessage" className="lead">
2748
<p><FontAwesomeIcon icon={['fas', 'bolt']} style={{ fontSize: "50px", marginBottom: "20px" }} /></p>
2849
<p>Oops, Exportify has encountered an unexpected error (5XX) while using the Spotify API. This kind of error is due to a problem on Spotify's side, and although it's rare, unfortunately all we can do is retry later.</p>
2950
<p style={{ marginTop: "50px" }}>Keep an eye on the <a target="_blank" rel="noreferrer" href="https://status.spotify.dev/">Spotify Web API Status page</a> to see if there are any known problems right now, and then <a rel="noreferrer" href="?">retry</a>.</p>
3051
</div>
31-
} else if (key.has('access_token')) {
32-
view = <PlaylistTable accessToken={key.get('access_token')!} onSetSubtitle={onSetSubtitle} />
52+
} else if (accessToken) {
53+
view = <PlaylistTable accessToken={accessToken!} onSetSubtitle={onSetSubtitle} />
3354
} else {
3455
view = <Login />
3556
}
@@ -38,7 +59,7 @@ function App() {
3859
<div className="App container">
3960
<header className="App-header">
4061
<div className="d-sm-none d-block mb-5" />
41-
<TopMenu loggedIn={key.has('access_token')} />
62+
<TopMenu loggedIn={!!accessToken} />
4263
<h1>
4364
<FontAwesomeIcon icon={['fab', 'spotify']} color="#84BD00" size="sm" /> <a href={process.env.PUBLIC_URL}>Exportify</a>
4465
</h1>

0 commit comments

Comments
 (0)