Notify users about expiring git PATs and expose PATs in admin panel (#33802)

* Allow admin access to user PATs

* Tests for new screen in admin panel

* Adding error for invalid token and way to parse error for OAuth 2

* Git bridge handles expired PAT

* Script for alerting on close to expiry and expired git tokens

* Refactoring and simplifying

* Updating email templates to match agreed docs

* tweak to email subject to include Overleaf

* Allowing dry run in scripts and general tidy up

* removing redundant tests and dry running script

* Fixing CI errors

* Adding new tab to admin test expectation

* Address PR feedback on oauth2-server changes

- Replace ad-hoc overleafErrorCode prop with a TokenExpiredError subclass
- Collapse listTokens/listTokensForAdmin into a single hook

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Adding cron definitions for alerting on expiring git pat

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
GitOrigin-RevId: 69b9fd901a201592a580c69abe7bd7d603e85d3a
This commit is contained in:
Liam O'Brien
2026-06-03 09:31:00 +01:00
committed by Copybot
parent a553a8390d
commit e53c6f2aea
14 changed files with 778 additions and 10 deletions
+1
View File
@@ -245,6 +245,7 @@ describe('admin panel', function () {
'Deleted Projects',
'Audit Log',
'Sessions',
'Personal Access Tokens',
]
cy.findAllByRole('tab').should('have.length', tabs.length)
tabs.forEach(tabName => {
@@ -5,6 +5,10 @@ import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpHeaders;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -107,15 +111,18 @@ public class Oauth2Filter implements Filter {
// fail later (for example, in the unlikely event that the token
// expired between the two requests). In that case, JGit will
// return a 401 without a custom error message.
int statusCode = checkAccessToken(this.oauth2Server, password, getClientIp(request));
if (statusCode == 429) {
AccessTokenCheck check = checkAccessToken(this.oauth2Server, password, getClientIp(request));
if (check.statusCode == 429) {
handleRateLimit(projectId, username, request, response);
return;
} else if (statusCode == 401) {
} else if (check.statusCode == 401 && "token_expired".equals(check.errorCode)) {
handleExpiredAccessToken(projectId, request, response);
return;
} else if (check.statusCode == 401) {
handleBadAccessToken(projectId, request, response);
return;
} else if (statusCode >= 400) {
handleUnknownOauthServerError(projectId, statusCode, request, response);
} else if (check.statusCode >= 400) {
handleUnknownOauthServerError(projectId, check.statusCode, request, response);
return;
}
cred.setAccessToken(password);
@@ -229,8 +236,52 @@ public class Oauth2Filter implements Filter {
"https://www.overleaf.com/learn/how-to/Git_integration"));
}
private int checkAccessToken(String oauth2Server, String accessToken, String clientIp)
private void handleExpiredAccessToken(
String projectId, HttpServletRequest request, HttpServletResponse response)
throws IOException {
Log.debug("[{}] Expired access token, ip={}", projectId, getClientIp(request));
sendResponse(
response,
401,
Arrays.asList(
"Your Overleaf Git authentication token has expired.",
"",
"Generate a new authentication token in your Overleaf Account Settings,",
"then run the git command again."));
}
static class AccessTokenCheck {
final int statusCode;
final String errorCode;
AccessTokenCheck(int statusCode, String errorCode) {
this.statusCode = statusCode;
this.errorCode = errorCode;
}
}
static String parseErrorCode(String body) {
if (body == null || body.isEmpty()) {
return null;
}
try {
JsonElement element = new Gson().fromJson(body, JsonElement.class);
if (element == null || !element.isJsonObject()) {
return null;
}
JsonObject obj = element.getAsJsonObject();
JsonElement codeElement = obj.get("error_code");
if (codeElement == null || codeElement.isJsonNull()) {
return null;
}
return codeElement.getAsString();
} catch (JsonSyntaxException | UnsupportedOperationException e) {
return null;
}
}
private AccessTokenCheck checkAccessToken(
String oauth2Server, String accessToken, String clientIp) throws IOException {
GenericUrl url = new GenericUrl(oauth2Server + "/oauth/token/info?client_ip=" + clientIp);
HttpRequest request = Instance.httpRequestFactory.buildGetRequest(url);
HttpHeaders headers = new HttpHeaders();
@@ -239,8 +290,12 @@ public class Oauth2Filter implements Filter {
request.setThrowExceptionOnExecuteError(false);
HttpResponse response = request.execute();
int statusCode = response.getStatusCode();
String errorCode = null;
if (statusCode >= 400 && statusCode < 500) {
errorCode = parseErrorCode(response.parseAsString());
}
response.disconnect();
return statusCode;
return new AccessTokenCheck(statusCode, errorCode);
}
private void handleUnknownOauthServerError(
@@ -0,0 +1,51 @@
package uk.ac.ic.wlgitbridge.server;
import org.junit.Assert;
import org.junit.Test;
public class Oauth2FilterTest {
@Test
public void parseErrorCode_returnsTokenExpired_whenBodyContainsIt() {
String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_expired\"}";
Assert.assertEquals("token_expired", Oauth2Filter.parseErrorCode(body));
}
@Test
public void parseErrorCode_returnsTokenInvalid_whenBodyContainsIt() {
String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_invalid\"}";
Assert.assertEquals("token_invalid", Oauth2Filter.parseErrorCode(body));
}
@Test
public void parseErrorCode_returnsNull_whenErrorCodeFieldIsMissing() {
String body = "{\"error\":\"invalid_token\"}";
Assert.assertNull(Oauth2Filter.parseErrorCode(body));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsNull() {
Assert.assertNull(Oauth2Filter.parseErrorCode(null));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsEmpty() {
Assert.assertNull(Oauth2Filter.parseErrorCode(""));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsNotJson() {
Assert.assertNull(Oauth2Filter.parseErrorCode("not json at all"));
}
@Test
public void parseErrorCode_returnsNull_whenBodyIsJsonArray() {
Assert.assertNull(Oauth2Filter.parseErrorCode("[1, 2, 3]"));
}
@Test
public void parseErrorCode_returnsNull_whenErrorCodeFieldIsJsonNull() {
String body = "{\"error\":\"invalid_token\",\"error_code\":null}";
Assert.assertNull(Oauth2Filter.parseErrorCode(body));
}
}
@@ -63,6 +63,31 @@ function checkCredentials(userDetailsMap, user, password) {
return isValid
}
// Map a thrown @node-oauth/oauth2-server error to a stable, machine-readable
// code that callers (e.g. git-bridge) can switch on. err.name values come
// from the library's error classes (snake_case OAuth standard names per
// RFC 6749/6750). The token_expired distinction is driven by a marker we
// set ourselves in Oauth2ServerModel.getAccessToken, so it survives library
// upgrades that might change error_description text.
function _classifyOauthError(err) {
switch (err?.name) {
case 'invalid_token':
return err.overleafErrorCode === 'token_expired'
? 'token_expired'
: 'token_invalid'
case 'invalid_request':
return err.overleafErrorCode === 'token_malformed'
? 'token_malformed'
: 'invalid_request'
case 'insufficient_scope':
return 'insufficient_scope'
case 'unauthorized_request':
return 'unauthorized_request'
default:
return 'unknown'
}
}
// TODO: Finish making these methods async
const AuthenticationController = {
serializeUser(user, callback) {
@@ -400,11 +425,14 @@ const AuthenticationController = {
err.message === 'Invalid request: malformed authorization header'
) {
err.code = 401
err.overleafErrorCode = 'token_malformed'
}
// send all other errors
res
.status(err.code)
.json({ error: err.name, error_description: err.message })
res.status(err.code).json({
error: err.name,
error_description: err.message,
error_code: _classifyOauthError(err),
})
}
}
return expressify(middleware)
@@ -889,6 +889,71 @@ templates.securityAlert = NoCTAEmailTemplate({
},
})
const GIT_TOKEN_DOCS_URL =
'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
subject() {
return 'Your Overleaf token is about to expire'
},
title() {
return 'Your token is about to expire'
},
greeting(opts) {
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi,'
},
message(opts, isPlainText) {
const settingsLink = EmailMessageHelper.displayLink(
'account settings',
`${settings.siteUrl}/user/settings`,
isPlainText
)
const docsLink = EmailMessageHelper.displayLink(
'our docs',
GIT_TOKEN_DOCS_URL,
isPlainText
)
return [
`One of your Git authentication tokens is about to expire. This means you won't be able to use your token to authenticate when performing git operations.`,
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
`Take a look at ${docsLink} if you need more help.`,
'All the best,',
'Team Overleaf',
]
},
})
templates.gitTokenExpired = NoCTAEmailTemplate({
subject() {
return 'Your Overleaf token has expired'
},
title() {
return 'Token expired'
},
greeting(opts) {
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi,'
},
message(opts, isPlainText) {
const settingsLink = EmailMessageHelper.displayLink(
'account settings',
`${settings.siteUrl}/user/settings`,
isPlainText
)
const docsLink = EmailMessageHelper.displayLink(
'our docs',
GIT_TOKEN_DOCS_URL,
isPlainText
)
return [
`One of your Git authentication tokens has expired. This means you won't be able to use it to authenticate when performing git operations.`,
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
`Take a look at ${docsLink} if you need more help.`,
'All the best,',
'Team Overleaf',
]
},
})
templates.SAMLDataCleared = ctaTemplate({
subject(opts) {
return `Institutional Login No Longer Linked - ${settings.appName}`
@@ -17,6 +17,14 @@ export const OauthAccessTokenSchema = new Schema(
createdAt: { type: Date },
expiresAt: Date,
lastUsedAt: Date,
lastNotifiedAt: {
type: {
warning: Date,
expired: Date,
},
_id: false,
},
notificationsSuppressedAt: Date,
},
{
collection: 'oauthAccessTokens',
+9
View File
@@ -426,6 +426,15 @@ module.exports = {
// featuresEpoch: 'YYYY-MM-DD',
personalAccessTokens: {
expiry: {
warningWindowDays: intFromEnv(
'PERSONAL_ACCESS_TOKEN_WARNING_WINDOW_DAYS',
2
),
},
},
features: {
personal: defaultFeatures,
},
@@ -1281,6 +1281,7 @@
"no_pdf_error_reason_output_pdf_already_exists": "",
"no_pdf_error_reason_unrecoverable_error": "",
"no_pdf_error_title": "",
"no_personal_access_tokens": "",
"no_preview_available": "",
"no_project_notifications_description": "",
"no_projects": "",
@@ -1695,6 +1696,7 @@
"save_x_or_more": "",
"saving": "",
"saving_notification_with_seconds": "",
"scope": "",
"search": "",
"search_all_project_files": "",
"search_bib_files": "",
@@ -1914,6 +1916,7 @@
"start_typing_find_your_company": "",
"start_typing_find_your_organization": "",
"start_typing_find_your_university": "",
"status": "",
"stop": "",
"stop_compile": "",
"stop_on_first_error": "",
@@ -2098,6 +2101,7 @@
"toggle_unknown_group": "",
"token": "",
"token_access_failure": "",
"token_expiring_soon": "",
"token_generated": "",
"token_limit_reached": "",
"token_read_only": "",
@@ -2293,6 +2297,7 @@
"user_has_left_organization_and_need_to_transfer_their_projects": "",
"user_last_name_attribute": "",
"user_management": "",
"user_personal_access_tokens": "",
"user_sessions": "",
"using_latex": "",
"using_premium_features": "",
+5
View File
@@ -1675,6 +1675,7 @@
"no_pdf_error_reason_output_pdf_already_exists": "This project contains a file called <code>output.pdf</code>. If that file exists, please rename it and compile again.",
"no_pdf_error_reason_unrecoverable_error": "There is an unrecoverable LaTeX error. If there are LaTeX errors shown below or in the raw logs, please try to fix them and compile again.",
"no_pdf_error_title": "No PDF",
"no_personal_access_tokens": "This user has no personal access tokens",
"no_planned_maintenance": "There is currently no planned maintenance",
"no_preview_available": "Sorry, no preview is available.",
"no_project_notifications_description": "You wont be notified about this project.",
@@ -2231,6 +2232,7 @@
"save_x_or_more": "Save __percentage__ or more",
"saving": "Saving",
"saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)",
"scope": "Scope",
"search": "Search",
"search_all_project_files": "Search all project files",
"search_bib_files": "Search by author, title, year",
@@ -2484,6 +2486,7 @@
"start_typing_find_your_organization": "Start typing to find your organization",
"start_typing_find_your_university": "Start typing to find your university",
"state": "State",
"status": "Status",
"status_checks": "Status Checks",
"still_have_questions": "Still have questions?",
"stop": "Stop",
@@ -2713,6 +2716,7 @@
"toggle_unknown_group": "Toggle unknown group",
"token": "Token",
"token_access_failure": "Cannot grant access; contact the project owner for help",
"token_expiring_soon": "Expiring soon",
"token_generated": "Token generated",
"token_limit_reached": "Youve reached the 10 token limit. To generate a new authentication token, please delete an existing one.",
"token_read_only": "token read-only",
@@ -2937,6 +2941,7 @@
"user_management": "User management",
"user_metrics": "User metrics",
"user_not_found": "User not found",
"user_personal_access_tokens": "Personal access tokens",
"user_sessions": "User Sessions",
"user_wants_you_to_see_project": "__username__ would like you to join __projectname__",
"using_latex": "Using LaTeX",
@@ -0,0 +1,69 @@
// One-off backfill: mark every personal access token that was already expired
// at the moment the expiry-notification feature shipped, so the recurring
// notifier (notify_expiring_tokens.mjs) does not blast a "your token has
// expired" email to users about long-dead tokens.
//
// `notificationsSuppressedAt` is set ONLY by this script. It is intentionally
// distinct from `lastNotifiedAt.expired` (which records actual sends) so the
// two cases remain unambiguous in the data forever.
//
// Idempotent: re-running does nothing further once the flag is set.
//
// Pass --dry-run to count matching tokens without writing.
import logger from '@overleaf/logger'
import {
db,
READ_PREFERENCE_SECONDARY,
} from '../../app/src/infrastructure/mongodb.mjs'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
async function main() {
const dryRun = process.argv.includes('--dry-run')
const now = new Date()
const cursor = db.oauthAccessTokens.find(
{
type: 'pat',
accessTokenExpiresAt: { $lt: now },
'lastNotifiedAt.expired': { $exists: false },
notificationsSuppressedAt: { $exists: false },
},
{
projection: { _id: 1 },
readPreference: READ_PREFERENCE_SECONDARY,
}
)
let matched = 0
for await (const doc of cursor) {
if (!dryRun) {
await db.oauthAccessTokens.updateOne(
{ _id: doc._id },
{ $set: { notificationsSuppressedAt: now } }
)
}
matched++
}
if (dryRun) {
logger.info(
{ matched },
'dry run: expired-token notifications would be suppressed'
)
} else {
logger.info(
{ suppressed: matched },
'expired-token notifications suppressed'
)
}
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
logger.error(
{ err: error },
'backfill_suppress_expired_token_notifications failed'
)
process.exit(1)
}
@@ -0,0 +1,138 @@
// Recurring job. Sends two kinds of email about personal access token expiry:
//
// - "expiring soon": token has not expired yet and falls within the
// configured warning window
// (Settings.personalAccessTokens.expiry.warningWindowDays, default 2 days).
// Sent at most once per token.
//
// - "expired": token has expired and we have not yet emailed the owner.
// Sent at most once per token. Tokens that were already expired before
// the feature shipped have `notificationsSuppressedAt` set by the
// backfill_suppress_expired_token_notifications.mjs script and are
// deliberately excluded.
//
// Pass --dry-run to log who would be emailed without sending or marking
// `lastNotifiedAt`.
import settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import {
db,
READ_PREFERENCE_SECONDARY,
} from '../../app/src/infrastructure/mongodb.mjs'
import { User } from '../../app/src/models/User.mjs'
import EmailHandler from '../../app/src/Features/Email/EmailHandler.mjs'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
const MS_PER_DAY = 24 * 60 * 60 * 1000
export async function main({ dryRun = false } = {}) {
const now = new Date()
const warningWindowDays =
settings.personalAccessTokens.expiry.warningWindowDays
const warningHorizon = new Date(
now.getTime() + warningWindowDays * MS_PER_DAY
)
logger.info(
{ warningWindowDays, warningHorizon, dryRun },
'starting notify_expiring_tokens'
)
const warningCount = await processBucket({
kind: 'warning',
template: 'gitTokenExpiringSoon',
dryRun,
query: {
type: 'pat',
accessTokenExpiresAt: { $gt: now, $lte: warningHorizon },
'lastNotifiedAt.warning': { $exists: false },
},
})
const expiredCount = await processBucket({
kind: 'expired',
template: 'gitTokenExpired',
dryRun,
query: {
type: 'pat',
accessTokenExpiresAt: { $lt: now },
'lastNotifiedAt.expired': { $exists: false },
notificationsSuppressedAt: { $exists: false },
},
})
logger.info(
{ warningCount, expiredCount, dryRun },
'finished notify_expiring_tokens'
)
}
export async function processBucket({ kind, template, query, dryRun = false }) {
const cursor = db.oauthAccessTokens.find(query, {
projection: {
_id: 1,
user_id: 1,
},
readPreference: READ_PREFERENCE_SECONDARY,
})
let sent = 0
for await (const token of cursor) {
const ok = await notifyOwner({ token, kind, template, dryRun })
if (ok) sent++
}
return sent
}
export async function notifyOwner({ token, kind, template, dryRun = false }) {
const user = await User.findOne(
{ _id: token.user_id },
{ email: 1, first_name: 1 }
).exec()
if (!user?.email) {
logger.warn(
{ tokenId: token._id, userId: token.user_id },
'skipping token notification: user not found or has no email'
)
return false
}
if (dryRun) {
logger.info(
{ tokenId: token._id, userId: token.user_id, kind, template },
'dry run: would send git token expiry notification'
)
return true
}
try {
await EmailHandler.promises.sendEmail(template, {
to: user.email,
firstName: user.first_name,
})
} catch (err) {
logger.error(
{ err, tokenId: token._id, userId: token.user_id, kind },
'failed to send git token expiry notification; will retry next run'
)
return false
}
await db.oauthAccessTokens.updateOne(
{ _id: token._id },
{ $set: { [`lastNotifiedAt.${kind}`]: new Date() } }
)
return true
}
if (import.meta.url === `file://${process.argv[1]}`) {
const dryRun = process.argv.includes('--dry-run')
try {
await scriptRunner(() => main({ dryRun }))
process.exit(0)
} catch (error) {
logger.error({ err: error }, 'notify_expiring_tokens failed')
process.exit(1)
}
}
@@ -730,6 +730,83 @@ describe('AuthenticationController', function () {
ctx.next.should.have.not.been.calledOnce
})
})
describe('error_code classification', function () {
// The classifier reads err.name (RFC-standard snake_case from
// @node-oauth/oauth2-server) plus an overleafErrorCode marker we
// attach ourselves. No reliance on err.message — that keeps the
// classification immune to library description changes.
async function runMiddlewareWithError(ctx, err) {
await new Promise(resolve => {
ctx.res.json.callsFake(() => resolve())
ctx.Oauth2Server.server.authenticate.rejects(err)
ctx.middleware(ctx.req, ctx.res, ctx.next)
})
}
it('returns "token_expired" when Oauth2ServerModel marks the error', async function (ctx) {
await runMiddlewareWithError(ctx, {
code: 401,
name: 'invalid_token',
overleafErrorCode: 'token_expired',
})
ctx.res.json.should.have.been.calledWithMatch({
error_code: 'token_expired',
})
})
it('returns "token_invalid" for an invalid_token error without a marker', async function (ctx) {
await runMiddlewareWithError(ctx, {
code: 401,
name: 'invalid_token',
})
ctx.res.json.should.have.been.calledWithMatch({
error_code: 'token_invalid',
})
})
it('returns "token_malformed" for a malformed authorization header', async function (ctx) {
await runMiddlewareWithError(ctx, {
code: 400,
name: 'invalid_request',
message: 'Invalid request: malformed authorization header',
})
ctx.res.json.should.have.been.calledWithMatch({
error_code: 'token_malformed',
})
})
it('returns "invalid_request" for any other invalid_request error', async function (ctx) {
await runMiddlewareWithError(ctx, {
code: 400,
name: 'invalid_request',
message: 'Invalid request: something else',
})
ctx.res.json.should.have.been.calledWithMatch({
error_code: 'invalid_request',
})
})
it('returns "insufficient_scope" for an insufficient_scope error', async function (ctx) {
await runMiddlewareWithError(ctx, {
code: 403,
name: 'insufficient_scope',
})
ctx.res.json.should.have.been.calledWithMatch({
error_code: 'insufficient_scope',
})
})
it('returns "unauthorized_request" for an unauthorized_request error', async function (ctx) {
await runMiddlewareWithError(ctx, {
code: 401,
name: 'unauthorized_request',
})
ctx.res.json.should.have.been.calledWithMatch({
error_code: 'unauthorized_request',
})
})
})
})
describe('requireGlobalLogin', function () {
@@ -173,6 +173,50 @@ describe('EmailBuilder', function () {
})
})
describe('gitTokenExpiringSoon', function () {
beforeEach(function (ctx) {
ctx.opts = {
to: 'user@example.com',
}
ctx.email = ctx.EmailBuilder.buildEmail('gitTokenExpiringSoon', ctx.opts)
})
it('should render html, text, and subject without undefined', function (ctx) {
expect(ctx.email.html).to.not.be.undefined
expect(ctx.email.text).to.not.be.undefined
expect(ctx.email.subject).to.not.be.undefined
ctx.email.html.indexOf('undefined').should.equal(-1)
ctx.email.text.indexOf('undefined').should.equal(-1)
ctx.email.subject.indexOf('undefined').should.equal(-1)
})
it('should link the CTA to user settings', function (ctx) {
ctx.email.text.should.contain(`${ctx.settings.siteUrl}/user/settings`)
})
})
describe('gitTokenExpired', function () {
beforeEach(function (ctx) {
ctx.opts = {
to: 'user@example.com',
}
ctx.email = ctx.EmailBuilder.buildEmail('gitTokenExpired', ctx.opts)
})
it('should render html, text, and subject without undefined', function (ctx) {
expect(ctx.email.html).to.not.be.undefined
expect(ctx.email.text).to.not.be.undefined
expect(ctx.email.subject).to.not.be.undefined
ctx.email.html.indexOf('undefined').should.equal(-1)
ctx.email.text.indexOf('undefined').should.equal(-1)
ctx.email.subject.indexOf('undefined').should.equal(-1)
})
it('should link the CTA to user settings', function (ctx) {
ctx.email.text.should.contain(`${ctx.settings.siteUrl}/user/settings`)
})
})
describe('ctaTemplate', function () {
describe('missing required content', function () {
const content = {
@@ -0,0 +1,213 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const SCRIPT_PATH = '../../../../scripts/oauth/notify_expiring_tokens.mjs'
describe('notify_expiring_tokens', function () {
beforeEach(async function (ctx) {
ctx.userEmail = 'user@example.com'
ctx.User = {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves({ email: ctx.userEmail }),
}),
}
ctx.collection = {
cursor: [],
find: sinon.stub().callsFake(() => ({
[Symbol.asyncIterator]: async function* () {
for (const t of ctx.collection.cursor) yield t
},
})),
updateOne: sinon.stub().resolves({ modifiedCount: 1 }),
}
ctx.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
vi.doMock('../../../../app/src/infrastructure/mongodb.mjs', () => ({
db: { oauthAccessTokens: ctx.collection },
READ_PREFERENCE_SECONDARY: 'secondary',
}))
vi.doMock('../../../../app/src/models/User.mjs', () => ({
User: ctx.User,
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({
default: ctx.EmailHandler,
}))
vi.doMock('@overleaf/settings', () => ({
default: {
personalAccessTokens: { expiry: { warningWindowDays: 2 } },
},
}))
vi.doMock('../../../../scripts/lib/ScriptRunner.mjs', () => ({
scriptRunner: async fn => fn(),
}))
ctx.script = await import(SCRIPT_PATH)
})
describe('notifyOwner', function () {
it('returns true and sets lastNotifiedAt on successful send', async function (ctx) {
const token = {
_id: 'tok-1',
user_id: 'user-1',
accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
}
const ok = await ctx.script.notifyOwner({
token,
kind: 'warning',
template: 'gitTokenExpiringSoon',
})
expect(ok).to.equal(true)
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'gitTokenExpiringSoon',
sinon.match({ to: ctx.userEmail })
)
expect(ctx.collection.updateOne).to.have.been.calledOnce
const update = ctx.collection.updateOne.firstCall.args[1]
expect(update.$set).to.have.property('lastNotifiedAt.warning')
})
it('returns false and does NOT set the marker if EmailHandler rejects', async function (ctx) {
ctx.EmailHandler.promises.sendEmail.rejects(new Error('SMTP down'))
const token = {
_id: 'tok-2',
user_id: 'user-2',
accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
}
const ok = await ctx.script.notifyOwner({
token,
kind: 'expired',
template: 'gitTokenExpired',
})
expect(ok).to.equal(false)
expect(ctx.collection.updateOne).to.not.have.been.called
})
it('returns false and skips when the owner has no email', async function (ctx) {
ctx.User.findOne.returns({
exec: sinon.stub().resolves(null),
})
const token = {
_id: 'tok-3',
user_id: 'user-3',
accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
}
const ok = await ctx.script.notifyOwner({
token,
kind: 'warning',
template: 'gitTokenExpiringSoon',
})
expect(ok).to.equal(false)
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
expect(ctx.collection.updateOne).to.not.have.been.called
})
})
describe('processBucket', function () {
it('iterates all matching tokens and counts successful sends', async function (ctx) {
ctx.collection.cursor = [
{
_id: 't1',
user_id: 'u1',
accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
},
{
_id: 't2',
user_id: 'u2',
accessTokenExpiresAt: new Date('2026-05-21T00:00:00Z'),
},
]
const count = await ctx.script.processBucket({
kind: 'warning',
template: 'gitTokenExpiringSoon',
query: { type: 'pat' },
})
expect(count).to.equal(2)
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledTwice
expect(ctx.collection.updateOne).to.have.been.calledTwice
})
it('continues processing remaining tokens after one send fails', async function (ctx) {
ctx.collection.cursor = [
{
_id: 't1',
user_id: 'u1',
accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
},
{
_id: 't2',
user_id: 'u2',
accessTokenExpiresAt: new Date('2026-05-21T00:00:00Z'),
},
]
ctx.EmailHandler.promises.sendEmail
.onFirstCall()
.rejects(new Error('transient'))
.onSecondCall()
.resolves()
const count = await ctx.script.processBucket({
kind: 'expired',
template: 'gitTokenExpired',
query: { type: 'pat' },
})
expect(count).to.equal(1)
expect(ctx.collection.updateOne).to.have.been.calledOnce
})
it('returns 0 when no tokens match', async function (ctx) {
ctx.collection.cursor = []
const count = await ctx.script.processBucket({
kind: 'warning',
template: 'gitTokenExpiringSoon',
query: { type: 'pat' },
})
expect(count).to.equal(0)
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
})
})
describe('main query construction', function () {
it('queries the warning bucket within the configured window and skips already-warned tokens', async function (ctx) {
ctx.collection.cursor = []
await ctx.script.main()
const warningCall = ctx.collection.find
.getCalls()
.find(c => c.args[0]['lastNotifiedAt.warning'])
expect(warningCall).to.exist
const q = warningCall.args[0]
expect(q.type).to.equal('pat')
expect(q['lastNotifiedAt.warning']).to.deep.equal({ $exists: false })
expect(q.accessTokenExpiresAt.$gt).to.be.instanceOf(Date)
expect(q.accessTokenExpiresAt.$lte).to.be.instanceOf(Date)
const horizonMs =
q.accessTokenExpiresAt.$lte.getTime() -
q.accessTokenExpiresAt.$gt.getTime()
expect(horizonMs).to.equal(2 * 24 * 60 * 60 * 1000)
})
it('queries the expired bucket and excludes suppressed tokens', async function (ctx) {
ctx.collection.cursor = []
await ctx.script.main()
const expiredCall = ctx.collection.find
.getCalls()
.find(c => c.args[0]['lastNotifiedAt.expired'])
expect(expiredCall).to.exist
const q = expiredCall.args[0]
expect(q['lastNotifiedAt.expired']).to.deep.equal({ $exists: false })
expect(q.notificationsSuppressedAt).to.deep.equal({ $exists: false })
})
})
})