Skip to content

Commit bc8e2a2

Browse files
committed
Implementation for findByEmail using shared read model and member linking
1 parent eae52f2 commit bc8e2a2

File tree

8 files changed

+105
-82
lines changed

8 files changed

+105
-82
lines changed

src/authentication/send-log-in-link.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ export const sendLogInLink: SendLogInLink = (deps, conf) => emailAddress => {
5454
deps.logger.error('While looking for email %s we found multiple users!', emailAddress);
5555
return TE.left(failure('Multiple members associated with that email. Please contact an administrator.')());
5656
}
57-
const email = toEmail(emailAddress)(magicLink.create(conf)(members[0]));
57+
// Note that we intentionally use the stored email address rather than the one provided.
58+
// This prevents attacks where you specify an email address that somehow matches to an existing user but isn't
59+
// actually treated the same by the mailserver(s) so gets routed differently (to the attacker).
60+
const email = toEmail(members[0].emailAddress)(magicLink.create(conf)(members[0]));
5861
return pipe(
5962
deps.rateLimitSendingOfEmails(email),
6063
TE.chain(deps.sendEmail),

src/read-models/shared-state/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ReadonlyRecord } from 'fp-ts/lib/ReadonlyRecord';
3131
import { TrainingSheetId } from '../../types/training-sheet';
3232
import { EquipmentId } from '../../types/equipment-id';
3333
import { getTrainingSheetIdMapping } from './equipment/get';
34+
import { findByEmail } from './member/get';
3435

