diff --git a/app/admin-api/notifications/bark/test/route.ts b/app/admin-api/notifications/bark/test/route.ts new file mode 100644 index 00000000..c322d4d3 --- /dev/null +++ b/app/admin-api/notifications/bark/test/route.ts @@ -0,0 +1,36 @@ +import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; +import { sendBarkNotification } from '@/lib/server/domain/credential-expiry-notifications'; +import { getJsonBody } from '@/lib/server/shared/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const POST = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + + if (authError) return authError; + + const body = await getJsonBody<{ token?: unknown }>(request); + const token = typeof body.token === 'string' ? body.token.trim() : ''; + + if (!token) { + return Response.json({ error: 'Bark token is required' }, { status: 400 }); + } + + try { + await sendBarkNotification({ + body: '这是一条 CodeBuddy2API Bark 测试通知。', + title: 'CodeBuddy2API Bark 测试', + token, + }); + return Response.json({ message: 'Test notification sent.' }); + } catch (error) { + return Response.json( + { + error: + error instanceof Error ? error.message : 'Bark notification failed', + }, + { status: 502 }, + ); + } +}; diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 4190c8b9..95f484ef 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -166,7 +166,7 @@ interface PollAuthResponse { interface SettingsResponse { labels?: Record; - settings?: Record; + settings?: Record; } interface DebugResponse { @@ -1414,7 +1414,7 @@ const AdminPageLayoutContent = ({ const result = await requestJson<{ message?: string; - settings?: Record; + settings?: Record; }>('/admin-api/settings', { body: JSON.stringify({ settings: settings.values, diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index 1497b05c..a55c15dc 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -1,12 +1,13 @@ 'use client'; import { Block, Flexbox, Input, TextArea } from '@lobehub/ui'; -import { Button, Select } from '@lobehub/ui/base-ui'; +import { Button, Select, Switch } from '@lobehub/ui/base-ui'; import { Table } from 'antd'; import type { TableColumnsType } from 'antd'; import { atom } from 'jotai'; import { Layers3, + BellRing, LoaderCircle, RefreshCw, Save, @@ -67,7 +68,7 @@ export const createSettingsState = ({ }; export interface SettingsController { - onChange: (key: string, value: string) => void; + onChange: (key: string, value: string | boolean) => void; onSave: () => void; settings: SettingsState; } @@ -107,6 +108,10 @@ const settingsSelectOptions: Record< { label: 'true', value: 'true' }, { label: 'false', value: 'false' }, ], + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: [ + { label: 'true', value: 'true' }, + { label: 'false', value: 'false' }, + ], }; /** @@ -183,6 +188,11 @@ const DESKTOP_HIDDEN_CONFIG_KEYS = new Set([ 'CODEBUDDY_ADMIN_PASSKEY_RP_ID', ]); +const CUSTOM_CONFIG_KEYS = new Set([ + 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + 'CODEBUDDY_BARK_TOKEN', +]); + /** * Maps a setting key to its helper text. * @@ -254,6 +264,114 @@ const SettingField = ({ ); }; +const CredentialExpiryNotificationSettings = ({ + onChange, + settings, + translations, +}: { + onChange: (key: string, value: string | boolean) => void; + settings: SettingsState; + translations: (key: string) => string; +}) => { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<'success' | 'error' | null>( + null, + ); + const token = String(settings.values.CODEBUDDY_BARK_TOKEN ?? ''); + const enabled = + settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED === + true || + settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED === + 'true'; + + const testNotification = async () => { + setTesting(true); + setTestResult(null); + + try { + const response = await fetch('/admin-api/notifications/bark/test', { + body: JSON.stringify({ token }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + setTestResult(response.ok ? 'success' : 'error'); + } catch { + setTestResult('error'); + } finally { + setTesting(false); + } + }; + + return ( + + + +

+ {translations('settingsPanel.credentialExpiryNotificationsTitle')} +

+
+

+ {translations('settingsPanel.credentialExpiryNotificationsDescription')} +

