Skip to content

Commit 2d07a4b

Browse files
committed
Add ability to change retro password [#5]
1 parent 2219064 commit 2d07a4b

27 files changed

+716
-267
lines changed

backend/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"supertest": "7.x",
3636
"superwstest": "2.x",
3737
"typescript": "5.9.x",
38-
"web-listener": "0.17.1",
38+
"web-listener": "0.17.2",
3939
"ws": "8.x"
4040
}
4141
}

backend/src/api-tests/archives.test.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,6 @@ import { testConfig } from './testConfig';
55
import { testServerRunner } from './testServerRunner';
66
import { appFactory, type TestHooks } from '../app';
77

8-
function getRetroToken(
9-
{ retroAuthService }: TestHooks,
10-
retroId: string,
11-
scopes = {},
12-
): Promise<string | null> {
13-
return retroAuthService.grantToken(retroId, {
14-
read: true,
15-
readArchives: true,
16-
write: true,
17-
...scopes,
18-
});
19-
}
20-
218
describe('API retro archives', () => {
229
const PROPS = testServerRunner(async () => {
2310
const app = await appFactory(new TestLogger(), testConfig());
@@ -216,3 +203,17 @@ describe('API retro archives', () => {
216203
});
217204
});
218205
});
206+
207+
function getRetroToken(
208+
{ retroAuthService }: TestHooks,
209+
retroId: string,
210+
scopes = {},
211+
): Promise<string | null> {
212+
return retroAuthService.grantToken(retroId, {
213+
read: true,
214+
readArchives: true,
215+
write: true,
216+
manage: true,
217+
...scopes,
218+
});
219+
}

backend/src/api-tests/auth.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ describe('API auth', () => {
137137
read: true,
138138
write: true,
139139
readArchives: true,
140+
manage: true,
140141
});
141142

142143
await request(server)

backend/src/api-tests/export.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ function getRetroToken(
1414
read: true,
1515
readArchives: true,
1616
write: true,
17+
manage: true,
1718
...scopes,
1819
});
1920
}

backend/src/api-tests/retroSocket.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function getRetroToken(
1313
read: true,
1414
readArchives: true,
1515
write: true,
16+
manage: true,
1617
...scopes,
1718
});
1819
}

backend/src/api-tests/retros.test.ts

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,19 @@ import { testConfig } from './testConfig';
44
import { testServerRunner } from './testServerRunner';
55
import { appFactory, type TestHooks } from '../app';
66

7-
function getUserToken({ userAuthService }: TestHooks, userId: string): string {
8-
return userAuthService.grantToken({
9-
aud: 'user',
10-
iss: 'test',
11-
sub: userId,
12-
});
13-
}
14-
157
describe('API retros', () => {
168
const PROPS = testServerRunner(async () => {
179
const app = await appFactory(new TestLogger(), testConfig());
1810

1911
const hooks = app.testHooks;
2012

21-
await hooks.retroService.createRetro(
13+
const myRetroId = await hooks.retroService.createRetro(
2214
'nobody',
2315
'my-retro',
2416
'My Retro',
2517
'mood',
2618
);
19+
await hooks.retroAuthService.setPassword(myRetroId, 'password1');
2720

2821
await hooks.retroService.createRetro(
2922
'me',
@@ -151,6 +144,118 @@ describe('API retros', () => {
151144
.expect(401);
152145
});
153146
});
147+
148+
describe('PUT /api/retros/retro-id/password', () => {
149+
it('changes the retro password', async (props) => {
150+
const { server, hooks } = props.getTyped(PROPS);
151+
152+
const id = (await hooks.retroService.getRetroIdForSlug('my-retro'))!;
153+
const retroToken = await hooks.retroAuthService.grantForPassword(
154+
id,
155+
'password1',
156+
);
157+
if (!retroToken) {
158+
throw new Error('failed to get retro token');
159+
}
160+
161+
await request(server)
162+
.put(`/api/retros/${id}/password`)
163+
.send({ password: 'password2', evictUsers: false })
164+
.set('Authorization', `Bearer ${retroToken}`)
165+
.expect(200)
166+
.expect('Content-Type', /application\/json/);
167+
168+
// existing token is still valid
169+
expect(
170+
await hooks.retroAuthService.readAndVerifyToken(id, retroToken),
171+
).isTruthy();
172+
173+
// new token requests must use new password
174+
expect(
175+
await hooks.retroAuthService.grantForPassword(id, 'password1'),
176+
).isNull();
177+
178+
const retroToken2 = await hooks.retroAuthService.grantForPassword(
179+
id,
180+
'password2',
181+
);
182+
expect(retroToken2).isTruthy();
183+
expect(
184+
await hooks.retroAuthService.readAndVerifyToken(id, retroToken2!),
185+
).isTruthy();
186+
});
187+
188+
it('voids existing tokens if requested', async (props) => {
189+
const { server, hooks } = props.getTyped(PROPS);
190+
191+
const id = (await hooks.retroService.getRetroIdForSlug('my-retro'))!;
192+
const retroToken = await hooks.retroAuthService.grantForPassword(
193+
id,
194+
'password1',
195+
);
196+
if (!retroToken) {
197+
throw new Error('failed to get retro token');
198+
}
199+
200+
await request(server)
201+
.put(`/api/retros/${id}/password`)
202+
.send({ password: 'password2', evictUsers: true })
203+
.set('Authorization', `Bearer ${retroToken}`)
204+
.expect(200)
205+
.expect('Content-Type', /application\/json/);
206+
207+
// existing token is no longer valid
208+
expect(
209+
await hooks.retroAuthService.readAndVerifyToken(id, retroToken),
210+
).isNull();
211+
212+
// new tokens are valid
213+
const retroToken2 = await hooks.retroAuthService.grantForPassword(
214+
id,
215+
'password2',
216+
);
217+
expect(retroToken2).isTruthy();
218+
expect(
219+
await hooks.retroAuthService.readAndVerifyToken(id, retroToken2!),
220+
).isTruthy();
221+
});
222+
223+
it('responds HTTP Unauthorized if no credentials are given', async (props) => {
224+
const { server, hooks } = props.getTyped(PROPS);
225+
226+
const id = (await hooks.retroService.getRetroIdForSlug('my-retro'))!;
227+
await request(server)
228+
.put(`/api/retros/${id}/password`)
229+
.send({ password: 'password2', evictUsers: false })
230+
.expect(401);
231+
});
232+
233+
it('responds HTTP Unauthorized if credentials are incorrect', async (props) => {
234+
const { server, hooks } = props.getTyped(PROPS);
235+
236+
const id = (await hooks.retroService.getRetroIdForSlug('my-retro'))!;
237+
await request(server)
238+
.put(`/api/retros/${id}/password`)
239+
.send({ password: 'password2', evictUsers: false })
240+
.set('Authorization', 'Bearer Foo')
241+
.expect(401);
242+
});
243+
244+
it('responds HTTP Forbidden if scope is not "manage"', async (props) => {
245+
const { server, hooks } = props.getTyped(PROPS);
246+
247+
const id = (await hooks.retroService.getRetroIdForSlug('my-retro'))!;
248+
const retroToken = await getRetroToken(hooks, id, {
249+
manage: false,
250+
});
251+
252+
await request(server)
253+
.put(`/api/retros/${id}/password`)
254+
.send({ password: 'password2', evictUsers: false })
255+
.set('Authorization', `Bearer ${retroToken}`)
256+
.expect(403);
257+
});
258+
});
154259
});
155260

156261
describe('API retros with my retros disabled', () => {
@@ -201,3 +306,25 @@ describe('API retros with my retros disabled', () => {
201306
});
202307
});
203308
});
309+
310+
function getUserToken({ userAuthService }: TestHooks, userId: string): string {
311+
return userAuthService.grantToken({
312+
aud: 'user',
313+
iss: 'test',
314+
sub: userId,
315+
});
316+
}
317+
318+
function getRetroToken(
319+
{ retroAuthService }: TestHooks,
320+
retroId: string,
321+
scopes = {},
322+
): Promise<string | null> {
323+
return retroAuthService.grantToken(retroId, {
324+
read: true,
325+
readArchives: true,
326+
write: true,
327+
manage: true,
328+
...scopes,
329+
});
330+
}

