Skip to content

Commit 308c343

Browse files
y4nderclaude
andauthored
fix: prevent duplicate moodle_token insert when Moodle rotates token (#399) (#400)
Surfaced by the new error log page on staging — login for `ucmn-t-67092` (and `harvie`) was failing with duplicate key value violates unique constraint "moodle_token_moodle_user_id_unique" Key (moodle_user_id)=(5) already exists The "rotated token" branch in `MoodleTokenRepository.UpsertFromMoodle` kept the existing row alive (just flipping `isValid = false`) and then called `this.create(...)` to insert a *second* row carrying the same `moodleUserId`. The column-level UNIQUE constraint on `moodle_user_id` rejected the insert at flush time → unhandled 500. Users whose token had not yet rotated (string-equal to the stored one) hit the second branch which updated in place and worked, which is why this was intermittent. Two compounding factors: 1. The find was keyed by `user.id`. When the local `User` row was recreated with a fresh UUID (or the existing token was soft-deleted), the find returned `null`, the create-path ran, and the unique constraint still saw the orphaned row. 2. The global soft-delete filter hid candidate rows that would have matched, so an in-place mutation never happened. Fix: - Look up by `moodleUserId` (the unique key) with `filters: { softDelete: false }` so the find is resilient to rotated tokens, soft-deleted rows, and re-created local users. - On hit, mutate the row in place: new token string, refreshed validation timestamps, rebind `user` to the current local user, clear `deletedAt`. Never create a second row — the unique constraint forbids it and the semantic intent ("one current token per Moodle user") is preserved. - Defensive precondition: throw if `user.moodleUserId` is missing, mirroring `MoodleToken.Create`. Adds repo-level unit tests covering: first-login, validation refresh (same token re-presented), rotated token (the failing case), and soft-deleted row revival. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e3731c6 commit 308c343

2 files changed

Lines changed: 191 additions & 19 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { MoodleTokenRepository } from './moodle-token.repository';
2+
import { MoodleToken } from '../entities/moodle-token.entity';
3+
import { User } from '../entities/user.entity';
4+
import type { MoodleTokenResponse } from '../modules/moodle/lib/moodle.types';
5+
6+
// Repo-level unit test exercising the unique-constraint-safe upsert.
7+
// Mocks the protected `findOne`/`create` surface MikroORM's EntityRepository
8+
// exposes so we can verify the lookup key + mutation behaviour without a DB.
9+
describe('MoodleTokenRepository.UpsertFromMoodle', () => {
10+
const buildUser = (overrides: Partial<User> = {}): User =>
11+
({
12+
id: 'user-uuid-current',
13+
moodleUserId: 5,
14+
...overrides,
15+
}) as User;
16+
17+
const buildTokenResponse = (token: string): MoodleTokenResponse =>
18+
({ token }) as MoodleTokenResponse;
19+
20+
const buildRepo = (
21+
findOneResult: MoodleToken | null,
22+
): {
23+
repo: MoodleTokenRepository;
24+
findOne: jest.Mock;
25+
create: jest.Mock;
26+
} => {
27+
const findOne = jest.fn().mockResolvedValue(findOneResult);
28+
const create = jest.fn((data: Partial<MoodleToken>) => data as MoodleToken);
29+
const repo = Object.create(
30+
MoodleTokenRepository.prototype,
31+
) as MoodleTokenRepository;
32+
Object.assign(repo, { findOne, create });
33+
return { repo, findOne, create };
34+
};
35+
36+
it('throws when the user has no moodleUserId (defensive precondition)', async () => {
37+
const { repo } = buildRepo(null);
38+
const user = buildUser({ moodleUserId: undefined });
39+
40+
await expect(
41+
repo.UpsertFromMoodle(user, buildTokenResponse('abc')),
42+
).rejects.toThrow(/moodleUserId/);
43+
});
44+
45+
it('looks up by moodleUserId (not user.id) and includes soft-deleted rows', async () => {
46+
const { repo, findOne } = buildRepo(null);
47+
const user = buildUser();
48+
49+
await repo.UpsertFromMoodle(user, buildTokenResponse('abc'));
50+
51+
expect(findOne).toHaveBeenCalledWith(
52+
{ moodleUserId: 5 },
53+
{ filters: { softDelete: false } },
54+
);
55+
});
56+
57+
it('creates a new row when no token exists for this moodleUserId', async () => {
58+
const { repo, create } = buildRepo(null);
59+
const user = buildUser();
60+
61+
const result = await repo.UpsertFromMoodle(
62+
user,
63+
buildTokenResponse('new-token'),
64+
);
65+
66+
expect(create).toHaveBeenCalledTimes(1);
67+
expect(result.token).toBe('new-token');
68+
expect(result.moodleUserId).toBe(5);
69+
expect(result.user).toBe(user);
70+
});
71+
72+
it('mutates in place when the same token is re-presented (validation refresh)', async () => {
73+
const existing = {
74+
id: 'token-uuid',
75+
token: 'same-token',
76+
moodleUserId: 5,
77+
isValid: true,
78+
lastValidatedAt: new Date('2026-01-01T00:00:00Z'),
79+
invalidatedAt: new Date('2026-01-02T00:00:00Z'),
80+
user: { id: 'user-uuid-current' } as User,
81+
deletedAt: undefined,
82+
} as MoodleToken;
83+
const { repo, create } = buildRepo(existing);
84+
const user = buildUser();
85+
86+
const result = await repo.UpsertFromMoodle(
87+
user,
88+
buildTokenResponse('same-token'),
89+
);
90+
91+
expect(create).not.toHaveBeenCalled();
92+
expect(result).toBe(existing);
93+
expect(result.token).toBe('same-token');
94+
expect(result.isValid).toBe(true);
95+
expect(result.invalidatedAt).toBeUndefined();
96+
expect(result.lastValidatedAt!.getTime()).toBeGreaterThan(
97+
new Date('2026-01-01T00:00:00Z').getTime(),
98+
);
99+
});
100+
101+
it('mutates in place on a rotated token (FAC fix: previously created a duplicate row)', async () => {
102+
const existing = {
103+
id: 'token-uuid',
104+
token: 'old-token',
105+
moodleUserId: 5,
106+
isValid: true,
107+
lastValidatedAt: new Date('2026-01-01T00:00:00Z'),
108+
user: { id: 'previous-user-uuid' } as User, // intentionally different
109+
deletedAt: undefined,
110+
} as MoodleToken;
111+
const { repo, create } = buildRepo(existing);
112+
const user = buildUser({ id: 'user-uuid-current' });
113+
114+
const result = await repo.UpsertFromMoodle(
115+
user,
116+
buildTokenResponse('new-rotated-token'),
117+
);
118+
119+
// Critical: no second row created — the unique constraint on moodleUserId
120+
// would have rejected it. We update the existing row instead.
121+
expect(create).not.toHaveBeenCalled();
122+
expect(result).toBe(existing);
123+
expect(result.token).toBe('new-rotated-token');
124+
expect(result.isValid).toBe(true);
125+
expect(result.invalidatedAt).toBeUndefined();
126+
// Also rebinds to the current local user so the FK stays consistent
127+
// when the local row was re-created with a fresh UUID.
128+
expect(result.user).toBe(user);
129+
});
130+
131+
it('revives a soft-deleted token instead of creating a duplicate', async () => {
132+
const softDeleted = {
133+
id: 'token-uuid',
134+
token: 'old-token',
135+
moodleUserId: 5,
136+
isValid: false,
137+
user: { id: 'user-uuid-current' } as User,
138+
deletedAt: new Date('2025-12-01T00:00:00Z'),
139+
} as MoodleToken;
140+
const { repo, create } = buildRepo(softDeleted);
141+
const user = buildUser();
142+
143+
const result = await repo.UpsertFromMoodle(
144+
user,
145+
buildTokenResponse('fresh-token'),
146+
);
147+
148+
expect(create).not.toHaveBeenCalled();
149+
expect(result).toBe(softDeleted);
150+
expect(result.token).toBe('fresh-token');
151+
expect(result.isValid).toBe(true);
152+
expect(result.deletedAt).toBeUndefined();
153+
});
154+
});
Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,49 @@
11
import { EntityRepository } from '@mikro-orm/postgresql';
22
import { MoodleToken } from '../entities/moodle-token.entity';
3-
import { User } from '../entities/user.entity';
43
import { MoodleTokenResponse } from '../modules/moodle/lib/moodle.types';
4+
import { User } from '../entities/user.entity';
55

66
export class MoodleTokenRepository extends EntityRepository<MoodleToken> {
7+
/**
8+
* Upserts the Moodle token for a user. `moodle_token.moodle_user_id` carries
9+
* a column-level UNIQUE constraint, so at most one row exists per Moodle
10+
* user. Look up by `moodleUserId` (the unique key) — not `user.id` — so the
11+
* lookup survives:
12+
*
13+
* 1. Rotated tokens. Moodle issues a new token string on the next login;
14+
* we mutate the existing row in place rather than insert a duplicate
15+
* with the same `moodleUserId` (the previous implementation did the
16+
* latter and tripped the unique constraint).
17+
* 2. Soft-deleted rows. Postgres enforces UNIQUE on every row, so a
18+
* soft-deleted token still blocks an insert. Including soft-deleted
19+
* rows in the find lets us revive the existing row instead.
20+
* 3. Re-created local User. If the local `user` row was rebuilt with a
21+
* fresh UUID, the old token's `user_id` FK no longer matches the
22+
* current user — but `moodleUserId` still does.
23+
*/
724
async UpsertFromMoodle(user: User, moodleTokens: MoodleTokenResponse) {
8-
let moodleToken = await this.findOne({
9-
user: {
10-
id: user.id,
11-
},
12-
});
25+
if (!user.moodleUserId) {
26+
throw new Error(
27+
'Cannot upsert MoodleToken for user without moodleUserId',
28+
);
29+
}
1330

14-
if (moodleToken === null) {
15-
// first token
16-
moodleToken = this.create(MoodleToken.Create(user, moodleTokens));
17-
} else if (moodleToken.token === moodleTokens.token) {
18-
// same token
19-
moodleToken.lastValidatedAt = new Date();
20-
moodleToken.invalidatedAt = undefined;
21-
moodleToken.isValid = true;
22-
} else {
23-
// rotated token
24-
moodleToken.isValid = false;
25-
moodleToken.invalidatedAt = new Date();
31+
const existing = await this.findOne(
32+
{ moodleUserId: user.moodleUserId },
33+
{ filters: { softDelete: false } },
34+
);
35+
36+
if (existing === null) {
2637
return this.create(MoodleToken.Create(user, moodleTokens));
2738
}
2839

29-
return moodleToken;
40+
existing.user = user;
41+
existing.token = moodleTokens.token;
42+
existing.lastValidatedAt = new Date();
43+
existing.invalidatedAt = undefined;
44+
existing.isValid = true;
45+
existing.deletedAt = undefined;
46+
47+
return existing;
3048
}
3149
}

0 commit comments

Comments
 (0)