3536
export type SharedReadModel = {
3637
db: BetterSQLite3Database;
@@ -96,6 +97,7 @@ export const initSharedReadModel = (
9697
get: getMemberFull(readModelDb, linking),
9798
getAll: getAllMemberFull(readModelDb, linking),
9899
getAsActor: getMemberAsActorFull(readModelDb, linking),
100+
findByEmail: findByEmail(readModelDb, linking),
99101
},
100102
equipment: {
101103
get: getEquipmentFull(readModelDb, linking),

src/read-models/shared-state/member-linking.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { MemberNumber } from "../../types/member-number";
22

3+
// NOTE: MemberLinking is a slightly grim solution to the problem that initially it was assumed each 'person' had 1 member number but
4+
// actually users might have multiple.
35
export class MemberLinking {
46
// Stores the linking between member numbers.
57
// This is much more effiently stored directly rather than using sqllite as its

src/read-models/shared-state/member/get.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {pipe} from 'fp-ts/lib/function';
22
import {BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
3-
import {inArray, desc} from 'drizzle-orm';
3+
import {inArray, desc, eq} from 'drizzle-orm';
44
import * as O from 'fp-ts/Option';
55
import * as RA from 'fp-ts/ReadonlyArray';
66
import * as RAE from 'fp-ts/ReadonlyNonEmptyArray';
77
import {MemberCoreInfo} from '../return-types';
88
import {membersTable} from '../state';
99
import {MemberCoreInfoPreMerge, mergeMemberCore} from './merge';
1010
import {MemberLinking} from '../member-linking';
11+
import { EmailAddress } from '../../../types';
12+
import { normaliseEmailAddress } from '../normalise-email-address';
1113

1214
const getMergedMember =
1315
(db: BetterSQLite3Database) =>
@@ -52,3 +54,29 @@ export const getAllMemberCore = (
5254
linking: MemberLinking
5355
): ReadonlyArray<MemberCoreInfo> =>
5456
pipe(linking.all(), RA.filterMap(getMergedMemberSet(db)));
57+
58+
export const findByEmail = (
59+
db: BetterSQLite3Database,
60+
linking: MemberLinking
61+
) => (email: EmailAddress): ReadonlyArray<MemberCoreInfo> => {
62+
// This is a bit grim because member numbers were initially assumed to be uniquely
63+
// identify a single member but actually a member can have multiple member numbers.
64+
// This means we need to find all the member numbers then group them then
65+
// finally use those to actually grab the merged members.
66+
// A potential solution would be to introduce a proper primary key that represents a single user
67+
// and then have member numbers map to the primary key 1:M.
68+
const foundMemberNumbers = db.select({
69+
memberNumber: membersTable.memberNumber,
70+
})
71+
.from(membersTable)
72+
.where(eq(membersTable.emailAddress, normaliseEmailAddress(email)))
73+
.orderBy(desc(membersTable.memberNumber))
74+
.all()
75+
.map(row => row.memberNumber);
76+
const groupedMemberNumbers = linking.mapAll(foundMemberNumbers);
77+
return groupedMemberNumbers.map(
78+
getMergedMemberSet(db)
79+
).flatMap(
80+
m => O.isSome(m) ? [m.value] : []
81+
);
82+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { EmailAddress } from "../../types";
2+
3+
export const normaliseEmailAddress = (email: EmailAddress): EmailAddress => {
4+
const splitPoint = email.lastIndexOf('@');
5+
if (splitPoint < 0) {
6+
return email;
7+
}
8+
return (email.substring(0, splitPoint) + '@' + email.substring(splitPoint + 1).toLowerCase()) as EmailAddress;
9+
};

tests/read-models/members/lookup-by-email.test.ts

Lines changed: 0 additions & 79 deletions
This file was deleted.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { normaliseEmailAddress } from "../../src/read-models/shared-state/normalise-email-address";
2+
import { EmailAddress } from "../../src/types/email-address";
3+
4+
describe('normaliseEmailAddress', () => {
5+
const validEmails = [
6+
['simple@example.com', 'simple@example.com'],
7+
['ALLCAPS@EXAMPLE.COM', 'ALLCAPS@example.com'],
8+
['MiXeD@ExAmPlE.cOm', 'MiXeD@example.com'],
9+
['user.name+tag@domain.co.uk', 'user.name+tag@domain.co.uk'],
10+
// Emails with quoted @ in the local part should preserve case in local part
11+
['"quoted@local"@example.com', '"quoted@local"@example.com'],
12+
['"Quoted@Local"@EXAMPLE.COM', '"Quoted@Local"@example.com'],
13+
];
14+
15+
it.each(validEmails)('normalises valid email %s to %s', (input, expected) => {
16+
expect(normaliseEmailAddress(input as EmailAddress)).toBe(expected);
17+
});
18+
19+
const invalidEmails = [
20+
['no-at-sign', 'no-at-sign'],
21+
['', ''],
22+
['@domain.com', '@domain.com'],
23+
['local@', 'local@'],
24+
];
25+
26+
it.each(invalidEmails)('handles invalid email %s gracefully', (input, expected) => {
27+
expect(normaliseEmailAddress(input as EmailAddress)).toBe(expected);
28+
});
29+
});

tests/read-models/shared-state/get-member.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,14 @@ describe('get-via-shared-read-model', () => {
209209
pipe(id, framework.sharedReadModel.members.get, getSomeOrFail);
210210

211211
describe('when the member does not exist', () => {
212-
it('returns none', () => {
212+
it('get returns none', () => {
213213
const result = framework.sharedReadModel.members.get(memberNumber);
214214
expect(result).toStrictEqual(O.none);
215215
});
216+
it('findByEmail returns none', () => {
217+
const result = framework.sharedReadModel.members.findByEmail(memberEmail);
218+
expect(result).toHaveLength(0);
219+
})
216220
});
217221

218222
describe('when the member exists', () => {
@@ -240,6 +244,31 @@ describe('get-via-shared-read-model', () => {
240244
);
241245
});
242246

247+
it('can find member by email', () => {
248+
const result = framework.sharedReadModel.members.findByEmail(
249+
memberEmail
250+
);
251+
expect(result).toHaveLength(1);
252+
expect(result[0].memberNumber).toEqual(memberNumber);
253+
expect(result[0].emailAddress).toEqual('foo@example.com');
254+
});
255+
256+
it('can find member by email with case insensitive domain', () => {
257+
const result = framework.sharedReadModel.members.findByEmail(
258+
'foo@eXAMple.com' as EmailAddress
259+
);
260+
expect(result).toHaveLength(1);
261+
expect(result[0].memberNumber).toEqual(memberNumber);
262+
expect(result[0].emailAddress).toEqual('foo@example.com');
263+
});
264+
265+
it('cannot find a non-existant email', () => {
266+
const result = framework.sharedReadModel.members.findByEmail(
267+
faker.internet.email() as EmailAddress
268+
);
269+
expect(result).toHaveLength(0);
270+
});
271+
243272
describe('and their name has been recorded', () => {
244273
const name = faker.person.fullName();
245274
beforeEach(async () => {

0 commit comments

Comments
 (0)