+ + + {translations('settingsPanel.credentialExpiryNotificationsEnabled')} + + + onChange( + 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + checked, + ) + } + /> + +
+ + + onChange('CODEBUDDY_BARK_TOKEN', event.target.value) + } + placeholder={translations('settingsPanel.barkTokenPlaceholder')} + type="password" + value={token} + /> +

+ + {translations('settingsPanel.barkLink')} + +

+
+ + + {testResult === 'success' ? ( + + {translations('settingsPanel.testBarkSuccess')} + + ) : null} + {testResult === 'error' ? ( + + {translations('settingsPanel.testBarkFailed')} + + ) : null} + +
+ ); +}; + /** * The settings one backend needs, rendered under the picker that selected it. * @@ -709,6 +827,7 @@ const Settings = ({ // A backend's own settings are rendered by its picker, which // knows which backend they belong to. .filter(([settingKey]) => !BACKEND_CONFIG_KEYS.has(settingKey)) + .filter(([settingKey]) => !CUSTOM_CONFIG_KEYS.has(settingKey)) .filter(([settingKey]) => desktop ? !DESKTOP_HIDDEN_CONFIG_KEYS.has(settingKey) : true, ) @@ -763,6 +882,11 @@ const Settings = ({ + diff --git a/electron/main.ts b/electron/main.ts index d1819d3f..538c7390 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1006,7 +1006,12 @@ const writeConsolePreferences = async (cookies: Cookie[]): Promise => { refreshMenus(); - if (mainWindow && !mainWindow.isDestroyed() && consoleOrigin) { + if ( + mainWindow && + !mainWindow.isDestroyed() && + consoleOrigin && + (backend.mode === 'remote' || gateway !== null) + ) { mainWindow.reload(); } }; diff --git a/instrumentation.ts b/instrumentation.ts index a0e72557..a965cf39 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -5,10 +5,13 @@ export const register = async (): Promise => { await import('@/lib/server/domain/credential-models'); const { startAutoCheckinScheduler } = await import('@/lib/server/domain/auto-checkin-scheduler'); + const { startCredentialExpiryNotificationScheduler } = + await import('@/lib/server/domain/credential-expiry-notifications'); void refreshMissingCredentialModels(); // Only in the server process: a build or an edge runtime must not start a // timer that would outlive the request it belongs to. startAutoCheckinScheduler(); + startCredentialExpiryNotificationScheduler(); }; diff --git a/lib/server/domain/config.ts b/lib/server/domain/config.ts index a6f8bd40..c7504ddc 100644 --- a/lib/server/domain/config.ts +++ b/lib/server/domain/config.ts @@ -31,6 +31,8 @@ export interface RuntimeConfig { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa' | 'internal' | 'public'; CODEBUDDY_LOG_LEVEL: string; CODEBUDDY_API_TIMEOUT_MINUTES: number; + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: boolean; + CODEBUDDY_BARK_TOKEN: string; CODEBUDDY_WEB_SEARCH_BACKEND: string; CODEBUDDY_WEB_FETCH_BACKEND: string; CODEBUDDY_SEARXNG_URL: string; @@ -68,6 +70,8 @@ const DEFAULT_CONFIG: RuntimeConfig = { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa', CODEBUDDY_LOG_LEVEL: 'INFO', CODEBUDDY_API_TIMEOUT_MINUTES: DEFAULT_API_TIMEOUT_MINUTES, + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: false, + CODEBUDDY_BARK_TOKEN: '', CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy', CODEBUDDY_SEARXNG_URL: '', @@ -96,6 +100,9 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_INTERNET_ENVIRONMENT: 'Network environment (internal/ioa/public)', CODEBUDDY_LOG_LEVEL: 'Log level', CODEBUDDY_API_TIMEOUT_MINUTES: 'API timeout, first token (minutes)', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: + 'Credential expiry notifications', + CODEBUDDY_BARK_TOKEN: 'Bark device key', CODEBUDDY_WEB_SEARCH_BACKEND: 'Web search backend', CODEBUDDY_WEB_FETCH_BACKEND: 'Web fetch backend', CODEBUDDY_SEARXNG_URL: 'SearXNG instance address (with port)', @@ -119,6 +126,8 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_INTERNET_ENVIRONMENT: 'ネットワーク環境 (internal/ioa/public)', CODEBUDDY_LOG_LEVEL: 'ログレベル', CODEBUDDY_API_TIMEOUT_MINUTES: 'API タイムアウト・最初のトークン (分)', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: '認証情報の有効期限通知', + CODEBUDDY_BARK_TOKEN: 'Bark デバイスキー', CODEBUDDY_WEB_SEARCH_BACKEND: 'Web 検索バックエンド', CODEBUDDY_WEB_FETCH_BACKEND: 'Web フェッチバックエンド', CODEBUDDY_SEARXNG_URL: 'SearXNG インスタンスのアドレス(ポートを含む)', @@ -141,6 +150,8 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_INTERNET_ENVIRONMENT: '网络环境 (internal/ioa/public)', CODEBUDDY_LOG_LEVEL: '日志级别', CODEBUDDY_API_TIMEOUT_MINUTES: 'API 超时时间,首个 token(分钟)', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: '凭据过期通知', + CODEBUDDY_BARK_TOKEN: 'Bark Token', CODEBUDDY_WEB_SEARCH_BACKEND: 'WebSearch 后端', CODEBUDDY_WEB_FETCH_BACKEND: 'WebFetch 后端', CODEBUDDY_SEARXNG_URL: 'SearXNG 实例地址(含端口)', @@ -287,6 +298,15 @@ export const getActiveConfig = async (): Promise => { persisted.CODEBUDDY_API_TIMEOUT_MINUTES ?? process.env.CODEBUDDY_API_TIMEOUT_MINUTES, ), + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: normalizeValue( + 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + persisted.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED ?? + process.env.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED, + ), + CODEBUDDY_BARK_TOKEN: normalizeValue( + 'CODEBUDDY_BARK_TOKEN', + persisted.CODEBUDDY_BARK_TOKEN ?? process.env.CODEBUDDY_BARK_TOKEN, + ), CODEBUDDY_WEB_SEARCH_BACKEND: normalizeValue( 'CODEBUDDY_WEB_SEARCH_BACKEND', persisted.CODEBUDDY_WEB_SEARCH_BACKEND ?? diff --git a/lib/server/domain/credential-expiry-notifications.ts b/lib/server/domain/credential-expiry-notifications.ts new file mode 100644 index 00000000..097b4c52 --- /dev/null +++ b/lib/server/domain/credential-expiry-notifications.ts @@ -0,0 +1,239 @@ +import { getActiveConfig } from './config'; +import { getCredentialExpiry, readCredentialRecords } from './credentials'; +import { readStorageJson, writeStorageJson } from '../storage'; + +export const BARK_URL = 'https://api.day.app/push'; + +export const CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS = [ + { key: 'three_days', seconds: 3 * 24 * 60 * 60, label: '还有 3 天' }, + { key: 'one_day', seconds: 24 * 60 * 60, label: '还有 1 天' }, + { key: 'expired', seconds: 0, label: '已过期' }, +] as const; + +type ExpiryNotificationKey = + (typeof CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS)[number]['key']; + +interface ExpiryNotificationState { + expiresAt: number; + sent: Partial>; +} + +type ExpiryNotificationStates = Record; + +const STATE_FILENAME = 'expiry_notifications.json'; +const TICK_INTERVAL_MS = 30 * 60 * 1000; +const START_DELAY_MS = 10 * 1000; + +const globalSchedulerState = globalThis as typeof globalThis & { + __codebuddy2apiCredentialExpiryNotifications__?: { + timer: ReturnType; + }; +}; + +let stateMutationQueue: Promise = Promise.resolve(); + +const loadNotificationStates = async (): Promise => { + const saved = await readStorageJson('credentials', STATE_FILENAME); + + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return {}; + + return Object.entries( + saved as Record, + ).reduce((result, [filename, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return result; + } + + const expiresAt = Number((value as Record).expiresAt); + const sent = (value as Record).sent; + + if (!Number.isFinite(expiresAt) || !sent || typeof sent !== 'object') { + return result; + } + + result[filename] = { + expiresAt, + sent: Object.fromEntries( + CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.filter(({ key }) => + Boolean((sent as Record)[key]), + ).map(({ key }) => [key, true]), + ), + }; + return result; + }, {}); +}; + +const saveNotificationStates = async ( + states: ExpiryNotificationStates, +): Promise => { + await writeStorageJson('credentials', STATE_FILENAME, states); +}; + +const updateNotificationStates = async ( + mutation: (states: ExpiryNotificationStates) => void, +): Promise => { + const operation = stateMutationQueue.then(async () => { + const states = await loadNotificationStates(); + mutation(states); + await saveNotificationStates(states); + return states; + }); + stateMutationQueue = operation.then( + () => undefined, + () => undefined, + ); + return operation; +}; + +export const sendBarkNotification = async ({ + body, + token, + title, +}: { + body: string; + token: string; + title: string; +}): Promise => { + const normalizedToken = token.trim(); + + if (!normalizedToken) { + throw new Error('Bark token is empty'); + } + + const response = await fetch(BARK_URL, { + body: JSON.stringify({ + body, + device_key: normalizedToken, + group: 'CodeBuddy2API', + title, + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`Bark returned ${response.status}`); + } +}; + +const credentialName = (credential: { + data: Record; + filename: string; +}): string => { + const userInfo = credential.data.user_info; + const email = + userInfo && typeof userInfo === 'object' + ? (userInfo as Record).email + : undefined; + + return String(email ?? credential.data.user_id ?? credential.filename); +}; + +export const runCredentialExpiryNotificationTick = async ( + now: Date = new Date(), +): Promise => { + const config = await getActiveConfig(); + const token = config.CODEBUDDY_BARK_TOKEN.trim(); + + if (!config.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED || !token) { + return []; + } + + const states = await loadNotificationStates(); + const nowSeconds = Math.floor(now.getTime() / 1000); + const sentNotifications: string[] = []; + + for (const credential of await readCredentialRecords()) { + const expiresAt = getCredentialExpiry(credential.data); + + if (expiresAt === null) continue; + + const currentState = states[credential.filename]; + const state: ExpiryNotificationState = + currentState?.expiresAt === expiresAt + ? currentState + : { expiresAt, sent: {} }; + const remaining = expiresAt - nowSeconds; + + const threshold = + remaining <= 0 + ? CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.find( + ({ key }) => key === 'expired', + ) + : remaining <= 24 * 60 * 60 + ? CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.find( + ({ key }) => key === 'one_day', + ) + : remaining <= 3 * 24 * 60 * 60 + ? CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.find( + ({ key }) => key === 'three_days', + ) + : undefined; + + if (!threshold || state.sent[threshold.key]) continue; + + const name = credentialName(credential); + + try { + await sendBarkNotification({ + body: `凭据 ${name} ${threshold.label}。过期时间:${new Date( + expiresAt * 1000, + ).toLocaleString()}`, + title: 'CodeBuddy2API 凭据提醒', + token, + }); + state.sent[threshold.key] = true; + states[credential.filename] = state; + await updateNotificationStates((saved) => { + saved[credential.filename] = state; + }); + sentNotifications.push(`${credential.filename}:${threshold.key}`); + } catch (error) { + console.warn( + `[CodeBuddy2API] Bark credential expiry notification failed for ${credential.filename}`, + error, + ); + } + } + + return sentNotifications; +}; + +export const startCredentialExpiryNotificationScheduler = (): void => { + if (globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__) { + return; + } + + const timer = setInterval(() => { + void runCredentialExpiryNotificationTick().catch((error) => { + console.warn( + '[CodeBuddy2API] Credential expiry notification tick failed', + error, + ); + }); + }, TICK_INTERVAL_MS); + timer.unref?.(); + globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__ = { + timer, + }; + + setTimeout(() => { + void runCredentialExpiryNotificationTick().catch((error) => { + console.warn( + '[CodeBuddy2API] Credential expiry notification tick failed', + error, + ); + }); + }, START_DELAY_MS).unref?.(); +}; + +export const stopCredentialExpiryNotificationScheduler = (): void => { + const state = + globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__; + + if (!state) return; + + clearInterval(state.timer); + delete globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__; +}; diff --git a/lib/server/domain/credentials.ts b/lib/server/domain/credentials.ts index 50eecb78..a0dcbef7 100644 --- a/lib/server/domain/credentials.ts +++ b/lib/server/domain/credentials.ts @@ -399,7 +399,9 @@ export const findEligibleCredentialRecordByFilename = async ( ); }; -const getCredentialExpiry = (credential: CredentialData): number | null => { +export const getCredentialExpiry = ( + credential: CredentialData, +): number | null => { if ( typeof credential.created_at !== 'number' || typeof credential.expires_in !== 'number' diff --git a/messages/en-US.json b/messages/en-US.json index c67562c7..8fa3e431 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -460,6 +460,15 @@ }, "settingsPanel": { "apiTimeoutHint": "Measured from when a request is sent until the upstream produces its first delta. Once output has started, a long answer is allowed to finish. Accepts fractional minutes, from 0.1 to 1440.", + "credentialExpiryNotificationsTitle": "Credential expiry notifications", + "credentialExpiryNotificationsDescription": "Send a Bark notification when a credential has 3 days left, 1 day left, or has expired. Checks run while the service is running.", + "credentialExpiryNotificationsEnabled": "Enable credential expiry notifications", + "barkToken": "Bark token", + "barkTokenPlaceholder": "Enter your Bark token", + "barkLink": "Open the Bark website to get a token", + "testBark": "Send test notification", + "testBarkSuccess": "Test notification sent.", + "testBarkFailed": "Test notification failed.", "clearUsageEvents": "Clear usage event cache", "confirmClearUsageEvents": "Clear all stored usage events?", "helper": "The passkey RP ID should usually match the final site domain. HTTPS works directly; localhost is commonly allowed for local development, while other non-HTTPS domains usually cannot use passkeys.", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 58b0ceae..418f5227 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -460,6 +460,15 @@ }, "settingsPanel": { "apiTimeoutHint": "リクエスト送信からアップストリームが最初の delta を返すまでの時間です。出力が始まった後は、長い応答でも完了まで許可されます。0.1〜1440 の範囲で小数分を指定できます。", + "credentialExpiryNotificationsTitle": "認証情報の有効期限通知", + "credentialExpiryNotificationsDescription": "認証情報の有効期限が 3 日前、1 日前、または期限切れになったときに Bark で通知します。サービスの実行中に確認します。", + "credentialExpiryNotificationsEnabled": "認証情報の有効期限通知を有効にする", + "barkToken": "Bark トークン", + "barkTokenPlaceholder": "Bark トークンを入力", + "barkLink": "Bark のサイトでトークンを取得", + "testBark": "テスト通知を送信", + "testBarkSuccess": "テスト通知を送信しました。", + "testBarkFailed": "テスト通知の送信に失敗しました。", "clearUsageEvents": "使用量イベントキャッシュを削除", "confirmClearUsageEvents": "保存済みの使用量イベントをすべて削除しますか?", "helper": "Passkey の RP ID は通常、最終的にアクセスするドメインと一致させます。HTTPS ではそのまま利用でき、localhost はローカル開発で使えることが多い一方、それ以外の非 HTTPS ドメインでは通常 passkey を利用できません。", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index bc0dc505..03b3a4c6 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -460,6 +460,15 @@ }, "settingsPanel": { "apiTimeoutHint": "从发起请求开始计时,直到上游返回第一个 delta。一旦开始输出,即使回答较长也会允许其完成。支持小数分钟,取值范围 0.1~1440。", + "credentialExpiryNotificationsTitle": "凭据过期通知", + "credentialExpiryNotificationsDescription": "凭据将在还有 3 天、还有 1 天和已过期时通过 Bark 通知。通知服务只会在服务运行时检查。", + "credentialExpiryNotificationsEnabled": "启用凭据过期通知", + "barkToken": "Bark Token", + "barkTokenPlaceholder": "输入 Bark Token", + "barkLink": "打开 Bark 官网获取 Token", + "testBark": "发送测试通知", + "testBarkSuccess": "测试通知已发送。", + "testBarkFailed": "测试通知发送失败。", "clearUsageEvents": "清空用量统计缓存", "confirmClearUsageEvents": "确认清空所有已保存的用量统计记录吗?", "helper": "Passkey RP ID 通常应与最终访问域名一致。HTTPS 站点可直接使用;localhost 在现代浏览器中通常可用于本地调试,其它非 HTTPS 域名通常无法使用 passkey。", diff --git a/tests/server/credential-expiry-notifications.test.ts b/tests/server/credential-expiry-notifications.test.ts new file mode 100644 index 00000000..b71fbf26 --- /dev/null +++ b/tests/server/credential-expiry-notifications.test.ts @@ -0,0 +1,278 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + addCredential, + listCredentials, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { getActiveConfig, updateSettings } from '@/lib/server/domain/config'; +import { resetStorageRuntime, writeStorageJson } from '@/lib/server/storage'; + +const tempRootDir = path.join(process.cwd(), '.tmp-test-expiry-notifications'); +const now = new Date(Math.floor(Date.now() / 1000) * 1000); +const nowSeconds = Math.floor(now.getTime() / 1000); +const DAY = 24 * 60 * 60; + +const send = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise +>(async () => new Response('{}', { status: 200 })); +const authError = vi.hoisted(() => + vi.fn<(request: Request) => Promise>(async () => null), +); + +vi.mock('@/lib/server/admin/session', () => ({ + getAdminSessionErrorResponse: authError, +})); + +vi.stubGlobal('fetch', send); + +const { + runCredentialExpiryNotificationTick, + sendBarkNotification, + startCredentialExpiryNotificationScheduler, + stopCredentialExpiryNotificationScheduler, +} = await import('@/lib/server/domain/credential-expiry-notifications'); +const BarkTestRoute = + await import('@/app/admin-api/notifications/bark/test/route'); + +const cleanup = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); +}; + +const seed = async ( + filename: string, + expiresIn: number, + data: Record = {}, +): Promise => { + await addCredential( + { + bearer_token: 'token', + created_at: nowSeconds, + expires_in: expiresIn, + user_id: 'tester@example.com', + ...data, + }, + filename, + ); +}; + +describe('credential expiry notifications', () => { + beforeEach(async () => { + cleanup(); + resetCredentialRuntimeState(); + resetStorageRuntime(); + send.mockReset(); + send.mockResolvedValue(new Response('{}', { status: 200 })); + authError.mockReset(); + authError.mockResolvedValue(null); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + delete process.env.CODEBUDDY_BARK_TOKEN; + delete process.env.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED; + await updateSettings({ + CODEBUDDY_BARK_TOKEN: 'bark-token', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: true, + }); + }); + + afterEach(() => { + stopCredentialExpiryNotificationScheduler(); + cleanup(); + resetCredentialRuntimeState(); + resetStorageRuntime(); + }); + + it('does nothing when notifications are disabled or unconfigured', async () => { + await seed('disabled.json', 3 * DAY); + await updateSettings({ + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: false, + }); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(send).not.toHaveBeenCalled(); + }); + + it('sends each threshold once and persists the sent state', async () => { + await seed('three-days.json', 3 * DAY - 5); + expect((await getActiveConfig()).CODEBUDDY_BARK_TOKEN).toBe('bark-token'); + expect( + (await getActiveConfig()) + .CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED, + ).toBe(true); + expect((await listCredentials()).credentials).toMatchObject([ + { expires_in: 3 * DAY - 5, time_remaining: 3 * DAY - 5 }, + ]); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'three-days.json:three_days', + ]); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toBe('https://api.day.app/push'); + expect(JSON.parse(String(send.mock.calls[0]?.[1]?.body))).toMatchObject({ + device_key: 'bark-token', + title: 'CodeBuddy2API 凭据提醒', + }); + console.log( + 'STATE1', + fs.readFileSync( + path.join(tempRootDir, '.codebuddy_creds', 'expiry_notifications.json'), + 'utf8', + ), + ); + + send.mockClear(); + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(send).not.toHaveBeenCalled(); + + const oneDay = new Date(now.getTime() + (2 * DAY + 60) * 1000); + await expect(runCredentialExpiryNotificationTick(oneDay)).resolves.toEqual([ + 'three-days.json:one_day', + ]); + const expired = new Date(now.getTime() + (3 * DAY + 60) * 1000); + await expect(runCredentialExpiryNotificationTick(expired)).resolves.toEqual( + ['three-days.json:expired'], + ); + }); + + it('uses the nearest threshold after a missed check', async () => { + await seed('missed.json', DAY - 1); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'missed.json:one_day', + ]); + }); + + it('ignores malformed state and credentials without expiry data', async () => { + await writeStorageJson('credentials', 'expiry_notifications.json', { + invalid: { expiresAt: 'not-a-number', sent: {} }, + malformed: null, + }); + await writeStorageJson('credentials', 'missing-expiry.json', { + bearer_token: 'token', + }); + await writeStorageJson('credentials', 'filename-fallback.json', { + bearer_token: 'token', + created_at: nowSeconds, + expires_in: DAY - 1, + }); + await seed('email.json', 3 * DAY - 5, { + user_info: { email: 'email@example.com' }, + }); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'email.json:three_days', + 'filename-fallback.json:one_day', + ]); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('retries a failed Bark request on the next tick', async () => { + await seed('retry.json', 3 * DAY - 5); + send.mockRejectedValueOnce(new Error('network down')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(warn).toHaveBeenCalled(); + + send.mockResolvedValueOnce(new Response('{}', { status: 200 })); + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'retry.json:three_days', + ]); + }); + + it('recovers when persisting notification state fails', async () => { + await seed('state-error.json', 3 * DAY - 5); + const storage = await import('@/lib/server/storage'); + const write = vi + .spyOn(storage, 'writeStorageJson') + .mockRejectedValueOnce(new Error('storage down')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(write).toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); + + it('sends a test notification and rejects an empty token', async () => { + await expect( + sendBarkNotification({ body: 'hello', title: 'test', token: ' ' }), + ).rejects.toThrow('Bark token is empty'); + + await expect( + sendBarkNotification({ body: 'hello', title: 'test', token: ' token ' }), + ).resolves.toBeUndefined(); + }); + + it('serves the Bark test notification endpoint', async () => { + const missing = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(missing.status).toBe(400); + + const success = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({ token: 'token' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(success.status).toBe(200); + + send.mockResolvedValueOnce(new Response('{}', { status: 500 })); + const failed = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({ token: 'token' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(failed.status).toBe(502); + + authError.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }), + ); + const unauthorized = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({ token: 'token' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(unauthorized.status).toBe(401); + }); + + it('starts only one scheduler and can be stopped', () => { + const interval = vi.spyOn(globalThis, 'setInterval'); + const clear = vi.spyOn(globalThis, 'clearInterval'); + + startCredentialExpiryNotificationScheduler(); + startCredentialExpiryNotificationScheduler(); + + expect(interval).toHaveBeenCalledTimes(1); + stopCredentialExpiryNotificationScheduler(); + expect(clear).toHaveBeenCalledTimes(1); + stopCredentialExpiryNotificationScheduler(); + }); + + it('runs the scheduled sweep after startup and on the interval', async () => { + vi.useFakeTimers(); + const config = await import('@/lib/server/domain/config'); + const getConfig = vi + .spyOn(config, 'getActiveConfig') + .mockRejectedValue(new Error('config unavailable')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + startCredentialExpiryNotificationScheduler(); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 10_000); + expect(warn).toHaveBeenCalled(); + } finally { + stopCredentialExpiryNotificationScheduler(); + getConfig.mockRestore(); + vi.useRealTimers(); + } + }); +});