backend/src/app.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const appFactory = async (
8585
): Promise<App> => {
8686
const db = await CollectionStorage.connect(config.db.url);
8787

88+
const passwordRequirements = { minLength: 8, maxLength: 512 };
8889
const hasher = new Hasher(config.password);
8990
const tokenManager = new TokenManager(config.token);
9091

@@ -136,7 +137,10 @@ export const appFactory = async (
136137
);
137138
app.mount('/api/diagnostics', new ApiDiagnosticsRouter(analyticsService));
138139
app.mount('/api/slugs', new ApiSlugsRouter(retroService));
139-
app.mount('/api/config', new ApiConfigRouter(config, auth.clientConfig));
140+
app.mount(
141+
'/api/config',
142+
new ApiConfigRouter(config, auth.clientConfig, passwordRequirements),
143+
);
140144
auth.addRoutes(app);
141145
app.mount(
142146
'/api/retros',
@@ -147,6 +151,7 @@ export const appFactory = async (
147151
retroArchiveService,
148152
analyticsService,
149153
config.permit.myRetros,
154+
passwordRequirements,
150155
),
151156
);
152157
app.mount(

backend/src/helpers/MultiMap.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export class MultiMap<K, V> {
2+
private readonly _data = new Map<K, Set<V>>();
3+
4+
add(k: K, v: V) {
5+
let l = this._data.get(k);
6+
if (!l) {
7+
l = new Set();
8+
this._data.set(k, l);
9+
}
10+
l.add(v);
11+
}
12+
13+
remove(k: K, v: V) {
14+
const l = this._data.get(k);
15+
if (l) {
16+
l.delete(v);
17+
if (!l.size) {
18+
this._data.delete(k);
19+
}
20+
}
21+
}
22+
23+
listAndPurge(k: K): Set<V> {
24+
const l = this._data.get(k) ?? new Set();
25+
this._data.delete(k);
26+
return l;
27+
}
28+
}

backend/src/routers/ApiConfigRouter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Router, sendJSON } from 'web-listener';
2-
import type { ClientConfig } from '../shared/api-entities';
2+
import type {
3+
ClientConfig,
4+
PasswordRequirements,
5+
} from '../shared/api-entities';
36

47
interface ServerConfig {
58
giphy: { apiKey: string };
@@ -9,12 +12,14 @@ export class ApiConfigRouter extends Router {
912
public constructor(
1013
serverConfig: ServerConfig,
1114
ssoClientConfig: ClientConfig['sso'],
15+
passwordRequirements: PasswordRequirements,
1216
) {
1317
super();
1418

1519
const clientConfig: ClientConfig = {
1620
sso: ssoClientConfig,
1721
giphy: serverConfig.giphy.apiKey !== '',
22+
passwordRequirements,
1823
};
1924

2025
this.get('/', (_, res) => sendJSON(res, clientConfig));

0 commit comments

Comments
 (0)