feat: remove hashed passwords feature spec and migrate to archive
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m6s

fix: update login token expiration to 62 days

chore: bump version to 1.0.5RC1

test: add isParentPersistent to LoginButton.spec.ts

refactor: rename Assign Tasks button to Assign Chores in ParentView.vue

refactor: rename Assign Tasks to Assign Chores in TaskAssignView.vue

feat: add stay in parent mode checkbox and badge in LoginButton.vue

test: enhance LoginButton.spec.ts with persistent mode tests

test: add authGuard.spec.ts for logoutParent and enforceParentExpiry

feat: implement parent mode expiry logic in auth.ts

test: add auth.expiry.spec.ts for parent mode expiry tests

chore: create template for feature specs
This commit is contained in:
2026-02-20 16:31:13 -05:00
parent ba909100a7
commit 55e7dc7568
15 changed files with 657 additions and 38 deletions

View File

@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { isParentAuthenticated, isUserLoggedIn, loginUser, initAuthSync } from '../auth'
import {
isParentAuthenticated,
isUserLoggedIn,
loginUser,
initAuthSync,
authenticateParent,
logoutParent,
} from '../auth'
import { nextTick } from 'vue'
// Stub window.location to prevent jsdom "navigation to another Document" warnings
@@ -26,20 +33,26 @@ global.localStorage = {
describe('auth store - child mode on login', () => {
beforeEach(() => {
isParentAuthenticated.value = true
localStorage.setItem('isParentAuthenticated', 'true')
// Use authenticateParent() to set up parent-mode state
authenticateParent(false)
})
afterEach(() => {
logoutParent()
})
it('should clear isParentAuthenticated and localStorage on loginUser()', async () => {
expect(isParentAuthenticated.value).toBe(true)
loginUser()
await nextTick() // flush Vue watcher
await nextTick()
expect(isParentAuthenticated.value).toBe(false)
})
it('logs out on cross-tab storage logout event', async () => {
initAuthSync()
isUserLoggedIn.value = true
isParentAuthenticated.value = true
authenticateParent(false)
expect(isParentAuthenticated.value).toBe(true)
const logoutEvent = new StorageEvent('storage', {
key: 'authSyncEvent',
@@ -51,4 +64,26 @@ describe('auth store - child mode on login', () => {
expect(isUserLoggedIn.value).toBe(false)
expect(isParentAuthenticated.value).toBe(false)
})
it('exits parent mode on cross-tab parent_logout storage event', async () => {
initAuthSync()
authenticateParent(false)
expect(isParentAuthenticated.value).toBe(true)
// Simulate being on a /parent route in this tab
locationStub.pathname = '/parent'
const parentLogoutEvent = new StorageEvent('storage', {
key: 'authSyncEvent',
newValue: JSON.stringify({ type: 'parent_logout', at: Date.now() }),
})
window.dispatchEvent(parentLogoutEvent)
await nextTick()
expect(isParentAuthenticated.value).toBe(false)
expect(locationStub.href).toBe('/child')
// Reset for other tests
locationStub.pathname = '/'
})
})

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
isParentAuthenticated,
isParentPersistent,
parentAuthExpiresAt,
authenticateParent,
logoutParent,
loginUser,
} from '../auth'
// Stub window.location
const locationStub = { href: '', pathname: '/', assign: vi.fn(), replace: vi.fn(), reload: vi.fn() }
Object.defineProperty(window, 'location', { value: locationStub, writable: true })
// Build a stateful localStorage stub and register it via vi.stubGlobal so it is
// visible to auth.ts's module scope (not just the test file's scope).
function makeLocalStorageStub() {
const store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = value
},
removeItem: (key: string) => {
delete store[key]
},
clear: () => {
for (const k of Object.keys(store)) delete store[k]
},
_store: store,
}
}
const localStorageStub = makeLocalStorageStub()
vi.stubGlobal('localStorage', localStorageStub)
describe('auth store - parent mode expiry', () => {
beforeEach(() => {
vi.useFakeTimers()
localStorageStub.clear()
logoutParent()
})
afterEach(() => {
logoutParent()
vi.useRealTimers()
})
describe('non-persistent mode', () => {
it('authenticateParent(false) sets isParentAuthenticated to true', () => {
authenticateParent(false)
expect(isParentAuthenticated.value).toBe(true)
expect(isParentPersistent.value).toBe(false)
})
it('non-persistent auth does not set isParentPersistent', () => {
authenticateParent(false)
expect(isParentPersistent.value).toBe(false)
expect(parentAuthExpiresAt.value).not.toBeNull()
// Confirm the expiry is ~1 minute, not 2 days
expect(parentAuthExpiresAt.value!).toBeLessThan(Date.now() + 172_800_000)
})
it('isParentAuthenticated becomes false after 1 minute (watcher fires)', () => {
authenticateParent(false)
expect(isParentAuthenticated.value).toBe(true)
// Advance 60s: watcher fires every 15s, at t=60000 Date.now() >= expiresAt
vi.advanceTimersByTime(60_001)
expect(isParentAuthenticated.value).toBe(false)
})
it('isParentAuthenticated is still true just before 1 minute', () => {
authenticateParent(false)
vi.advanceTimersByTime(59_999)
// Watcher last fired at t=45000, next fires at t=60000 — hasn't expired yet
expect(isParentAuthenticated.value).toBe(true)
})
})
describe('persistent mode', () => {
it('authenticateParent(true) sets isParentAuthenticated to true', () => {
authenticateParent(true)
expect(isParentAuthenticated.value).toBe(true)
expect(isParentPersistent.value).toBe(true)
})
it('writes expiresAt to localStorage for persistent auth — parentAuthExpiresAt is set to ~2 days', () => {
const before = Date.now()
authenticateParent(true)
const after = Date.now()
// Verify the expiry ref is populated and within the 2-day window
expect(parentAuthExpiresAt.value).not.toBeNull()
expect(parentAuthExpiresAt.value!).toBeGreaterThanOrEqual(before + 172_800_000)
expect(parentAuthExpiresAt.value!).toBeLessThanOrEqual(after + 172_800_000)
})
it('isParentAuthenticated becomes false after 2 days (watcher fires)', () => {
authenticateParent(true)
expect(isParentAuthenticated.value).toBe(true)
vi.advanceTimersByTime(172_800_001)
expect(isParentAuthenticated.value).toBe(false)
})
it('isParentAuthenticated is still true just before 2 days', () => {
authenticateParent(true)
// Advance to just before expiry; watcher last fired at t=172_785_000
vi.advanceTimersByTime(172_784_999)
expect(isParentAuthenticated.value).toBe(true)
})
})
describe('logoutParent()', () => {
it('clears isParentAuthenticated and isParentPersistent', () => {
authenticateParent(true)
expect(isParentAuthenticated.value).toBe(true)
logoutParent()
expect(isParentAuthenticated.value).toBe(false)
expect(isParentPersistent.value).toBe(false)
})
it('removing auth clears expiresAt and persistent flag', () => {
authenticateParent(true)
expect(parentAuthExpiresAt.value).not.toBeNull()
logoutParent()
expect(parentAuthExpiresAt.value).toBeNull()
expect(isParentPersistent.value).toBe(false)
})
it('clears parentAuthExpiresAt', () => {
authenticateParent(false)
logoutParent()
expect(parentAuthExpiresAt.value).toBeNull()
})
})
describe('loginUser()', () => {
it('loginUser() clears parent auth entirely', () => {
authenticateParent(true)
expect(isParentAuthenticated.value).toBe(true)
loginUser()
expect(isParentAuthenticated.value).toBe(false)
expect(isParentPersistent.value).toBe(false)
expect(parentAuthExpiresAt.value).toBeNull()
})
})
describe('localStorage restore on init', () => {
it('expired localStorage entry is cleaned up when checked', () => {
// Simulate a stored entry that is already expired
const expired = Date.now() - 1000
localStorage.setItem('parentAuth', JSON.stringify({ expiresAt: expired }))
// Mirroring the init logic in auth.ts: read, check, remove if stale
const stored = localStorage.getItem('parentAuth')
if (stored) {
const parsed = JSON.parse(stored) as { expiresAt: number }
if (!parsed.expiresAt || Date.now() >= parsed.expiresAt) {
localStorage.removeItem('parentAuth')
}
}
expect(localStorage.getItem('parentAuth')).toBeNull()
})
})
})

View File

@@ -1,44 +1,117 @@
import { ref, watch } from 'vue'
import { ref } from 'vue'
const hasLocalStorage =
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
const AUTH_SYNC_EVENT_KEY = 'authSyncEvent'
const PARENT_AUTH_KEY = 'parentAuth'
const PARENT_AUTH_EXPIRY_NON_PERSISTENT = 60_000 // 1 minute
const PARENT_AUTH_EXPIRY_PERSISTENT = 172_800_000 // 2 days
// --- Parent auth expiry state ---
export const isParentAuthenticated = ref(false)
export const isParentPersistent = ref(false)
export const parentAuthExpiresAt = ref<number | null>(null)
// Restore persistent parent auth from localStorage on store init
if (hasLocalStorage) {
try {
const stored = localStorage.getItem(PARENT_AUTH_KEY)
if (stored) {
const parsed = JSON.parse(stored) as { expiresAt: number }
if (parsed.expiresAt && Date.now() < parsed.expiresAt) {
parentAuthExpiresAt.value = parsed.expiresAt
isParentPersistent.value = true
isParentAuthenticated.value = true
} else {
localStorage.removeItem(PARENT_AUTH_KEY)
}
}
} catch {
localStorage.removeItem(PARENT_AUTH_KEY)
}
}
export const isParentAuthenticated = ref(
hasLocalStorage ? localStorage.getItem('isParentAuthenticated') === 'true' : false,
)
export const isUserLoggedIn = ref(false)
export const isAuthReady = ref(false)
export const currentUserId = ref('')
let authSyncInitialized = false
watch(isParentAuthenticated, (val) => {
if (hasLocalStorage && typeof localStorage.setItem === 'function') {
localStorage.setItem('isParentAuthenticated', val ? 'true' : 'false')
}
})
// --- Background expiry watcher ---
let expiryWatcherIntervalId: ReturnType<typeof setInterval> | null = null
export function authenticateParent() {
function runExpiryCheck() {
if (parentAuthExpiresAt.value !== null && Date.now() >= parentAuthExpiresAt.value) {
applyParentLoggedOutState()
if (typeof window !== 'undefined') {
window.location.href = '/child'
}
}
}
export function startParentExpiryWatcher() {
stopParentExpiryWatcher()
expiryWatcherIntervalId = setInterval(runExpiryCheck, 15_000)
}
export function stopParentExpiryWatcher() {
if (expiryWatcherIntervalId !== null) {
clearInterval(expiryWatcherIntervalId)
expiryWatcherIntervalId = null
}
}
/**
* Explicitly checks whether parent auth has expired and clears it if so.
* Called by the router guard before allowing /parent routes.
*/
export function enforceParentExpiry() {
runExpiryCheck()
}
export function authenticateParent(persistent: boolean) {
const duration = persistent ? PARENT_AUTH_EXPIRY_PERSISTENT : PARENT_AUTH_EXPIRY_NON_PERSISTENT
parentAuthExpiresAt.value = Date.now() + duration
isParentPersistent.value = persistent
isParentAuthenticated.value = true
if (persistent && hasLocalStorage && typeof localStorage.setItem === 'function') {
localStorage.setItem(PARENT_AUTH_KEY, JSON.stringify({ expiresAt: parentAuthExpiresAt.value }))
}
startParentExpiryWatcher()
}
export function logoutParent() {
applyParentLoggedOutState()
broadcastParentLogoutEvent()
}
function applyParentLoggedOutState() {
parentAuthExpiresAt.value = null
isParentPersistent.value = false
isParentAuthenticated.value = false
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
localStorage.removeItem('isParentAuthenticated')
localStorage.removeItem(PARENT_AUTH_KEY)
}
stopParentExpiryWatcher()
}
function broadcastParentLogoutEvent() {
if (!hasLocalStorage || typeof localStorage.setItem !== 'function') return
localStorage.setItem(
AUTH_SYNC_EVENT_KEY,
JSON.stringify({ type: 'parent_logout', at: Date.now() }),
)
}
export function loginUser() {
isUserLoggedIn.value = true
// Always start in child mode after login
isParentAuthenticated.value = false
applyParentLoggedOutState()
}
function applyLoggedOutState() {
isUserLoggedIn.value = false
currentUserId.value = ''
logoutParent()
applyParentLoggedOutState()
}
function broadcastLogoutEvent() {
@@ -64,6 +137,11 @@ export function initAuthSync() {
if (!window.location.pathname.startsWith('/auth')) {
window.location.href = '/auth/login'
}
} else if (payload?.type === 'parent_logout') {
applyParentLoggedOutState()
if (window.location.pathname.startsWith('/parent')) {
window.location.href = '/child'
}
}
} catch {
// Ignore malformed sync events.