diff --git a/CLAUDE.md b/CLAUDE.md index 6e6b675..7fdebc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,12 @@ POST /api/generate-schedule POST /api/render-schedule GET /api/school-events POST /api/school-events/refresh +POST /api/pdf-payload/encrypt +POST /api/pdf-payload/decrypt +POST /api/schedule-shares +GET /api/schedule-shares +DELETE /api/schedule-shares/:token +GET /api/schedule-shares/:token GET / GET /robots.txt ``` @@ -61,6 +67,7 @@ MongoDB 集合清單(`scheduleApp` 資料庫): - `profiles`:設定檔與排班資料 - `holidays`:台灣國定假日(`_id` 為日期字串如 `2025-01-01`) - `schoolEvents`:學校行事曆快取(`_id` 為學期代碼如 `1142`,7 天 TTL) +- `scheduleShares`:班表分享連結(`_id` 為隨機 token,記錄對應的 profile/scheduleName/personFilter,無過期機制) --- @@ -85,15 +92,17 @@ backend/ app.js # Express 設定、middleware、路由掛載 config.js # 環境變數 validators.js # 輸入驗證(profile/schedule 名稱、settings) - db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents 三個 collection) - routes/ # status, holidays, profiles, schedules, generate, schoolCalendar + db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents、scheduleShares 四個 collection) + routes/ # status, holidays, profiles, schedules, generate, schoolCalendar, pdfPayload, shares services/ scheduleAlgorithm.js # 排班核心演算法(純函數) scheduleRenderer.js # HTML 渲染 holidayService.js # 假日快取、CDN 更新 schoolCalendar.js # 學校行事曆(記憶體 6h + MongoDB 7 天持久快取) + pdfPayloadCrypto.js # PDF 匯入/匯出隱藏資料的 AES-256-GCM 加解密(金鑰來自 PDF_PAYLOAD_SECRET) repositories/ profileRepository.js # MongoDB CRUD(profiles、schedules) + shareRepository.js # 班表分享連結的 token 產生/查詢(scheduleShares collection) tests/ frontend/ @@ -115,8 +124,9 @@ frontend/ escapeHtml.js debounce.js capacityStatus.js # 容量/填補率狀態計算 - connectionStatus.js # 後端連線狀態顯示 + connectionStatus.js # 後端連線狀態顯示;同時解讀 /api/status 的假日/學校行事曆自動更新結果,異常時顯示警示圖示(點擊看原因) excelDownload.js # ExcelJS Workbook → Blob 觸發瀏覽器下載 + exportFilename.js # 組合匯出檔名(班表名稱_日期範圍_時間戳記),PDF/Excel/人員Excel 共用 features/schedule/ personnelView.js # 人員月曆視圖 + Excel 下載 diffSummary.js # 變更摘要 Modal(buildDiff、共用 renderDiffSection) @@ -124,7 +134,10 @@ frontend/ scheduleCompare.js # 比較已儲存班表 Modal(人員異動/填補率/勤務設定差異) exportWeekFilter.js # 匯出週次篩選(getExportData,供複製/Excel/PDF/人員Excel 共用) personTaskStats.js # 值勤統計 Modal(人員 × 勤務次數,加總 + 每週明細,Excel/PDF 匯出共用同一份計算) - pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計) + pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_SECRET 會額外把班表資料加密嵌入 PDF metadata) + pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密) + scheduleShare.js # 分享班表 Modal:產生免登入連結(可選只分享單一人員、可設有效期限),並列出/撤銷已產生的連結 + sharedView.js # 免登入分享連結的頁面(接管整個 document.body,不含後台功能) scheduleGenerator.js # 前端排班產生流程 features/settings/ settingsRenderer.js # 設定頁渲染 @@ -185,9 +198,11 @@ MONGODB_URI=mongodb+srv://:@.mongodb.net/ DB_NAME=scheduleApp # 預設值 PORT=3000 # 預設值 CORS_ORIGIN=* # 預設值;未設定時 config.js 也會 fallback 成 *,僅適合開發環境 +PDF_PAYLOAD_SECRET= # 選填,64 字元 hex(32 bytes)。未設定時 PDF 匯入/匯出隱藏資料功能自動停用,其他功能不受影響 ``` 若未提供 `MONGODB_URI`,伺服器仍會啟動,但所有資料庫功能停用(會顯示警告)。 +若未提供 `PDF_PAYLOAD_SECRET`,`/api/pdf-payload/encrypt`、`/api/pdf-payload/decrypt` 會回傳 503,前端的 PDF 匯入按鈕與匯出時的隱藏資料嵌入都會靜默跳過(不影響一般匯出/檢視)。 --- @@ -292,6 +307,15 @@ GitHub Actions(`.github/workflows/ci-cd.yml`)在推送到 `develop`/`release 但 `POST /api/generate-schedule`、`POST /api/render-schedule` **沒有**這層檢查:內部呼叫的 `getHolidaysForYear()` 在無 DB 時直接回傳空 `Map`,假日/行事曆資料會被靜默忽略、班表照樣「成功」產生,前端不會收到任何錯誤或警告。 診斷方式:`GET /api/status` 確認 `"database": "connected"` 還是 `"disconnected"`。 +### 🟡 班表分享連結(`GET /api/schedule-shares/:token`)刻意不需要登入 + +這是全站唯一一個刻意設計成公開、無需任何驗證就能讀取的端點——分享連結的目的就是讓拿到連結的人不用進後台。安全性完全依賴 token 本身夠隨機(128 bits,`crypto.randomBytes(16)`),不是靠登入機制擋。 + +- 建立連結時可選「永久有效」或設定 1-365 天後失效(`expiresInDays`)。過期判斷在 `GET /api/schedule-shares/:token` 讀取當下即時檢查,不能只依賴 MongoDB TTL index(`expiresAt` 欄位,`expireAfterSeconds: 0`)——TTL 背景清除任務約每 60 秒才跑一次,不是精準即時刪除,永久連結因為沒有 `expiresAt` 欄位不受 TTL index 影響。 +- 分享 Modal 內會列出該份班表已產生的所有連結(`GET /api/schedule-shares?profile=&scheduleName=`),可用「撤銷」按鈕呼叫 `DELETE /api/schedule-shares/:token` 立即刪除、讓連結失效。這兩個管理端點本身沒有額外驗證(跟系統其他管理端點一樣,整個後台沒有登入機制),只是回傳/操作 token 中繼資料,不會外洩班表內容。 +- `personFilter` 的過濾**必須在後端做**(`shares.js` 的 `filterScheduleForPerson`),不能改成前端拿到完整班表 JSON 後才用 CSS/JS 隱藏其他人——那樣瀏覽器開發者工具的網路分頁還是看得到完整資料,等於沒做過濾。 +- 分享連結對應的是**某一份已儲存班表的快照**(`profile` + `scheduleName`),不會跟著該班表之後的修改自動更新;若使用者事後編輯並重新儲存同名班表,舊分享連結會顯示新內容(因為是即時查 `getSchedule`,不是存快照),但若該班表被刪除,連結會直接 404。 + ### 🟡 Tailwind CDN 動態 HTML 限制 前端使用 Tailwind CDN(JIT 模式),**動態注入的 innerHTML 中的 `dark:` class 不會被編譯**。 @@ -308,6 +332,8 @@ GitHub Actions(`.github/workflows/ci-cd.yml`)在推送到 `develop`/`release 不會自動更新的是:**去年(含)以前**的資料、以及最初從 `holidays/*.json` 植入(無 `source:'cdn'` 標記)的資料。要強制整批重抓三年資料,呼叫 `POST /api/holidays/reseed`(清空整個 collection + 記憶體快取,全部重新從 CDN 拉)。 直接修改 `holidays/*.json` 不會生效——只有 collection 是空的時候(`seedHolidays()`)才會讀本地檔案;reseed 走的是 CDN,不讀本地 JSON。 +假日 CDN 與學校行事曆(`schoolCalendar.js`)這兩個自動抓資料機制若失敗,過去完全沒有地方看得出來(只寫 `debug` log,正式環境預設不顯示)。現在 `GET /api/status` 會回報 `holidaysLastRefresh`(最近一次自動更新每個年份的成功/筆數/錯誤)與 `schoolCalendarLastFetch`(最近一次即時抓取的成功與筆數;`eventCount: 0` 通常代表學校網站格式已改版,regex 抓不到東西了)。 + ### 🟡 backend/tests/ 的端點路徑 測試直接打 `/api/status`(含前綴),改動路由時要對照確認測試路徑一致。 diff --git a/backend/.env.example b/backend/.env.example index a57d6ba..6c28123 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,3 +2,8 @@ MONGODB_URI=mongodb+srv://:@.mongodb.net/ DB_NAME=scheduleApp PORT=3000 CORS_ORIGIN=* + +# 選填:PDF 匯入/匯出的隱藏資料加密金鑰。未設定時該功能停用(其他功能不受影響)。 +# 必須是 64 字元 hex 字串(32 bytes),可用下列指令產生: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +PDF_PAYLOAD_SECRET= diff --git a/backend/src/app.js b/backend/src/app.js index 33b5937..40d6246 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -13,6 +13,8 @@ const profilesRouter = require('./routes/profiles'); const schedulesRouter = require('./routes/schedules'); const generateRouter = require('./routes/generate'); const schoolCalendarRouter = require('./routes/schoolCalendar'); +const pdfPayloadRouter = require('./routes/pdfPayload'); +const sharesRouter = require('./routes/shares'); const app = express(); @@ -100,6 +102,8 @@ app.use(profilesRouter); app.use(schedulesRouter); app.use(generateRouter); app.use(schoolCalendarRouter); +app.use(pdfPayloadRouter); +app.use(sharesRouter); // robots.txt(爬蟲不索引 API 路徑) app.get('/robots.txt', (req, res) => { diff --git a/backend/src/config.js b/backend/src/config.js index c7392b9..366af7d 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -10,6 +10,8 @@ const PORT = process.env.PORT || 3000; const MONGODB_URI = process.env.MONGODB_URI; const DB_NAME = process.env.DB_NAME || 'scheduleApp'; const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; +// PDF 匯入/匯出的隱藏資料加密金鑰:64 字元 hex 字串(32 bytes)。未設定時該功能停用。 +const PDF_PAYLOAD_SECRET = process.env.PDF_PAYLOAD_SECRET; module.exports = { SAFE_PROFILE_NAME_REGEX, @@ -19,4 +21,5 @@ module.exports = { MONGODB_URI, DB_NAME, CORS_ORIGIN, + PDF_PAYLOAD_SECRET, }; diff --git a/backend/src/db/connect.js b/backend/src/db/connect.js index 0082d45..e953ffd 100644 --- a/backend/src/db/connect.js +++ b/backend/src/db/connect.js @@ -10,6 +10,7 @@ let db; let configCollection; let holidaysCollection; let schoolEventsCollection; +let scheduleSharesCollection; let isDbConnected = false; if (MONGODB_URI) { @@ -38,6 +39,8 @@ const getHolidaysCollection = () => holidaysCollection; const getSchoolEventsCollection = () => schoolEventsCollection; +const getScheduleSharesCollection = () => scheduleSharesCollection; + const connect = async () => { if (!client) return; debugServer('正在連線至 MongoDB...'); @@ -48,6 +51,11 @@ const connect = async () => { configCollection = db.collection('profiles'); holidaysCollection = db.collection('holidays'); schoolEventsCollection = db.collection('schoolEvents'); + scheduleSharesCollection = db.collection('scheduleShares'); + // TTL index:只會刪除「有 expiresAt 欄位且已過期」的文件,沒有這個欄位的永久 + // 分享連結不受影響。MongoDB 背景清除任務約每 60 秒跑一次,不是精準即時刪除, + // 所以 GET /api/schedule-shares/:token 仍須自行檢查 expiresAt,不能只靠這個 index。 + await scheduleSharesCollection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); isDbConnected = true; }; @@ -87,6 +95,7 @@ module.exports = { getConfigCollection, getHolidaysCollection, getSchoolEventsCollection, + getScheduleSharesCollection, connect, disconnect, ensureConfigDocument, diff --git a/backend/src/repositories/profileRepository.js b/backend/src/repositories/profileRepository.js index e70bd7c..aab2b02 100644 --- a/backend/src/repositories/profileRepository.js +++ b/backend/src/repositories/profileRepository.js @@ -23,11 +23,11 @@ const setActiveProfile = async (name) => { // matchedCount > 0 但 modifiedCount 為 0 代表本來就是目前的作用中設定檔,視為成功(冪等) }; -const createProfile = async (name) => { +const createProfile = async (name, settings = { tasks: [], personnel: [] }) => { const configCollection = getConfigCollection(); const update = { $set: { - [`profiles.${name}`]: { settings: { tasks: [], personnel: [] }, schedules: {} }, + [`profiles.${name}`]: { settings, schedules: {} }, }, }; const result = await configCollection.updateOne( diff --git a/backend/src/repositories/shareRepository.js b/backend/src/repositories/shareRepository.js new file mode 100644 index 0000000..fc8020b --- /dev/null +++ b/backend/src/repositories/shareRepository.js @@ -0,0 +1,41 @@ +const crypto = require('crypto'); +const { getScheduleSharesCollection } = require('../db/connect'); + +// token 本身當 _id:128 bits 隨機值,猜中機率可忽略不計,不需要額外唯一性檢查。 +// expiresInDays 為 null/undefined 時代表永久有效,不寫入 expiresAt 欄位—— +// MongoDB 的 TTL index(見 db/connect.js)只會清掉「有 expiresAt 且已過期」的文件, +// 沒有這個欄位的永久連結完全不受影響。 +const createShare = async (profile, scheduleName, personFilter, expiresInDays) => { + const token = crypto.randomBytes(16).toString('hex'); + const collection = getScheduleSharesCollection(); + const doc = { + _id: token, + profile, + scheduleName, + personFilter: personFilter || null, + createdAt: new Date(), + }; + if (expiresInDays) { + doc.expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + } + await collection.insertOne(doc); + return token; +}; + +const getShare = async (token) => { + const collection = getScheduleSharesCollection(); + return collection.findOne({ _id: token }); +}; + +const listShares = async (profile, scheduleName) => { + const collection = getScheduleSharesCollection(); + return collection.find({ profile, scheduleName }).sort({ createdAt: -1 }).toArray(); +}; + +const deleteShare = async (token) => { + const collection = getScheduleSharesCollection(); + const result = await collection.deleteOne({ _id: token }); + return result.deletedCount > 0; +}; + +module.exports = { createShare, getShare, listShares, deleteShare }; diff --git a/backend/src/routes/pdfPayload.js b/backend/src/routes/pdfPayload.js new file mode 100644 index 0000000..d599849 --- /dev/null +++ b/backend/src/routes/pdfPayload.js @@ -0,0 +1,39 @@ +'use strict'; + +const express = require('express'); +const debug = require('debug')('app:pdfPayload'); +const { encryptPayload, decryptPayload, isConfigured } = require('../services/pdfPayloadCrypto'); + +const router = express.Router(); + +// 把班表資料加密成一段 base64 字串,供前端嵌入匯出 PDF 的 metadata 欄位(人眼開啟 PDF 看不到)。 +router.post('/api/pdf-payload/encrypt', (req, res) => { + if (!isConfigured()) return res.status(503).json({ message: 'PDF 加密功能未設定' }); + try { + const { data } = req.body; + if (data === undefined) return res.status(400).json({ message: '缺少 data 欄位' }); + const payload = encryptPayload(data); + res.json({ payload }); + } catch (error) { + debug('加密失敗:', error); + res.status(500).json({ message: '加密時發生未預期的錯誤' }); + } +}); + +// 把從 PDF metadata 讀出的 base64 字串解密還原成班表資料。 +router.post('/api/pdf-payload/decrypt', (req, res) => { + if (!isConfigured()) return res.status(503).json({ message: 'PDF 加密功能未設定' }); + try { + const { payload } = req.body; + if (typeof payload !== 'string' || !payload) { + return res.status(400).json({ message: '缺少 payload 欄位' }); + } + const data = decryptPayload(payload); + res.json({ data }); + } catch (error) { + debug('解密失敗(可能不是本系統匯出的 PDF,或內容已損毀):', error); + res.status(400).json({ message: '無法解析此 PDF,可能不是由本系統匯出,或內容已損毀' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/profiles.js b/backend/src/routes/profiles.js index 35567d0..e92a69f 100644 --- a/backend/src/routes/profiles.js +++ b/backend/src/routes/profiles.js @@ -41,12 +41,23 @@ router.put('/api/profiles/active', async (req, res) => { router.post('/api/profiles', async (req, res) => { if (!getIsDbConnected()) return res.status(503).json({ message: '資料庫未連線' }); try { - const { name } = req.body; + const { name, settings } = req.body; const validation = validateProfileName(name); if (!validation.valid) { return res.status(400).json({ message: validation.error }); } - await repo.createProfile(name); + + // settings 為選填:不帶就沿用舊行為建立空白設定檔;帶了(複製現有設定檔用)就驗證後套用。 + if (settings !== undefined) { + const settingsValidation = validateSettings(settings); + if (!settingsValidation.valid) { + return res.status(400).json({ message: settingsValidation.error }); + } + await repo.createProfile(name, settings); + } else { + await repo.createProfile(name); + } + res.status(201).json({ message: '設定檔已新增' }); } catch (error) { debugDb('新增設定檔失敗:', error); diff --git a/backend/src/routes/shares.js b/backend/src/routes/shares.js new file mode 100644 index 0000000..1fbc64a --- /dev/null +++ b/backend/src/routes/shares.js @@ -0,0 +1,129 @@ +'use strict'; + +const express = require('express'); +const debug = require('debug')('app:shares'); +const { getIsDbConnected } = require('../db/connect'); +const { validateProfileName, validateScheduleName } = require('../validators'); +const shareRepo = require('../repositories/shareRepository'); +const profileRepo = require('../repositories/profileRepository'); +const { generateScheduleHtml } = require('../services/scheduleRenderer'); + +const router = express.Router(); + +// 保留完整週次/任務結構,只把「不是這個人」的格子清空——這樣可以直接沿用既有的 +// generateScheduleHtml 渲染邏輯,不用另外寫一份簡化版渲染器。 +const filterScheduleForPerson = (fullScheduleData, personName) => + fullScheduleData.map((week) => ({ + ...week, + schedule: week.schedule.map((day) => day.map((cell) => cell.filter((name) => name === personName))), + })); + +// 建立一個免登入的分享連結(token),對應到某個已儲存的班表,選填只給單一人員看。 +router.post('/api/schedule-shares', async (req, res) => { + if (!getIsDbConnected()) return res.status(503).json({ message: '資料庫未連線' }); + try { + const { profile, scheduleName, personFilter, expiresInDays } = req.body; + + const profileValidation = validateProfileName(profile); + if (!profileValidation.valid) return res.status(400).json({ message: profileValidation.error }); + + const nameValidation = validateScheduleName(scheduleName); + if (!nameValidation.valid) return res.status(400).json({ message: nameValidation.error }); + + if (personFilter !== undefined && personFilter !== null && typeof personFilter !== 'string') { + return res.status(400).json({ message: 'personFilter 必須是字串' }); + } + + // expiresInDays 為 null/undefined 代表永久有效;有帶值時限制在合理範圍(1-365 天)。 + if ( + expiresInDays !== undefined && + expiresInDays !== null && + (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 365) + ) { + return res.status(400).json({ message: 'expiresInDays 必須是 1-365 之間的整數' }); + } + + const scheduleData = await profileRepo.getSchedule(profile, scheduleName); + if (!scheduleData) return res.status(404).json({ message: '找不到指定的班表' }); + + const token = await shareRepo.createShare(profile, scheduleName, personFilter || null, expiresInDays || null); + res.status(201).json({ token }); + } catch (error) { + debug('建立分享連結失敗:', error); + res.status(500).json({ message: '建立分享連結時發生錯誤' }); + } +}); + +// 列出某份班表底下已產生的分享連結,供後台管理介面顯示「已產生的連結」清單。 +router.get('/api/schedule-shares', async (req, res) => { + if (!getIsDbConnected()) return res.status(503).json({ message: '資料庫未連線' }); + try { + const { profile, scheduleName } = req.query; + + const profileValidation = validateProfileName(profile); + if (!profileValidation.valid) return res.status(400).json({ message: profileValidation.error }); + + const nameValidation = validateScheduleName(scheduleName); + if (!nameValidation.valid) return res.status(400).json({ message: nameValidation.error }); + + const shares = await shareRepo.listShares(profile, scheduleName); + res.json( + shares.map((s) => ({ + token: s._id, + personFilter: s.personFilter, + createdAt: s.createdAt, + expiresAt: s.expiresAt || null, + })) + ); + } catch (error) { + debug('列出分享連結失敗:', error); + res.status(500).json({ message: '列出分享連結時發生錯誤' }); + } +}); + +// 撤銷(刪除)一個已產生的分享連結,撤銷後該連結立即失效。 +router.delete('/api/schedule-shares/:token', async (req, res) => { + if (!getIsDbConnected()) return res.status(503).json({ message: '資料庫未連線' }); + try { + const { token } = req.params; + const deleted = await shareRepo.deleteShare(token); + if (!deleted) return res.status(404).json({ message: '此分享連結不存在或已被撤銷' }); + res.json({ message: '分享連結已撤銷' }); + } catch (error) { + debug('撤銷分享連結失敗:', error); + res.status(500).json({ message: '撤銷分享連結時發生錯誤' }); + } +}); + +// 公開端點,刻意不需要登入——分享連結的重點就是讓拿到連結的人直接看,不用進到後台。 +router.get('/api/schedule-shares/:token', async (req, res) => { + if (!getIsDbConnected()) return res.status(503).json({ message: '資料庫未連線' }); + try { + const { token } = req.params; + const share = await shareRepo.getShare(token); + if (!share) return res.status(404).json({ message: '此分享連結不存在或已失效' }); + + // MongoDB 的 TTL 背景清除任務約每 60 秒才跑一次,不是精準即時刪除, + // 這裡自己再檢查一次 expiresAt,確保過期的瞬間就正確擋下,不用等背景任務清掉文件。 + if (share.expiresAt && new Date(share.expiresAt) < new Date()) { + return res.status(404).json({ message: '此分享連結已過期' }); + } + + const scheduleData = await profileRepo.getSchedule(share.profile, share.scheduleName); + if (!scheduleData) return res.status(404).json({ message: '此分享連結對應的班表已被刪除' }); + + // 有 personFilter 時在伺服器端就把其他人的資料過濾掉,不能只靠前端不顯示—— + // 否則瀏覽器開發者工具的網路分頁還是看得到完整班表 JSON,等於白做安全性。 + const filteredData = share.personFilter + ? filterScheduleForPerson(scheduleData, share.personFilter) + : scheduleData; + + const html = generateScheduleHtml(filteredData); + res.json({ html, scheduleName: share.scheduleName, personFilter: share.personFilter }); + } catch (error) { + debug('讀取分享連結失敗:', error); + res.status(500).json({ message: '讀取分享連結時發生錯誤' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 711640b..0fa44a6 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -1,6 +1,7 @@ const express = require('express'); const { getIsDbConnected, getHolidaysCollection, getConfigCollection } = require('../db/connect'); -const { holidaysCache } = require('../services/holidayService'); +const { holidaysCache, lastRefreshStatus } = require('../services/holidayService'); +const { getLastFetchStatus } = require('../services/schoolCalendar'); const router = express.Router(); @@ -9,6 +10,11 @@ router.get('/api/status', async (req, res) => { const status = { server: 'running', database: isDbConnected ? 'connected' : 'disconnected', + // 假日 CDN/學校行事曆這兩個自動抓資料機制平常都靜默運作,失敗時也不會有 + // 任何地方顯示出來(debug log 正式環境預設不開)。這裡回報最近一次結果, + // 讓人可以直接從 /api/status 看出這兩個機制是不是還正常。 + holidaysLastRefresh: lastRefreshStatus, + schoolCalendarLastFetch: getLastFetchStatus(), }; if (isDbConnected) { diff --git a/backend/src/services/holidayService.js b/backend/src/services/holidayService.js index c7d7e06..57133ba 100644 --- a/backend/src/services/holidayService.js +++ b/backend/src/services/holidayService.js @@ -8,6 +8,12 @@ const debugServer = debug('app:server'); const holidaysCache = new Map(); +// 記錄「最近一次自動更新」的結果,讓 /api/status 能回報這個機制是否還正常運作—— +// 光靠 debugDb 完全不夠,因為 debug log 正式環境預設不會顯示,CDN 掛掉或格式跑掉 +// 時不會有任何地方看得出來。success 用「有沒有實際抓到資料」判斷,而不是「有沒有 +// 拋例外」,因為 getHolidaysForYear() 在 CDN 失敗時是回傳空 Map、不拋例外。 +const lastRefreshStatus = { at: null, years: {} }; + const getWeekInfo = (weekString, weekIndex) => { const [year, weekNum] = weekString.split('-W').map(Number); const simpleDate = new Date(Date.UTC(year, 0, 1 + (weekNum - 1) * 7)); @@ -95,16 +101,26 @@ const refreshHolidaysFromCDN = async () => { if (!getIsDbConnected()) return; const holidaysCollection = getHolidaysCollection(); const currentYear = new Date().getFullYear(); + const years = {}; for (const year of [currentYear, currentYear + 1]) { try { await holidaysCollection.deleteMany({ _id: { $regex: `^${year}` }, source: 'cdn' }); holidaysCache.delete(year); - await getHolidaysForYear(year); - debugDb(`已自動更新 ${year} 年假日資料`); + const holidayMap = await getHolidaysForYear(year); + // 舊資料已經刪了,這裡如果抓到 0 筆,代表這個年份現在完全沒有假日資料 + // (CDN 那時剛好打不到),不能當成「更新成功」。 + years[year] = { success: holidayMap.size > 0, count: holidayMap.size }; + debugDb(`已自動更新 ${year} 年假日資料(${holidayMap.size} 筆)`); } catch (e) { + years[year] = { success: false, count: 0, error: e.message }; debugDb(`自動更新 ${year} 年假日資料失敗:`, e.message); } } + lastRefreshStatus.at = Date.now(); + lastRefreshStatus.years = years; + if (Object.values(years).some((y) => !y.success)) { + console.warn('[holidayService] 假日自動更新有年份失敗或抓到 0 筆資料:', years); + } }; const seedHolidays = async () => { @@ -178,6 +194,7 @@ const seedHolidays = async () => { module.exports = { holidaysCache, + lastRefreshStatus, getWeekInfo, getHolidaysForYear, refreshHolidaysFromCDN, diff --git a/backend/src/services/pdfPayloadCrypto.js b/backend/src/services/pdfPayloadCrypto.js new file mode 100644 index 0000000..d186618 --- /dev/null +++ b/backend/src/services/pdfPayloadCrypto.js @@ -0,0 +1,44 @@ +'use strict'; + +const crypto = require('crypto'); +const { PDF_PAYLOAD_SECRET } = require('../config'); + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +const getKey = () => { + if (!PDF_PAYLOAD_SECRET || !/^[0-9a-fA-F]{64}$/.test(PDF_PAYLOAD_SECRET)) return null; + return Buffer.from(PDF_PAYLOAD_SECRET, 'hex'); +}; + +const isConfigured = () => getKey() !== null; + +// 回傳 base64 字串:iv(12 bytes) + authTag(16 bytes) + 密文,供直接嵌入 PDF metadata 欄位。 +const encryptPayload = (plainObj) => { + const key = getKey(); + if (!key) throw new Error('PDF_PAYLOAD_SECRET 未設定或格式錯誤'); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const plaintext = Buffer.from(JSON.stringify(plainObj), 'utf8'); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); +}; + +// GCM 的 authTag 驗證失敗(資料被竄改、或根本不是本系統加密的內容)會在 final() 拋出例外。 +const decryptPayload = (payloadBase64) => { + const key = getKey(); + if (!key) throw new Error('PDF_PAYLOAD_SECRET 未設定或格式錯誤'); + const buf = Buffer.from(payloadBase64, 'base64'); + if (buf.length <= IV_LENGTH + AUTH_TAG_LENGTH) throw new Error('payload 長度不足'); + const iv = buf.subarray(0, IV_LENGTH); + const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return JSON.parse(decrypted.toString('utf8')); +}; + +module.exports = { encryptPayload, decryptPayload, isConfigured }; diff --git a/backend/src/services/schoolCalendar.js b/backend/src/services/schoolCalendar.js index 7c0a6d0..a8e51b7 100644 --- a/backend/src/services/schoolCalendar.js +++ b/backend/src/services/schoolCalendar.js @@ -9,6 +9,13 @@ const MONGO_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天 // 學校行事曆快取(6小時) let schoolEventsCache = { data: null, fetchedAt: 0 }; +// 記錄「最近一次即時抓取」(不含快取命中)的結果,讓 /api/status 能回報這個 +// scraper 是否還正常運作。這裡是整套機制最脆弱的一環:靠 regex 解析學校網站的 +// HTML,網站一旦改版、class 名稱跑掉,fetch 仍會是 200 成功、但完全解析不到 +// 任何考試資料,程式本身不會拋出任何錯誤——所以額外判斷「回傳 0 筆」視為可疑。 +let lastFetchStatus = { at: null, success: null, eventCount: null, warning: null }; +const getLastFetchStatus = () => lastFetchStatus; + // 自動計算當前學期代碼(民國年+學期,例:1142) const getCurrentPeriod = () => { const now = new Date(); @@ -218,6 +225,17 @@ const getSchoolEvents = async () => { const fetchedAt = Date.now(); schoolEventsCache = { data: events, fetchedAt }; + const suspiciouslyEmpty = events.length === 0; + lastFetchStatus = { + at: fetchedAt, + success: !suspiciouslyEmpty, + eventCount: events.length, + warning: suspiciouslyEmpty ? '本次即時抓取沒有找到任何考試資料,可能是學校網站格式已變更' : null, + }; + if (suspiciouslyEmpty) { + console.warn(`[schoolCalendar] 即時抓取結果為 0 筆考試資料(period=${period}),請確認目標網站格式是否變更: ${BASE}`); + } + // 存入 MongoDB 持久快取 if (getIsDbConnected()) { try { @@ -235,4 +253,4 @@ const getSchoolEvents = async () => { return { cached: false, data: events }; }; -module.exports = { getSchoolEvents, schoolEventsCache }; +module.exports = { getSchoolEvents, schoolEventsCache, getLastFetchStatus }; diff --git a/backend/tests/repositories/shareRepository.test.js b/backend/tests/repositories/shareRepository.test.js new file mode 100644 index 0000000..c1a09a9 --- /dev/null +++ b/backend/tests/repositories/shareRepository.test.js @@ -0,0 +1,132 @@ +'use strict'; + +process.env.NODE_ENV = 'test'; + +jest.mock('../../src/db/connect', () => ({ + getScheduleSharesCollection: jest.fn(), +})); + +const { getScheduleSharesCollection } = require('../../src/db/connect'); +const { createShare, getShare, listShares, deleteShare } = require('../../src/repositories/shareRepository'); + +const makeCol = () => { + const sortMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }); + return { + insertOne: jest.fn().mockResolvedValue({ acknowledged: true }), + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockReturnValue({ sort: sortMock }), + deleteOne: jest.fn().mockResolvedValue({ deletedCount: 0 }), + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('createShare', () => { + it('產生的 token 是 32 字元的 hex 字串', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + const token = await createShare('default', 'A', null); + expect(token).toMatch(/^[0-9a-f]{32}$/); + }); + + it('每次呼叫都產生不同的 token', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + const t1 = await createShare('default', 'A', null); + const t2 = await createShare('default', 'A', null); + expect(t1).not.toBe(t2); + }); + + it('寫入的文件包含 profile/scheduleName/personFilter/createdAt,_id 即為 token', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + const token = await createShare('default', 'A', '張三'); + expect(col.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: token, profile: 'default', scheduleName: 'A', personFilter: '張三' }) + ); + }); + + it('personFilter 未提供時存為 null', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + await createShare('default', 'A'); + expect(col.insertOne).toHaveBeenCalledWith(expect.objectContaining({ personFilter: null })); + }); + + it('未指定 expiresInDays 時不寫入 expiresAt 欄位(永久有效)', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + await createShare('default', 'A', null, null); + const doc = col.insertOne.mock.calls[0][0]; + expect(doc).not.toHaveProperty('expiresAt'); + }); + + it('指定 expiresInDays 時寫入正確的 expiresAt(未來時間)', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + const before = Date.now(); + await createShare('default', 'A', null, 7); + const after = Date.now(); + const doc = col.insertOne.mock.calls[0][0]; + expect(doc.expiresAt).toBeInstanceOf(Date); + const expectedMin = before + 7 * 24 * 60 * 60 * 1000; + const expectedMax = after + 7 * 24 * 60 * 60 * 1000; + expect(doc.expiresAt.getTime()).toBeGreaterThanOrEqual(expectedMin); + expect(doc.expiresAt.getTime()).toBeLessThanOrEqual(expectedMax); + }); +}); + +describe('getShare', () => { + it('回傳 collection.findOne 依 _id 查詢的結果', async () => { + const col = makeCol(); + col.findOne.mockResolvedValue({ _id: 'tok', profile: 'default', scheduleName: 'A', personFilter: null }); + getScheduleSharesCollection.mockReturnValue(col); + const result = await getShare('tok'); + expect(col.findOne).toHaveBeenCalledWith({ _id: 'tok' }); + expect(result.scheduleName).toBe('A'); + }); + + it('查無資料時回傳 null', async () => { + const col = makeCol(); + getScheduleSharesCollection.mockReturnValue(col); + const result = await getShare('nope'); + expect(result).toBeNull(); + }); +}); + +describe('listShares', () => { + it('依 profile 與 scheduleName 查詢,並依 createdAt 由新到舊排序', async () => { + const col = makeCol(); + const docs = [{ _id: 'tok1' }, { _id: 'tok2' }]; + const sortMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue(docs) }); + col.find = jest.fn().mockReturnValue({ sort: sortMock }); + getScheduleSharesCollection.mockReturnValue(col); + + const result = await listShares('default', 'A'); + + expect(col.find).toHaveBeenCalledWith({ profile: 'default', scheduleName: 'A' }); + expect(sortMock).toHaveBeenCalledWith({ createdAt: -1 }); + expect(result).toEqual(docs); + }); +}); + +describe('deleteShare', () => { + it('刪除成功時回傳 true', async () => { + const col = makeCol(); + col.deleteOne.mockResolvedValue({ deletedCount: 1 }); + getScheduleSharesCollection.mockReturnValue(col); + const result = await deleteShare('tok'); + expect(col.deleteOne).toHaveBeenCalledWith({ _id: 'tok' }); + expect(result).toBe(true); + }); + + it('找不到對應 token 時回傳 false', async () => { + const col = makeCol(); + col.deleteOne.mockResolvedValue({ deletedCount: 0 }); + getScheduleSharesCollection.mockReturnValue(col); + const result = await deleteShare('nope'); + expect(result).toBe(false); + }); +}); diff --git a/backend/tests/routes/pdfPayload.test.js b/backend/tests/routes/pdfPayload.test.js new file mode 100644 index 0000000..4335a24 --- /dev/null +++ b/backend/tests/routes/pdfPayload.test.js @@ -0,0 +1,84 @@ +'use strict'; + +process.env.NODE_ENV = 'test'; + +jest.mock('../../src/services/pdfPayloadCrypto', () => ({ + isConfigured: jest.fn(), + encryptPayload: jest.fn(), + decryptPayload: jest.fn(), +})); + +const request = require('supertest'); +const app = require('../../server'); +const { isConfigured, encryptPayload, decryptPayload } = require('../../src/services/pdfPayloadCrypto'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('POST /api/pdf-payload/encrypt', () => { + it('未設定金鑰時回傳 503', async () => { + isConfigured.mockReturnValue(false); + const res = await request(app).post('/api/pdf-payload/encrypt').send({ data: { a: 1 } }); + expect(res.status).toBe(503); + expect(encryptPayload).not.toHaveBeenCalled(); + }); + + it('缺少 data 欄位時回傳 400', async () => { + isConfigured.mockReturnValue(true); + const res = await request(app).post('/api/pdf-payload/encrypt').send({}); + expect(res.status).toBe(400); + }); + + it('成功加密時回傳 payload 字串', async () => { + isConfigured.mockReturnValue(true); + encryptPayload.mockReturnValue('encrypted-base64-string'); + const res = await request(app).post('/api/pdf-payload/encrypt').send({ data: { a: 1 } }); + expect(res.status).toBe(200); + expect(res.body.payload).toBe('encrypted-base64-string'); + expect(encryptPayload).toHaveBeenCalledWith({ a: 1 }); + }); + + it('加密過程拋出例外時回傳 500', async () => { + isConfigured.mockReturnValue(true); + encryptPayload.mockImplementation(() => { throw new Error('boom'); }); + const res = await request(app).post('/api/pdf-payload/encrypt').send({ data: { a: 1 } }); + expect(res.status).toBe(500); + }); +}); + +describe('POST /api/pdf-payload/decrypt', () => { + it('未設定金鑰時回傳 503', async () => { + isConfigured.mockReturnValue(false); + const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); + expect(res.status).toBe(503); + expect(decryptPayload).not.toHaveBeenCalled(); + }); + + it('缺少 payload 欄位時回傳 400', async () => { + isConfigured.mockReturnValue(true); + const res = await request(app).post('/api/pdf-payload/decrypt').send({}); + expect(res.status).toBe(400); + }); + + it('payload 不是字串時回傳 400', async () => { + isConfigured.mockReturnValue(true); + const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 123 }); + expect(res.status).toBe(400); + }); + + it('成功解密時回傳 data', async () => { + isConfigured.mockReturnValue(true); + decryptPayload.mockReturnValue({ a: 1 }); + const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); + expect(res.status).toBe(200); + expect(res.body.data).toEqual({ a: 1 }); + }); + + it('解密失敗(竄改或非本系統匯出的 PDF)時回傳 400', async () => { + isConfigured.mockReturnValue(true); + decryptPayload.mockImplementation(() => { throw new Error('bad auth tag'); }); + const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); + expect(res.status).toBe(400); + }); +}); diff --git a/backend/tests/routes/profiles.test.js b/backend/tests/routes/profiles.test.js index dd78d84..ddc327b 100644 --- a/backend/tests/routes/profiles.test.js +++ b/backend/tests/routes/profiles.test.js @@ -140,6 +140,28 @@ describe('POST /api/profiles', () => { const res = await request(app).post('/api/profiles').send({ name: 'valid' }); expect(res.status).toBe(500); }); + + it('不帶 settings 時 createProfile 只收到 name(沿用空白設定檔行為)', async () => { + repo.createProfile.mockResolvedValue(); + await request(app).post('/api/profiles').send({ name: 'valid-profile' }); + expect(repo.createProfile).toHaveBeenCalledWith('valid-profile'); + }); + + it('帶合法 settings 時(複製設定檔)會一併傳給 createProfile', async () => { + repo.createProfile.mockResolvedValue(); + const settings = { tasks: [{ name: '早班', count: 1 }], personnel: [{ name: '張三', maxShifts: 5 }] }; + const res = await request(app).post('/api/profiles').send({ name: 'copied-profile', settings }); + expect(res.status).toBe(201); + expect(repo.createProfile).toHaveBeenCalledWith('copied-profile', settings); + }); + + it('帶不合法 settings 時回傳 400,不呼叫 createProfile', async () => { + const res = await request(app) + .post('/api/profiles') + .send({ name: 'copied-profile', settings: { tasks: 'not-an-array' } }); + expect(res.status).toBe(400); + expect(repo.createProfile).not.toHaveBeenCalled(); + }); }); // ── PUT /api/profiles/:name ─────────────────────────────────────────────────── diff --git a/backend/tests/routes/shares.test.js b/backend/tests/routes/shares.test.js new file mode 100644 index 0000000..d77e83b --- /dev/null +++ b/backend/tests/routes/shares.test.js @@ -0,0 +1,239 @@ +'use strict'; + +process.env.NODE_ENV = 'test'; + +jest.mock('../../src/db/connect', () => ({ + getIsDbConnected: jest.fn().mockReturnValue(true), + getScheduleSharesCollection: jest.fn(), +})); + +jest.mock('../../src/repositories/profileRepository', () => ({ + getSchedule: jest.fn(), +})); + +jest.mock('../../src/repositories/shareRepository', () => ({ + createShare: jest.fn(), + getShare: jest.fn(), + listShares: jest.fn(), + deleteShare: jest.fn(), +})); + +const request = require('supertest'); +const app = require('../../server'); +const { getIsDbConnected } = require('../../src/db/connect'); +const profileRepo = require('../../src/repositories/profileRepository'); +const shareRepo = require('../../src/repositories/shareRepository'); + +const SAMPLE_SCHEDULE = [ + { + schedule: [[['張三', '李四']], [['張三']], [['李四']], [[]], [['張三', '李四']]], + fillStats: [{ name: '素描教室', priority: 1, needed: 2, filled: 2, ok: true }], + tasks: [{ name: '素描教室', count: 2, priority: 1 }], + dateRange: '08/31 - 09/04', + weekDayDates: ['08/31', '09/01', '09/02', '09/03', '09/04'], + scheduleDays: [ + { date: '20260831', shouldSchedule: true, description: '' }, + { date: '20260901', shouldSchedule: true, description: '' }, + { date: '20260902', shouldSchedule: true, description: '' }, + { date: '20260903', shouldSchedule: true, description: '' }, + { date: '20260904', shouldSchedule: true, description: '' }, + ], + color: { header: '#0284c7', row: '#f0f9ff' }, + weekIndex: 0, + }, +]; + +beforeEach(() => { + jest.clearAllMocks(); + getIsDbConnected.mockReturnValue(true); +}); + +describe('POST /api/schedule-shares', () => { + it('DB 未連線時回傳 503', async () => { + getIsDbConnected.mockReturnValue(false); + const res = await request(app).post('/api/schedule-shares').send({ profile: 'default', scheduleName: 'A' }); + expect(res.status).toBe(503); + }); + + it('profile 名稱不合法時回傳 400', async () => { + const res = await request(app).post('/api/schedule-shares').send({ profile: 'a/b', scheduleName: 'A' }); + expect(res.status).toBe(400); + }); + + it('scheduleName 不合法時回傳 400', async () => { + const res = await request(app).post('/api/schedule-shares').send({ profile: 'default', scheduleName: '' }); + expect(res.status).toBe(400); + }); + + it('personFilter 不是字串時回傳 400', async () => { + const res = await request(app) + .post('/api/schedule-shares') + .send({ profile: 'default', scheduleName: 'A', personFilter: 123 }); + expect(res.status).toBe(400); + }); + + it('找不到指定班表時回傳 404', async () => { + profileRepo.getSchedule.mockResolvedValue(null); + const res = await request(app).post('/api/schedule-shares').send({ profile: 'default', scheduleName: 'A' }); + expect(res.status).toBe(404); + }); + + it('成功時回傳 201 與 token', async () => { + profileRepo.getSchedule.mockResolvedValue(SAMPLE_SCHEDULE); + shareRepo.createShare.mockResolvedValue('abc123token'); + const res = await request(app) + .post('/api/schedule-shares') + .send({ profile: 'default', scheduleName: 'A', personFilter: '張三' }); + expect(res.status).toBe(201); + expect(res.body.token).toBe('abc123token'); + expect(shareRepo.createShare).toHaveBeenCalledWith('default', 'A', '張三', null); + }); + + it('不帶 personFilter/expiresInDays 時以 null 建立(永久分享整份班表)', async () => { + profileRepo.getSchedule.mockResolvedValue(SAMPLE_SCHEDULE); + shareRepo.createShare.mockResolvedValue('token2'); + await request(app).post('/api/schedule-shares').send({ profile: 'default', scheduleName: 'A' }); + expect(shareRepo.createShare).toHaveBeenCalledWith('default', 'A', null, null); + }); + + it('帶合法 expiresInDays 時一併傳給 createShare', async () => { + profileRepo.getSchedule.mockResolvedValue(SAMPLE_SCHEDULE); + shareRepo.createShare.mockResolvedValue('token3'); + await request(app) + .post('/api/schedule-shares') + .send({ profile: 'default', scheduleName: 'A', expiresInDays: 7 }); + expect(shareRepo.createShare).toHaveBeenCalledWith('default', 'A', null, 7); + }); + + it('expiresInDays 不是整數時回傳 400', async () => { + const res = await request(app) + .post('/api/schedule-shares') + .send({ profile: 'default', scheduleName: 'A', expiresInDays: 7.5 }); + expect(res.status).toBe(400); + }); + + it('expiresInDays 超出 1-365 範圍時回傳 400', async () => { + const res = await request(app) + .post('/api/schedule-shares') + .send({ profile: 'default', scheduleName: 'A', expiresInDays: 400 }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/schedule-shares/:token', () => { + it('DB 未連線時回傳 503', async () => { + getIsDbConnected.mockReturnValue(false); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(503); + }); + + it('token 不存在時回傳 404', async () => { + shareRepo.getShare.mockResolvedValue(null); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(404); + }); + + it('token 存在但 expiresAt 已過期時回傳 404,且不去查班表資料', async () => { + shareRepo.getShare.mockResolvedValue({ + profile: 'default', + scheduleName: 'A', + personFilter: null, + expiresAt: new Date(Date.now() - 1000), + }); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(404); + expect(res.body.message).toContain('過期'); + expect(profileRepo.getSchedule).not.toHaveBeenCalled(); + }); + + it('token 存在且 expiresAt 尚未到期時正常回傳', async () => { + shareRepo.getShare.mockResolvedValue({ + profile: 'default', + scheduleName: 'A', + personFilter: null, + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + }); + profileRepo.getSchedule.mockResolvedValue(SAMPLE_SCHEDULE); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(200); + }); + + it('token 存在但班表已被刪除時回傳 404', async () => { + shareRepo.getShare.mockResolvedValue({ profile: 'default', scheduleName: 'A', personFilter: null }); + profileRepo.getSchedule.mockResolvedValue(null); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(404); + }); + + it('personFilter 為 null 時回傳完整班表的 HTML(所有人都看得到)', async () => { + shareRepo.getShare.mockResolvedValue({ profile: 'default', scheduleName: 'A', personFilter: null }); + profileRepo.getSchedule.mockResolvedValue(SAMPLE_SCHEDULE); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(200); + expect(res.body.html).toContain('張三'); + expect(res.body.html).toContain('李四'); + expect(res.body.personFilter).toBeNull(); + }); + + it('personFilter 有指定時,回傳的 HTML 只包含該人員的名字,其他人被過濾掉', async () => { + shareRepo.getShare.mockResolvedValue({ profile: 'default', scheduleName: 'A', personFilter: '張三' }); + profileRepo.getSchedule.mockResolvedValue(SAMPLE_SCHEDULE); + const res = await request(app).get('/api/schedule-shares/xxx'); + expect(res.status).toBe(200); + expect(res.body.html).toContain('張三'); + expect(res.body.html).not.toContain('李四'); + expect(res.body.personFilter).toBe('張三'); + }); +}); + +describe('GET /api/schedule-shares(列出分享連結)', () => { + it('DB 未連線時回傳 503', async () => { + getIsDbConnected.mockReturnValue(false); + const res = await request(app).get('/api/schedule-shares').query({ profile: 'default', scheduleName: 'A' }); + expect(res.status).toBe(503); + }); + + it('profile 不合法時回傳 400', async () => { + const res = await request(app).get('/api/schedule-shares').query({ profile: 'a/b', scheduleName: 'A' }); + expect(res.status).toBe(400); + }); + + it('scheduleName 不合法時回傳 400', async () => { + const res = await request(app).get('/api/schedule-shares').query({ profile: 'default', scheduleName: '' }); + expect(res.status).toBe(400); + }); + + it('成功時回傳精簡欄位的陣列(不含 profile/scheduleName,只給 token/personFilter/createdAt/expiresAt)', async () => { + shareRepo.listShares.mockResolvedValue([ + { _id: 'tok1', profile: 'default', scheduleName: 'A', personFilter: '張三', createdAt: new Date(1), expiresAt: null }, + { _id: 'tok2', profile: 'default', scheduleName: 'A', personFilter: null, createdAt: new Date(2), expiresAt: new Date(3) }, + ]); + const res = await request(app).get('/api/schedule-shares').query({ profile: 'default', scheduleName: 'A' }); + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { token: 'tok1', personFilter: '張三', createdAt: new Date(1).toISOString(), expiresAt: null }, + { token: 'tok2', personFilter: null, createdAt: new Date(2).toISOString(), expiresAt: new Date(3).toISOString() }, + ]); + }); +}); + +describe('DELETE /api/schedule-shares/:token(撤銷分享連結)', () => { + it('DB 未連線時回傳 503', async () => { + getIsDbConnected.mockReturnValue(false); + const res = await request(app).delete('/api/schedule-shares/xxx'); + expect(res.status).toBe(503); + }); + + it('成功刪除時回傳 200', async () => { + shareRepo.deleteShare.mockResolvedValue(true); + const res = await request(app).delete('/api/schedule-shares/xxx'); + expect(res.status).toBe(200); + expect(shareRepo.deleteShare).toHaveBeenCalledWith('xxx'); + }); + + it('token 不存在時回傳 404', async () => { + shareRepo.deleteShare.mockResolvedValue(false); + const res = await request(app).delete('/api/schedule-shares/xxx'); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/tests/routes/status.test.js b/backend/tests/routes/status.test.js index d0efaca..c0ca9c8 100644 --- a/backend/tests/routes/status.test.js +++ b/backend/tests/routes/status.test.js @@ -17,6 +17,7 @@ jest.mock('../../src/db/connect', () => ({ jest.mock('../../src/services/holidayService', () => ({ holidaysCache: new Map(), + lastRefreshStatus: { at: null, years: {} }, getWeekInfo: jest.fn(), getHolidaysForYear: jest.fn().mockResolvedValue(new Map()), seedHolidays: jest.fn(), @@ -38,6 +39,7 @@ jest.mock('../../src/repositories/profileRepository', () => ({ jest.mock('../../src/services/schoolCalendar', () => ({ getSchoolEvents: jest.fn(), schoolEventsCache: { data: null, fetchedAt: 0 }, + getLastFetchStatus: jest.fn().mockReturnValue({ at: null, success: null, eventCount: null, warning: null }), })); // ── 測試主體 ────────────────────────────────────────────────────────────────── @@ -45,7 +47,8 @@ jest.mock('../../src/services/schoolCalendar', () => ({ const request = require('supertest'); const app = require('../../server'); const { getIsDbConnected, getHolidaysCollection, getConfigCollection } = require('../../src/db/connect'); -const { holidaysCache } = require('../../src/services/holidayService'); +const { holidaysCache, lastRefreshStatus } = require('../../src/services/holidayService'); +const { getLastFetchStatus } = require('../../src/services/schoolCalendar'); beforeEach(() => { jest.clearAllMocks(); @@ -97,4 +100,30 @@ describe('GET /api/status', () => { expect(res.body.database).toBe('connected'); expect(res.body.dbError).toBe('query timeout'); }); + + it('回傳假日自動更新與學校行事曆抓取的最近一次結果', async () => { + getIsDbConnected.mockReturnValue(false); + lastRefreshStatus.at = 1234567890; + lastRefreshStatus.years = { 2026: { success: true, count: 47 } }; + + const res = await request(app).get('/api/status'); + + expect(res.body.holidaysLastRefresh).toEqual({ at: 1234567890, years: { 2026: { success: true, count: 47 } } }); + expect(res.body.schoolCalendarLastFetch).toEqual({ at: null, success: null, eventCount: null, warning: null }); + }); + + it('學校行事曆抓取結果為 0 筆時,狀態帶有 warning', async () => { + getIsDbConnected.mockReturnValue(false); + getLastFetchStatus.mockReturnValue({ + at: 1234567890, + success: false, + eventCount: 0, + warning: '本次即時抓取沒有找到任何考試資料,可能是學校網站格式已變更', + }); + + const res = await request(app).get('/api/status'); + + expect(res.body.schoolCalendarLastFetch.success).toBe(false); + expect(res.body.schoolCalendarLastFetch.warning).toContain('格式已變更'); + }); }); diff --git a/backend/tests/services/holidayService.test.js b/backend/tests/services/holidayService.test.js index fb30cb2..005d428 100644 --- a/backend/tests/services/holidayService.test.js +++ b/backend/tests/services/holidayService.test.js @@ -24,6 +24,7 @@ const fsMock = require('fs'); const { holidaysCache, + lastRefreshStatus, getWeekInfo, getHolidaysForYear, refreshHolidaysFromCDN, @@ -220,6 +221,52 @@ describe('refreshHolidaysFromCDN', () => { expect(holidaysCache.has(currentYear)).toBe(false); expect(holidaysCache.has(currentYear + 1)).toBe(false); }); + + it('成功抓到資料時 lastRefreshStatus 記錄 success 與筆數', async () => { + getIsDbConnected.mockReturnValue(true); + const currentYear = new Date().getFullYear(); + const col = makeCol(); + // deleteMany 後 toArray 回傳非空(模擬 CDN 重新植入成功) + col.toArray.mockResolvedValue([{ _id: `${currentYear}0101`, name: '元旦', isHoliday: true }]); + getHolidaysCollection.mockReturnValue(col); + + await refreshHolidaysFromCDN(); + + expect(lastRefreshStatus.at).not.toBeNull(); + expect(lastRefreshStatus.years[currentYear]).toEqual({ success: true, count: 1 }); + }); + + it('CDN 與資料庫都抓不到資料時(0 筆)標記 success:false 並發出 console.warn', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + getIsDbConnected.mockReturnValue(true); + const currentYear = new Date().getFullYear(); + const col = makeCol(); + col.toArray.mockResolvedValue([]); // DB 無資料 + getHolidaysCollection.mockReturnValue(col); + global.fetch.mockResolvedValue({ ok: false }); // CDN 也失敗 + + await refreshHolidaysFromCDN(); + + expect(lastRefreshStatus.years[currentYear]).toEqual({ success: false, count: 0 }); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('deleteMany 拋出例外時該年份記錄 error 訊息(非只是吞掉錯誤)', async () => { + getIsDbConnected.mockReturnValue(true); + const currentYear = new Date().getFullYear(); + const col = makeCol(); + col.deleteMany.mockRejectedValue(new Error('query timeout')); + getHolidaysCollection.mockReturnValue(col); + + await refreshHolidaysFromCDN(); + + expect(lastRefreshStatus.years[currentYear]).toEqual({ + success: false, + count: 0, + error: 'query timeout', + }); + }); }); // ── seedHolidays ───────────────────────────────────────────────────────────── diff --git a/backend/tests/services/pdfPayloadCrypto.test.js b/backend/tests/services/pdfPayloadCrypto.test.js new file mode 100644 index 0000000..1ff0e36 --- /dev/null +++ b/backend/tests/services/pdfPayloadCrypto.test.js @@ -0,0 +1,71 @@ +'use strict'; + +process.env.NODE_ENV = 'test'; + +describe('pdfPayloadCrypto', () => { + const VALID_SECRET = 'a'.repeat(64); + + const loadModule = (secret) => { + jest.resetModules(); + if (secret === undefined) { + delete process.env.PDF_PAYLOAD_SECRET; + } else { + process.env.PDF_PAYLOAD_SECRET = secret; + } + return require('../../src/services/pdfPayloadCrypto'); + }; + + afterEach(() => { + delete process.env.PDF_PAYLOAD_SECRET; + }); + + it('isConfigured() 在密鑰未設定時回傳 false', () => { + const { isConfigured } = loadModule(undefined); + expect(isConfigured()).toBe(false); + }); + + it('isConfigured() 在密鑰格式錯誤時回傳 false', () => { + const { isConfigured } = loadModule('not-a-valid-hex-key'); + expect(isConfigured()).toBe(false); + }); + + it('isConfigured() 在密鑰格式正確時回傳 true', () => { + const { isConfigured } = loadModule(VALID_SECRET); + expect(isConfigured()).toBe(true); + }); + + it('encryptPayload/decryptPayload 可以正確往返還原資料', () => { + const { encryptPayload, decryptPayload } = loadModule(VALID_SECRET); + const original = { weeks: [{ tasks: ['素描教室'], schedule: [['張三', '李四']] }] }; + const encrypted = encryptPayload(original); + expect(typeof encrypted).toBe('string'); + expect(decryptPayload(encrypted)).toEqual(original); + }); + + it('相同資料每次加密結果不同(iv 隨機產生)', () => { + const { encryptPayload } = loadModule(VALID_SECRET); + expect(encryptPayload({ x: 1 })).not.toBe(encryptPayload({ x: 1 })); + }); + + it('decryptPayload 遇到被竄改的內容會拋出例外', () => { + const { encryptPayload, decryptPayload } = loadModule(VALID_SECRET); + const encrypted = encryptPayload({ x: 1 }); + const tampered = encrypted.slice(0, -4) + (encrypted.slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'); + expect(() => decryptPayload(tampered)).toThrow(); + }); + + it('decryptPayload 遇到格式不符的字串(非本系統加密)會拋出例外', () => { + const { decryptPayload } = loadModule(VALID_SECRET); + expect(() => decryptPayload(Buffer.from('random unrelated text').toString('base64'))).toThrow(); + }); + + it('encryptPayload 在密鑰未設定時拋出例外', () => { + const { encryptPayload } = loadModule(undefined); + expect(() => encryptPayload({ x: 1 })).toThrow(); + }); + + it('decryptPayload 在密鑰未設定時拋出例外', () => { + const { decryptPayload } = loadModule(undefined); + expect(() => decryptPayload('anything')).toThrow(); + }); +}); diff --git a/backend/tests/services/schoolCalendar.test.js b/backend/tests/services/schoolCalendar.test.js index 6e8f24f..8db29d1 100644 --- a/backend/tests/services/schoolCalendar.test.js +++ b/backend/tests/services/schoolCalendar.test.js @@ -174,6 +174,24 @@ describe('getSchoolEvents — 無 DB,fetch 成功空 HTML', () => { expect(result.cached).toBe(false); expect(result.data).toEqual([]); }); + + it('即時抓取結果為 0 筆時,getLastFetchStatus() 標記 success:false 並帶 warning,同時發出 console.warn', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const mod = loadModule(); + getIsDbConnected.mockReturnValue(false); + global.fetch.mockResolvedValue({ + arrayBuffer: jest.fn().mockResolvedValue(makeArrayBuffer(EMPTY_HTML)), + }); + + await mod.getSchoolEvents(); + + const status = mod.getLastFetchStatus(); + expect(status.success).toBe(false); + expect(status.eventCount).toBe(0); + expect(status.warning).toContain('格式已變更'); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); // ── 無 DB,fetch 成功(含考試 HTML)────────────────────────────────────────── @@ -194,6 +212,23 @@ describe('getSchoolEvents — 無 DB,解析考試資料', () => { expect(ev).toHaveProperty('endDate'); expect(ev).toHaveProperty('type', 'exam'); }); + + it('抓到資料時 getLastFetchStatus() 標記 success:true,且不發出 console.warn', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const mod = loadModule(); + getIsDbConnected.mockReturnValue(false); + global.fetch.mockResolvedValue({ + arrayBuffer: jest.fn().mockResolvedValue(makeArrayBuffer(EXAM_HTML)), + }); + + await mod.getSchoolEvents(); + + const status = mod.getLastFetchStatus(); + expect(status.success).toBe(true); + expect(status.warning).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); // ── isHighSchoolExam 與 getCleanExamName 各分支 ────────────────────────────── diff --git a/e2e/tests/04-golden-path.spec.js b/e2e/tests/04-golden-path.spec.js index c69a7f1..468da98 100644 --- a/e2e/tests/04-golden-path.spec.js +++ b/e2e/tests/04-golden-path.spec.js @@ -95,7 +95,7 @@ test.describe('黃金路徑', () => { await expect(page.locator('#save-edits-btn')).toBeDisabled(); }); - test('匯出 Excel 觸發下載,檔名為 班表.xlsx', async ({ page }) => { + test('匯出 Excel 觸發下載,檔名符合「班表_日期範圍_時間戳記」規則', async ({ page }) => { await generateMinimalSchedule(page); const [download] = await Promise.all([ @@ -103,10 +103,12 @@ test.describe('黃金路徑', () => { page.locator('#export-excel').click(), ]); - expect(download.suggestedFilename()).toBe('班表.xlsx'); + // 未儲存班表時檔名開頭固定用「班表」;日期範圍+時間戳記每次匯出都不同, + // 用正則比對格式而非固定字串(避免每次執行 CI 都要更新期望值)。 + expect(download.suggestedFilename()).toMatch(/^班表_\d{4}~\d{4}_\d{8}-\d{6}\.xlsx$/); }); - test('匯出 PDF 觸發下載,檔名為 班表.pdf', async ({ page }) => { + test('匯出 PDF 觸發下載,檔名符合「班表_日期範圍_時間戳記」規則', async ({ page }) => { await generateMinimalSchedule(page); const [download] = await Promise.all([ @@ -114,6 +116,6 @@ test.describe('黃金路徑', () => { page.locator('#export-pdf').click(), ]); - expect(download.suggestedFilename()).toBe('班表.pdf'); + expect(download.suggestedFilename()).toMatch(/^班表_\d{4}~\d{4}_\d{8}-\d{6}\.pdf$/); }); }); diff --git a/frontend/index.html b/frontend/index.html index f9b6a85..53066d3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -328,7 +328,7 @@ /* ─── Profile dropdown row ─────────────────────────────────── */ .profile-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .profile-row .select { flex: 1; } -.profile-actions { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } +.profile-actions { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; } .profile-actions .btn { padding: 6px 0; font-size: 12px; } /* ─── Task/Personnel list ──────────────────────────────────── */ @@ -1258,6 +1258,11 @@ .topbar { display: none !important; } aside { display: none !important; } .panel-head > .panel-actions { display: none !important; } + #edit-toolbar { display: none !important; } + .remove-person { display: none !important; } + .person-tag.unfilled { display: none !important; } + .card.saved { display: none !important; } + .app-footer { display: none !important; } #output-container { margin: 0 !important; padding: 0 !important; border: none !important; box-shadow: none !important; background: white !important; } #schedule-output { overflow: visible !important; } .s-table { page-break-inside: avoid; width: 100% !important; } @@ -1323,6 +1328,9 @@ 正在檢查連線... + + @@ -1450,7 +1459,7 @@ @@ -1546,14 +1555,18 @@

可用人員

已儲存的班表

- +
+ + +
+
    @@ -1588,7 +1601,7 @@

    已儲存的班表

    + + +