Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions app/core/notification-center.js.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,10 @@ function writeNotifCache(unread) {
'local_password_expiring': {
theme: function () { return L.themePassword },
detail: function (p) {
// One row is stored per milestone, so days_remaining is frozen at
// delivery time (a D-14 warning would still read "14 days" at D-8).
// Recompute from the absolute expiry when the payload carries it.
// Keep the countdown recorded at delivery, alongside the row's
// timestamp. Recomputing it today makes distinct milestones
// for the same expiry look like duplicate notifications.
var days = parseInt(p.days_remaining, 10) || 0
var expiresAt = parseInt(p.expires_at, 10) || 0
if (expiresAt > 0) {
days = Math.ceil((expiresAt * 1000 - Date.now()) / 86400000)
}
if (days <= 0) return L.passwordExpired
if (days === 1) return L.passwordExpiresTomorrow
return L.passwordExpiring.replace('%s', String(days))
Expand Down
61 changes: 61 additions & 0 deletions tests/JavaScript/password-expiry-notifications.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* TeamPass - a collaborative passwords manager.
* Copyright (C) 2009-2026 Teampass.net
* Distributed under the GNU GPL version 3, WITHOUT ANY WARRANTY.
* See <https://www.gnu.org/licenses/>.
*/
const assert = require('node:assert/strict')
const { readFileSync } = require('node:fs')
const { join } = require('node:path')
const vm = require('node:vm')
const { test } = require('node:test')

const source = readFileSync(join(__dirname, '../../app/core/notification-center.js.php'), 'utf8')
const start = source.indexOf('var RENDERERS = {')
const end = source.indexOf('function escapeText(', start)
assert.ok(start >= 0 && end > start, 'Missing notification renderers')

function renderer(now) {
const context = vm.createContext({
L: {
passwordExpired: 'Your TeamPass password has expired',
passwordExpiresTomorrow: 'Your TeamPass password expires tomorrow',
passwordExpiring: 'Your TeamPass password expires in %s days'
}
})
vm.runInContext('Date.now = () => ' + Date.parse(now), context)
vm.runInContext(source.slice(start, end), context)
return context.RENDERERS.local_password_expiring.detail
}

const expiresAt = Date.parse('2026-11-15T03:15:00Z') / 1000

test('weekly warnings for the same expiry keep their original distinct countdowns', () => {
const detail = renderer('2026-11-08T03:15:00Z')
const firstWarning = { days_remaining: 14, threshold: 14, expires_at: expiresAt }
const secondWarning = { days_remaining: 7, threshold: 7, expires_at: expiresAt }

assert.equal(detail(firstWarning), 'Your TeamPass password expires in 14 days')
assert.equal(detail(secondWarning), 'Your TeamPass password expires in 7 days')
})

test('reading a notification later does not rewrite its historical message', () => {
const warning = { days_remaining: 7, threshold: 7, expires_at: expiresAt }
for (const now of ['2026-11-08T03:15:00Z', '2026-11-14T03:15:00Z', '2026-11-16T03:15:00Z']) {
assert.equal(renderer(now)(warning), 'Your TeamPass password expires in 7 days', now)
}
})

test('a warning delivered between milestones uses the actual days at delivery', () => {
const detail = renderer('2026-11-08T03:15:00Z')
assert.equal(detail({ days_remaining: 8, threshold: 14, expires_at: expiresAt }),
'Your TeamPass password expires in 8 days')
})

test('tomorrow and expired messages use the stored countdown with or without an expiry timestamp', () => {
const detail = renderer('2026-11-08T03:15:00Z')
for (const expiry of [expiresAt, undefined]) {
assert.equal(detail({ days_remaining: 1, expires_at: expiry }), 'Your TeamPass password expires tomorrow')
assert.equal(detail({ days_remaining: 0, expires_at: expiry }), 'Your TeamPass password has expired')
}
})
Loading