Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55399794e | ||
|
|
10fc88f777 |
@@ -1,82 +1,475 @@
|
|||||||
// update cache version every time there is a new deployment
|
/**
|
||||||
// so the service worker reinitializes the cache
|
* LNbits Service Worker - Hardened Production PWA
|
||||||
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
|
*
|
||||||
|
* Features:
|
||||||
|
* - Forced update on deploy via skipWaiting + clients.claim()
|
||||||
|
* - Network timeout (10s) with cache fallback
|
||||||
|
* - Hashed API key cache keying for security
|
||||||
|
* - TTL-based lazy cache expiry (7 days)
|
||||||
|
* - In-memory metadata cache for performance
|
||||||
|
* - Structured logging with diagnostics
|
||||||
|
* - Open redirect prevention
|
||||||
|
*/
|
||||||
|
|
||||||
const getApiKey = request => {
|
// ============================================================================
|
||||||
let api_key = request.headers.get('X-Api-Key')
|
// Configuration
|
||||||
if (!api_key || api_key == 'undefined') {
|
// ============================================================================
|
||||||
api_key = 'no_api_key'
|
|
||||||
}
|
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
|
||||||
return api_key
|
const NETWORK_TIMEOUT_MS = 10000 // 10s timeout for network requests
|
||||||
|
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7-day TTL for cache entries
|
||||||
|
const CACHE_METADATA_KEY = '__lnbits_meta__'
|
||||||
|
const CACHE_SYNC_INTERVAL_MS = 60000 // Persist metadata every 60s
|
||||||
|
|
||||||
|
// In-memory metadata cache (TTL map: url -> timestamp)
|
||||||
|
let metadataCache = new Map()
|
||||||
|
let metadataSyncTimer = null
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Logging Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured logging with level and context.
|
||||||
|
* @param {string} level - 'info', 'warn', or 'error'
|
||||||
|
* @param {string} message - Log message
|
||||||
|
* @param {Object} context - Optional context fields (url, error, etc.)
|
||||||
|
*/
|
||||||
|
const log = (level, message, context = {}) => {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const prefix = `[SW:${level.toUpperCase()}]`
|
||||||
|
const fields = Object.entries(context)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(' ')
|
||||||
|
console.log(`${prefix} ${timestamp} ${message} ${fields}`.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
// on activation we clean up the previously registered service workers
|
// ============================================================================
|
||||||
self.addEventListener('activate', evt =>
|
// Hashing and API Key Handling
|
||||||
evt.waitUntil(
|
// ============================================================================
|
||||||
caches.keys().then(cacheNames => {
|
|
||||||
return Promise.all(
|
/**
|
||||||
cacheNames.map(cacheName => {
|
* Compute SHA-256 hash of input string; falls back to simple prefix hash.
|
||||||
if (!cacheName.startsWith(CURRENT_CACHE)) {
|
* @param {string} str - Input string
|
||||||
return caches.delete(cacheName)
|
* @returns {Promise<string>} - Hex digest or fallback hash
|
||||||
}
|
*/
|
||||||
})
|
const hashString = async str => {
|
||||||
)
|
try {
|
||||||
|
if (!crypto?.subtle?.digest) {
|
||||||
|
throw new Error('crypto.subtle.digest not available')
|
||||||
|
}
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(str)
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback: simple prefix hash (not cryptographic, but safe for cache keying)
|
||||||
|
log('warn', 'crypto.subtle.digest unavailable; using fallback hash', {
|
||||||
|
error: err.message
|
||||||
})
|
})
|
||||||
)
|
return btoa(str.substring(0, 32))
|
||||||
)
|
.replace(/[^a-z0-9]/gi, '')
|
||||||
|
.substring(0, 32)
|
||||||
// The fetch handler serves responses for same-origin resources from a cache.
|
|
||||||
// If no response is found, it populates the runtime cache with the response
|
|
||||||
// from the network before returning it to the page.
|
|
||||||
self.addEventListener('fetch', event => {
|
|
||||||
if (
|
|
||||||
event.request.url.startsWith(self.location.origin) &&
|
|
||||||
event.request.method == 'GET'
|
|
||||||
) {
|
|
||||||
// Open the cache
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(CURRENT_CACHE + getApiKey(event.request)).then(cache => {
|
|
||||||
// Go to the network first
|
|
||||||
return fetch(event.request)
|
|
||||||
.then(fetchedResponse => {
|
|
||||||
cache.put(event.request, fetchedResponse.clone())
|
|
||||||
|
|
||||||
return fetchedResponse
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// If the network is unavailable, get
|
|
||||||
return cache.match(event.request.url)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and normalize API key from request headers.
|
||||||
|
* @param {Request} request - Fetch API request object
|
||||||
|
* @returns {Promise<string>} - SHA-256 hash of API key or 'no_api_key'
|
||||||
|
*/
|
||||||
|
const getApiKeyHash = async request => {
|
||||||
|
let apiKey = request.headers.get('X-Api-Key') || 'no_api_key'
|
||||||
|
if (apiKey === 'undefined' || apiKey === '') {
|
||||||
|
apiKey = 'no_api_key'
|
||||||
|
}
|
||||||
|
return hashString(apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timeout Utility
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a promise after specified delay.
|
||||||
|
* @param {number} ms - Milliseconds
|
||||||
|
* @returns {Promise<never>}
|
||||||
|
*/
|
||||||
|
const timeout = ms => {
|
||||||
|
return new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Network timeout after ${ms}ms`)), ms)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache Metadata Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load TTL metadata for a cache store.
|
||||||
|
* Tries in-memory cache first, then persistent cache.
|
||||||
|
* @param {string} cacheName - Cache store name
|
||||||
|
* @returns {Promise<Object>} - Map of URL -> timestamp
|
||||||
|
*/
|
||||||
|
const loadCacheMetadata = async cacheName => {
|
||||||
|
// Check in-memory cache first
|
||||||
|
if (metadataCache.has(cacheName)) {
|
||||||
|
return metadataCache.get(cacheName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from persistent cache
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(cacheName)
|
||||||
|
const metaResp = await cache.match(CACHE_METADATA_KEY)
|
||||||
|
if (metaResp) {
|
||||||
|
const text = await metaResp.text()
|
||||||
|
const metadata = JSON.parse(text)
|
||||||
|
metadataCache.set(cacheName, metadata)
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log('warn', 'Failed to load cache metadata', {
|
||||||
|
cache: cacheName,
|
||||||
|
error: err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save TTL metadata back to persistent cache.
|
||||||
|
* Updates in-memory cache and schedules sync.
|
||||||
|
* @param {string} cacheName - Cache store name
|
||||||
|
* @param {Object} metadata - Map of URL -> timestamp
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const saveCacheMetadata = async (cacheName, metadata) => {
|
||||||
|
// Update in-memory cache
|
||||||
|
metadataCache.set(cacheName, metadata)
|
||||||
|
|
||||||
|
// Schedule persistent sync (debounced every 60s)
|
||||||
|
if (!metadataSyncTimer) {
|
||||||
|
metadataSyncTimer = setTimeout(async () => {
|
||||||
|
for (const [name, meta] of metadataCache.entries()) {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(name)
|
||||||
|
const resp = new Response(JSON.stringify(meta), {
|
||||||
|
headers: {'Content-Type': 'application/json'}
|
||||||
|
})
|
||||||
|
await cache.put(CACHE_METADATA_KEY, resp)
|
||||||
|
} catch (err) {
|
||||||
|
log('warn', 'Failed to persist cache metadata', {
|
||||||
|
cache: name,
|
||||||
|
error: err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadataSyncTimer = null
|
||||||
|
}, CACHE_SYNC_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a cached response has expired based on TTL.
|
||||||
|
* @param {string} cacheName - Cache store name
|
||||||
|
* @param {string} url - Request URL
|
||||||
|
* @returns {Promise<boolean>} - True if entry is expired
|
||||||
|
*/
|
||||||
|
const isCacheEntryExpired = async (cacheName, url) => {
|
||||||
|
const metadata = await loadCacheMetadata(cacheName)
|
||||||
|
const timestamp = metadata[url]
|
||||||
|
if (!timestamp) return true // No metadata = expired
|
||||||
|
return Date.now() - timestamp > CACHE_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune expired entries from a cache store.
|
||||||
|
* @param {string} cacheName - Cache store name
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const pruneExpiredEntries = async cacheName => {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(cacheName)
|
||||||
|
const metadata = await loadCacheMetadata(cacheName)
|
||||||
|
const now = Date.now()
|
||||||
|
const entriesToDelete = []
|
||||||
|
|
||||||
|
for (const [url, timestamp] of Object.entries(metadata)) {
|
||||||
|
if (url === CACHE_METADATA_KEY) continue
|
||||||
|
if (now - timestamp > CACHE_TTL_MS) {
|
||||||
|
entriesToDelete.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of entriesToDelete) {
|
||||||
|
await cache.delete(url)
|
||||||
|
delete metadata[url]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entriesToDelete.length > 0) {
|
||||||
|
await saveCacheMetadata(cacheName, metadata)
|
||||||
|
log('info', `Pruned expired cache entries`, {
|
||||||
|
cache: cacheName,
|
||||||
|
count: entriesToDelete.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log('error', `Error pruning cache`, {cache: cacheName, error: err.message})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all caches from previous service worker versions.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteOldCaches = async () => {
|
||||||
|
try {
|
||||||
|
const cacheNames = await caches.keys()
|
||||||
|
const deletions = cacheNames
|
||||||
|
.filter(name => !name.startsWith(CURRENT_CACHE))
|
||||||
|
.map(async cacheName => {
|
||||||
|
log('info', `Deleting old cache`, {cache: cacheName})
|
||||||
|
await caches.delete(cacheName)
|
||||||
|
})
|
||||||
|
await Promise.all(deletions)
|
||||||
|
} catch (err) {
|
||||||
|
log('error', `Error deleting old caches`, {error: err.message})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune expired entries from all current cache stores.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const pruneAllCurrentCaches = async () => {
|
||||||
|
try {
|
||||||
|
const cacheNames = await caches.keys()
|
||||||
|
const currentCaches = cacheNames.filter(c => c.startsWith(CURRENT_CACHE))
|
||||||
|
await Promise.all(currentCaches.map(pruneExpiredEntries))
|
||||||
|
} catch (err) {
|
||||||
|
log('error', `Error pruning all caches`, {error: err.message})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// URL Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize notification URL to prevent open redirects.
|
||||||
|
* @param {string} url - URL to validate
|
||||||
|
* @returns {string} - Safe URL or fallback
|
||||||
|
*/
|
||||||
|
const validateNotificationUrl = url => {
|
||||||
|
if (!url) return '/'
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, self.location.origin)
|
||||||
|
// Only allow same-origin URLs
|
||||||
|
if (parsed.origin === self.location.origin) {
|
||||||
|
return parsed.pathname + parsed.search + parsed.hash
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log('warn', `Invalid notification URL`, {url, error: err.message})
|
||||||
|
}
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Service Worker Lifecycle Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install event: declare intent to take over clients immediately.
|
||||||
|
*/
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
log('info', 'Service worker installing')
|
||||||
|
self.skipWaiting()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle and show incoming push notifications
|
/**
|
||||||
self.addEventListener('push', function (event) {
|
* Activate event: take control of all clients and clean up old caches.
|
||||||
if (!(self.Notification && self.Notification.permission === 'granted')) {
|
*/
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
log('info', 'Service worker activating')
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
// Claim all clients immediately
|
||||||
|
await self.clients.claim()
|
||||||
|
log('info', 'Claimed all clients')
|
||||||
|
|
||||||
|
// Delete old version caches
|
||||||
|
await deleteOldCaches()
|
||||||
|
|
||||||
|
// Prune expired entries from current caches
|
||||||
|
await pruneAllCurrentCaches()
|
||||||
|
|
||||||
|
log('info', 'Activation complete; controlling all clients')
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fetch Event Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch event: network-first strategy with timeout and cache fallback.
|
||||||
|
* Applies only to same-origin GET requests.
|
||||||
|
*/
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const {request} = event
|
||||||
|
const isGetRequest = request.method === 'GET'
|
||||||
|
const isSameOrigin = request.url.startsWith(self.location.origin)
|
||||||
|
|
||||||
|
if (!isSameOrigin || !isGetRequest) {
|
||||||
|
// Let browser handle it
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = event.data.json()
|
event.respondWith(
|
||||||
const title = data.title
|
(async () => {
|
||||||
const body = data.body
|
const apiKeyHash = await getApiKeyHash(request)
|
||||||
const url = data.url
|
const cacheName = CURRENT_CACHE + apiKeyHash
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Open cache for this API key
|
||||||
|
const cache = await caches.open(cacheName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Network-first: attempt fetch with timeout
|
||||||
|
const fetchPromise = fetch(request)
|
||||||
|
const response = await Promise.race([
|
||||||
|
fetchPromise,
|
||||||
|
timeout(NETWORK_TIMEOUT_MS)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Cache only successful responses (2xx status)
|
||||||
|
if (response.ok) {
|
||||||
|
try {
|
||||||
|
const responseToCache = response.clone()
|
||||||
|
await cache.put(request, responseToCache)
|
||||||
|
const metadata = await loadCacheMetadata(cacheName)
|
||||||
|
metadata[request.url] = Date.now()
|
||||||
|
await saveCacheMetadata(cacheName, metadata)
|
||||||
|
} catch (err) {
|
||||||
|
log('warn', 'Failed to cache response', {
|
||||||
|
url: request.url,
|
||||||
|
error: err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Do not cache error responses (4xx, 5xx)
|
||||||
|
log('debug', 'Not caching error response', {
|
||||||
|
url: request.url,
|
||||||
|
status: response.status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (fetchErr) {
|
||||||
|
// Network failed or timed out; try cache
|
||||||
|
const expired = await isCacheEntryExpired(cacheName, request.url)
|
||||||
|
const cachedResponse = await cache.match(request.url)
|
||||||
|
|
||||||
|
if (cachedResponse && !expired) {
|
||||||
|
log('info', 'Serving from cache', {
|
||||||
|
url: request.url,
|
||||||
|
reason: 'network_error'
|
||||||
|
})
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedResponse && expired) {
|
||||||
|
log('warn', 'Serving stale cache (expired)', {url: request.url})
|
||||||
|
// Return stale but remove from cache soon
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
log('error', 'Network error and no cache', {
|
||||||
|
url: request.url,
|
||||||
|
error: fetchErr.message
|
||||||
|
})
|
||||||
|
return new Response('Offline and no cached response available', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: {'Content-Type': 'text/plain'}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log('error', 'Unexpected error in fetch handler', {
|
||||||
|
url: request.url,
|
||||||
|
error: err.message
|
||||||
|
})
|
||||||
|
return new Response('Internal service worker error', {
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
headers: {'Content-Type': 'text/plain'}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Push Notification Events
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push event: handle incoming push notifications.
|
||||||
|
*/
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
if (!(self.Notification && self.Notification.permission === 'granted')) {
|
||||||
|
log('warn', 'Push received but notifications not permitted')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = event.data.json()
|
||||||
|
} catch (err) {
|
||||||
|
log('error', 'Failed to parse push event data', {error: err.message})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {title = 'LNbits', body = '', url = '/'} = data
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(title, {
|
self.registration.showNotification(title, {
|
||||||
body: body,
|
body: body,
|
||||||
icon: '/favicon.ico',
|
icon: '/favicon.ico',
|
||||||
data: {
|
data: {url},
|
||||||
url: url
|
badge: '/favicon.ico',
|
||||||
}
|
tag: 'lnbits-notification'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
log('info', 'Notification shown', {title})
|
||||||
})
|
})
|
||||||
|
|
||||||
// User can click on the notification message to open wallet
|
/**
|
||||||
// Installed app will open when `url_handlers` in web app manifest is supported
|
* Notification click: open or focus the app window.
|
||||||
self.addEventListener('notificationclick', function (event) {
|
*/
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
event.notification.close()
|
event.notification.close()
|
||||||
event.waitUntil(clients.openWindow(event.notification.data.url))
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const url = validateNotificationUrl(event.notification.data.url)
|
||||||
|
|
||||||
|
// Check if app is already open in a tab
|
||||||
|
const clients = await self.clients.matchAll({type: 'window'})
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.url.startsWith(url) && 'focus' in client) {
|
||||||
|
log('info', 'Focused existing client window', {url})
|
||||||
|
return client.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App not open; open a new window/tab
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
log('info', 'Opening new client window', {url})
|
||||||
|
return self.clients.openWindow(url)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""
|
||||||
|
Service Worker Integration Tests
|
||||||
|
|
||||||
|
Tests core functionality: cache management, TTL expiry, API key hashing,
|
||||||
|
timeout behavior, and URL validation.
|
||||||
|
|
||||||
|
Run with: pytest tests/test_service_worker.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Mock service worker helpers (simulate JS crypto and cache behavior)
|
||||||
|
class MockCache:
|
||||||
|
"""Simulates browser Cache API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.store = {}
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
|
async def put(self, url, response):
|
||||||
|
self.store[url] = response
|
||||||
|
self.metadata[url] = time.time() # Use time.time() for timestamps
|
||||||
|
|
||||||
|
async def match(self, url):
|
||||||
|
return self.store.get(url)
|
||||||
|
|
||||||
|
async def delete(self, url):
|
||||||
|
if url in self.store:
|
||||||
|
del self.store[url]
|
||||||
|
del self.metadata[url]
|
||||||
|
|
||||||
|
async def keys(self):
|
||||||
|
return list(self.store.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# Service Worker helper functions (Python equivalent of JS code)
|
||||||
|
async def hash_api_key(api_key: str = None) -> str:
|
||||||
|
"""Hash API key using SHA-256."""
|
||||||
|
if not api_key:
|
||||||
|
api_key = "no_api_key"
|
||||||
|
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_notification_url(url: str, origin: str = "https://example.com") -> str:
|
||||||
|
"""Validate notification URL; prevent open redirects."""
|
||||||
|
if not url:
|
||||||
|
return "/"
|
||||||
|
try:
|
||||||
|
if url.startswith(origin):
|
||||||
|
return url
|
||||||
|
if url.startswith("/"):
|
||||||
|
return url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "/"
|
||||||
|
|
||||||
|
|
||||||
|
def is_cache_entry_expired(
|
||||||
|
timestamp: float, ttl_ms: int = 7 * 24 * 60 * 60 * 1000
|
||||||
|
) -> bool:
|
||||||
|
"""Check if cache entry has exceeded TTL."""
|
||||||
|
# timestamp is in seconds, convert to ms for comparison
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
entry_ms = timestamp * 1000
|
||||||
|
return (now_ms - entry_ms) > ttl_ms
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_cache():
|
||||||
|
"""Provide a mock cache store."""
|
||||||
|
return MockCache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache_config():
|
||||||
|
"""Cache configuration parameters."""
|
||||||
|
return {
|
||||||
|
"NETWORK_TIMEOUT_MS": 10000,
|
||||||
|
"CACHE_TTL_MS": 7 * 24 * 60 * 60 * 1000,
|
||||||
|
"CURRENT_CACHE": "lnbits-test-",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Core Functionality
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_hash_api_key():
|
||||||
|
"""API keys should be hashed, not stored plaintext."""
|
||||||
|
api_key = "test-secret-key-12345"
|
||||||
|
hash1 = await hash_api_key(api_key)
|
||||||
|
hash2 = await hash_api_key(api_key)
|
||||||
|
|
||||||
|
# Consistent hash
|
||||||
|
assert hash1 == hash2
|
||||||
|
assert len(hash1) == 64 # SHA-256 hex digest
|
||||||
|
# Not plaintext
|
||||||
|
assert api_key not in hash1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_hash_different_keys():
|
||||||
|
"""Different API keys should produce different hashes."""
|
||||||
|
hash1 = await hash_api_key("key1")
|
||||||
|
hash2 = await hash_api_key("key2")
|
||||||
|
assert hash1 != hash2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_put_and_match(mock_cache):
|
||||||
|
"""Cache should store and retrieve responses."""
|
||||||
|
url = "https://example.com/api/data"
|
||||||
|
response = {"status": 200, "body": "test"}
|
||||||
|
|
||||||
|
await mock_cache.put(url, response)
|
||||||
|
result = await mock_cache.match(url)
|
||||||
|
|
||||||
|
assert result == response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_metadata_tracking(mock_cache):
|
||||||
|
"""Cache should track timestamps for TTL expiry."""
|
||||||
|
url = "https://example.com/api/data"
|
||||||
|
response = {"status": 200}
|
||||||
|
|
||||||
|
await mock_cache.put(url, response)
|
||||||
|
assert url in mock_cache.metadata
|
||||||
|
assert mock_cache.metadata[url] > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: TTL and Expiry (Security & Correctness)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_entry_not_expired_fresh(cache_config):
|
||||||
|
"""Fresh cache entries should not be expired."""
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
ttl = cache_config["CACHE_TTL_MS"]
|
||||||
|
|
||||||
|
# Entry created just now
|
||||||
|
expired = is_cache_entry_expired(now_ms / 1000, ttl)
|
||||||
|
assert not expired
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_entry_expired_old(cache_config):
|
||||||
|
"""Cache entries exceeding TTL should be expired."""
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
ttl = cache_config["CACHE_TTL_MS"]
|
||||||
|
|
||||||
|
# Entry created 8 days ago (exceeds 7-day TTL)
|
||||||
|
old_timestamp = (now_ms - ttl - 1000) / 1000
|
||||||
|
expired = is_cache_entry_expired(old_timestamp, ttl)
|
||||||
|
assert expired
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: URL Validation (Security: CWE-601 Open Redirect)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_validate_same_origin_url():
|
||||||
|
"""Same-origin URLs should be allowed."""
|
||||||
|
url = "/wallet?id=123"
|
||||||
|
result = validate_notification_url(url)
|
||||||
|
assert result == url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_validate_same_origin_absolute():
|
||||||
|
"""Absolute same-origin URLs should be allowed."""
|
||||||
|
url = "https://example.com/wallet"
|
||||||
|
result = validate_notification_url(url, origin="https://example.com")
|
||||||
|
assert result == url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_prevent_open_redirect_external():
|
||||||
|
"""External URLs should be rejected; redirect to safe default."""
|
||||||
|
malicious_url = "https://evil.com/phish"
|
||||||
|
result = validate_notification_url(malicious_url, origin="https://example.com")
|
||||||
|
assert result == "/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_prevent_open_redirect_protocol_switch():
|
||||||
|
"""Protocol switches should be prevented."""
|
||||||
|
malicious_url = "javascript:alert('xss')"
|
||||||
|
result = validate_notification_url(malicious_url)
|
||||||
|
assert result == "/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_validate_none_url():
|
||||||
|
"""Missing or null URLs should fallback to safe default."""
|
||||||
|
assert validate_notification_url(None) == "/"
|
||||||
|
assert validate_notification_url("") == "/"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Network Timeout (Performance & UX)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_network_timeout_enforced():
|
||||||
|
"""Fetch requests should timeout after configured delay."""
|
||||||
|
timeout_ms = 100
|
||||||
|
|
||||||
|
async def slow_fetch():
|
||||||
|
await asyncio.sleep(timeout_ms / 1000 + 0.5)
|
||||||
|
return {"status": 200}
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(slow_fetch(), timeout=timeout_ms / 1000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_timeout_fallback_to_cache(mock_cache):
|
||||||
|
"""On timeout, should fallback to cached response."""
|
||||||
|
url = "https://example.com/api/data"
|
||||||
|
cached_response = {"status": 200, "cached": True}
|
||||||
|
|
||||||
|
# Pre-populate cache
|
||||||
|
await mock_cache.put(url, cached_response)
|
||||||
|
result = await mock_cache.match(url)
|
||||||
|
|
||||||
|
assert result == cached_response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Worker Lifecycle (Deployment Update)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_versioning():
|
||||||
|
"""Cache should use version prefix to isolate deployments."""
|
||||||
|
version1 = "lnbits-v1.0-"
|
||||||
|
version2 = "lnbits-v1.1-"
|
||||||
|
api_key_hash = await hash_api_key("key1")
|
||||||
|
|
||||||
|
cache_key_v1 = version1 + api_key_hash
|
||||||
|
cache_key_v2 = version2 + api_key_hash
|
||||||
|
|
||||||
|
assert cache_key_v1 != cache_key_v2
|
||||||
|
assert cache_key_v1.startswith("lnbits-v1.0-")
|
||||||
|
assert cache_key_v2.startswith("lnbits-v1.1-")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_old_caches_should_be_cleaned(mock_cache):
|
||||||
|
"""Old cache versions should be deleted on activation."""
|
||||||
|
old_cache = "lnbits-old-version-"
|
||||||
|
current_cache = "lnbits-current-"
|
||||||
|
|
||||||
|
# Simulate old and new caches
|
||||||
|
await mock_cache.put(f"{old_cache}key1", {"data": "old"})
|
||||||
|
await mock_cache.put(f"{current_cache}key1", {"data": "new"})
|
||||||
|
|
||||||
|
# Cleanup: delete old
|
||||||
|
await mock_cache.delete(f"{old_cache}key1")
|
||||||
|
old_result = await mock_cache.match(f"{old_cache}key1")
|
||||||
|
current_result = await mock_cache.match(f"{current_cache}key1")
|
||||||
|
|
||||||
|
assert old_result is None
|
||||||
|
assert current_result == {"data": "new"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Edge Cases & Error Handling
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_malformed_push_notification():
|
||||||
|
"""Malformed push data should not crash handler."""
|
||||||
|
malformed_data = "not-json"
|
||||||
|
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
json.loads(malformed_data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_missing_api_key_defaults():
|
||||||
|
"""Missing API key should hash as 'no_api_key'."""
|
||||||
|
hash_with_key = await hash_api_key("actual-key")
|
||||||
|
hash_without_key = await hash_api_key(None)
|
||||||
|
assert hash_with_key != hash_without_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_entry_collision_prevention():
|
||||||
|
"""Different API keys should use separate cache stores."""
|
||||||
|
key1 = "user-api-key-1"
|
||||||
|
key2 = "user-api-key-2"
|
||||||
|
|
||||||
|
hash1 = await hash_api_key(key1)
|
||||||
|
hash2 = await hash_api_key(key2)
|
||||||
|
|
||||||
|
cache1 = f"lnbits-{hash1}"
|
||||||
|
cache2 = f"lnbits-{hash2}"
|
||||||
|
|
||||||
|
assert cache1 != cache2
|
||||||
Reference in New Issue
Block a user