diff --git a/server/scripts/test-cross-device-notification.js b/server/scripts/test-cross-device-notification.js new file mode 100644 index 0000000..630a983 --- /dev/null +++ b/server/scripts/test-cross-device-notification.js @@ -0,0 +1,118 @@ +"use strict"; + +function parseArgs(argv) { + const result = {}; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + const [key, ...rest] = arg.slice(2).split("="); + if (!key) continue; + const value = rest.length ? rest.join("=") : ""; + result[key] = value; + } + return result; +} + +const args = parseArgs(process.argv); +const BASE_URL = (args.baseUrl || process.env.BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); +const ADMIN_TOKEN = args.adminToken || process.env.ADMIN_TOKEN || ""; +const DEVICE_A = args.deviceA || process.env.DEVICE_A || "develop-test2/test1"; +const DEVICE_B = args.deviceB || process.env.DEVICE_B || "develop-test/test1"; + +if (!ADMIN_TOKEN) { + console.error("[ERROR] 缺少 ADMIN_TOKEN 环境变量。"); + console.error("示例1: node scripts/test-cross-device-notification.js --adminToken=your-token"); + console.error("示例2: node scripts/test-cross-device-notification.js --adminToken=your-token --baseUrl=http://127.0.0.1:3000 --deviceA=develop-test2/test1 --deviceB=develop-test/test1"); + process.exit(1); +} + +async function postJson(path, body) { + const response = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + + let data; + try { + data = await response.json(); + } catch (_error) { + data = { success: false, errMsg: "响应不是 JSON" }; + } + + return { status: response.status, data }; +} + +function assert(condition, message, details) { + if (condition) return; + const error = new Error(message); + error.details = details; + throw error; +} + +async function main() { + console.log(`[INFO] BASE_URL=${BASE_URL}`); + console.log(`[INFO] DEVICE_A=${DEVICE_A}`); + console.log(`[INFO] DEVICE_B=${DEVICE_B}`); + + console.log("\n[1/5] 为 DEVICE_A 生成一条通知(createVolumeConfigRequest)..."); + const createReq = await postJson("/", { + type: "createVolumeConfigRequest", + deviceId: DEVICE_A + }); + assert(createReq.data.success === true, "createVolumeConfigRequest 失败", createReq); + + console.log("[2/5] 读取 DEVICE_A 的 pending notification..."); + const pendingA = await postJson("/", { + type: "getPendingPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_A + }); + assert(pendingA.data.success === true, "getPendingPanelNotification(DEVICE_A) 调用失败", pendingA); + assert(pendingA.data.pending === true, "DEVICE_A 没有待处理通知", pendingA); + const notificationId = pendingA.data.notification?.notificationId; + assert(Boolean(notificationId), "未拿到 notificationId", pendingA); + console.log(`[INFO] notificationId=${notificationId}`); + + console.log("[3/5] 使用 DEVICE_B 尝试确认 DEVICE_A 的 notificationId(预期失败)..."); + const crossAck = await postJson("/", { + type: "ackPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_B, + notificationId + }); + assert(crossAck.data.success === false, "跨 deviceId 确认意外成功(存在越权风险)", crossAck); + assert(crossAck.data.errMsg === "Panel 通知不存在", "跨 deviceId 失败文案非预期", crossAck); + + console.log("[4/5] 使用 DEVICE_A 正常确认同一 notificationId(预期成功)..."); + const ownerAck = await postJson("/", { + type: "ackPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_A, + notificationId + }); + assert(ownerAck.data.success === true, "DEVICE_A 确认自身通知失败", ownerAck); + + console.log("[5/5] 再次读取 DEVICE_A pending,确认已被消费..."); + const pendingAfter = await postJson("/", { + type: "getPendingPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_A + }); + assert(pendingAfter.data.success === true, "二次查询 pending 失败", pendingAfter); + + console.log("\n[PASS] 测试通过:notificationId 不能被其他 deviceId 确认。\n"); + console.log("关键结果:"); + console.log(`- crossAck.success = ${crossAck.data.success}`); + console.log(`- crossAck.errMsg = ${crossAck.data.errMsg}`); + console.log(`- ownerAck.success = ${ownerAck.data.success}`); +} + +main().catch((error) => { + console.error("\n[FAIL]", error.message); + if (error.details) { + console.error("details="); + console.error(JSON.stringify(error.details, null, 2)); + } + process.exit(1); +}); diff --git a/server/src/app.js b/server/src/app.js index df7ab4a..290bfee 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -937,7 +937,7 @@ function createApp({ item.deviceId === deviceId && item.notificationId === notificationId ); if (!exists) { - return { success: true, notificationId, deleted: 0, idempotent: true }; + return { success: false, errMsg: "Panel 通知不存在" }; } database.panelNotifications = database.panelNotifications.filter((item) => !(item.deviceId === deviceId && item.notificationId === notificationId) diff --git a/server/test/server.test.js b/server/test/server.test.js index 019ca3f..6d1fb74 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -550,9 +550,8 @@ test("panel notifications and volume configuration stay isolated by device", asy type: "ackPanelNotification", deviceId, notificationId: notification.notification.notificationId, adminToken: "test-token" }); - assert.equal(duplicatedAck.success, true); - assert.equal(duplicatedAck.idempotent, true); - assert.equal(duplicatedAck.deleted, 0); + assert.equal(duplicatedAck.success, false); + assert.equal(duplicatedAck.errMsg, "Panel 通知不存在"); async function uploadResult(folderName, fileName, content) { const result = await post({ type: "uploadDataFile", fileName, folder: `${deviceId}/${folderName}` });