| 1 | // Automated coverage for the dashboard's target-resolution rail (#4397). |
| 2 | // |
| 3 | // These functions are the only thing standing between "the user clicked a row" |
| 4 | // and "a reply or an approval was POSTed somewhere". Every branch here is a |
| 5 | // fail-closed decision, so every branch is asserted: a saved session is never |
| 6 | // replied to, a stale selection never sends, and an approval that is not in the |
| 7 | // live thread's own approval set is never answered. |
| 8 | // |
| 9 | // Run by `npm test` in `web/` — see `web/vitest.config.ts`, whose `include` |
| 10 | // reaches this file deliberately so the rail cannot regress without CI saying |
| 11 | // so. The module is import-safe outside a browser: `app.mjs` only calls |
| 12 | // `startBrowserClient()` when `document` exists. |
| 13 | |
| 14 | import { describe, expect, it } from "vitest"; |
| 15 | |
| 16 | import { |
| 17 | NO_TARGET, |
| 18 | canReply, |
| 19 | refusalMessage, |
| 20 | resolveApprovalTarget, |
| 21 | resolveReplyTarget, |
| 22 | sessionTarget, |
| 23 | streamCursor, |
| 24 | threadTarget, |
| 25 | } from "./app.mjs"; |
| 26 | |
| 27 | /** Minimal stand-in for the live stream state the real client threads through. */ |
| 28 | function streamState(threadId, approvalIds = []) { |
| 29 | return { threadId, approvals: new Map(approvalIds.map((id) => [id, {}])) }; |
| 30 | } |
| 31 | |
| 32 | describe("canReply", () => { |
| 33 | it("admits only a live thread with an id", () => { |
| 34 | expect(canReply(threadTarget("thread-a"))).toBe(true); |
| 35 | expect(canReply(sessionTarget("session-a"))).toBe(false); |
| 36 | expect(canReply(NO_TARGET)).toBe(false); |
| 37 | expect(canReply(threadTarget(""))).toBe(false); |
| 38 | expect(canReply(undefined)).toBe(false); |
| 39 | }); |
| 40 | }); |
| 41 | |
| 42 | describe("resolveReplyTarget", () => { |
| 43 | it("resolves a live thread that the stream is following", () => { |
| 44 | const resolved = resolveReplyTarget(threadTarget("thread-a"), streamState("thread-a")); |
| 45 | expect(resolved).toEqual({ ok: true, threadId: "thread-a" }); |
| 46 | }); |
| 47 | |
| 48 | it("resolves a live thread before any stream state exists", () => { |
| 49 | // A freshly created thread has no stream yet; refusing here would make the |
| 50 | // first message of every new thread impossible to send. |
| 51 | expect(resolveReplyTarget(threadTarget("thread-a"), null)).toEqual({ |
| 52 | ok: true, |
| 53 | threadId: "thread-a", |
| 54 | }); |
| 55 | expect(resolveReplyTarget(threadTarget("thread-a"), { threadId: "" })).toEqual({ |
| 56 | ok: true, |
| 57 | threadId: "thread-a", |
| 58 | }); |
| 59 | }); |
| 60 | |
| 61 | it("refuses a saved session — a recording has no runtime to receive a reply", () => { |
| 62 | expect(resolveReplyTarget(sessionTarget("session-a"), streamState("thread-a"))).toEqual({ |
| 63 | ok: false, |
| 64 | reason: "session-not-live", |
| 65 | }); |
| 66 | }); |
| 67 | |
| 68 | it("refuses when nothing is selected", () => { |
| 69 | expect(resolveReplyTarget(NO_TARGET, streamState("thread-a"))).toEqual({ |
| 70 | ok: false, |
| 71 | reason: "no-target", |
| 72 | }); |
| 73 | expect(resolveReplyTarget(null, streamState("thread-a"))).toEqual({ |
| 74 | ok: false, |
| 75 | reason: "no-target", |
| 76 | }); |
| 77 | expect(resolveReplyTarget(threadTarget(""), streamState("thread-a"))).toEqual({ |
| 78 | ok: false, |
| 79 | reason: "no-target", |
| 80 | }); |
| 81 | }); |
| 82 | |
| 83 | it("refuses a stale target: the stream moved on while the user did not", () => { |
| 84 | expect(resolveReplyTarget(threadTarget("thread-a"), streamState("thread-b"))).toEqual({ |
| 85 | ok: false, |
| 86 | reason: "stale-target", |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | it("never returns an id on refusal", () => { |
| 91 | for (const [target, state] of [ |
| 92 | [sessionTarget("session-a"), streamState("thread-a")], |
| 93 | [NO_TARGET, streamState("thread-a")], |
| 94 | [threadTarget("thread-a"), streamState("thread-b")], |
| 95 | ]) { |
| 96 | expect(resolveReplyTarget(target, state).threadId).toBeUndefined(); |
| 97 | } |
| 98 | }); |
| 99 | }); |
| 100 | |
| 101 | describe("resolveApprovalTarget", () => { |
| 102 | it("resolves an approval the watched thread actually holds", () => { |
| 103 | const state = streamState("thread-a", ["approval-1"]); |
| 104 | expect(resolveApprovalTarget("approval-1", threadTarget("thread-a"), state)).toEqual({ |
| 105 | ok: true, |
| 106 | threadId: "thread-a", |
| 107 | approvalId: "approval-1", |
| 108 | }); |
| 109 | }); |
| 110 | |
| 111 | it("inherits every reply refusal — authority cannot outrank the target check", () => { |
| 112 | const state = streamState("thread-a", ["approval-1"]); |
| 113 | expect(resolveApprovalTarget("approval-1", sessionTarget("session-a"), state).reason).toBe( |
| 114 | "session-not-live", |
| 115 | ); |
| 116 | expect(resolveApprovalTarget("approval-1", NO_TARGET, state).reason).toBe("no-target"); |
| 117 | expect( |
| 118 | resolveApprovalTarget("approval-1", threadTarget("thread-b"), state).reason, |
| 119 | ).toBe("stale-target"); |
| 120 | }); |
| 121 | |
| 122 | it("refuses an approval that is not in the live approval set", () => { |
| 123 | // Already decided, expired, or belonging to a thread we stopped watching. |
| 124 | const state = streamState("thread-a", ["approval-1"]); |
| 125 | expect(resolveApprovalTarget("approval-2", threadTarget("thread-a"), state)).toEqual({ |
| 126 | ok: false, |
| 127 | reason: "stale-approval", |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | it("refuses when the stream state is missing or belongs to another thread", () => { |
| 132 | expect(resolveApprovalTarget("approval-1", threadTarget("thread-a"), null)).toEqual({ |
| 133 | ok: false, |
| 134 | reason: "stale-target", |
| 135 | }); |
| 136 | // A reply would be allowed here (no stream yet), but an approval must not: |
| 137 | // there is no live approval set to check membership against. |
| 138 | expect( |
| 139 | resolveApprovalTarget("approval-1", threadTarget("thread-a"), { threadId: "" }), |
| 140 | ).toEqual({ ok: false, reason: "stale-target" }); |
| 141 | }); |
| 142 | |
| 143 | it("refuses an unidentified approval", () => { |
| 144 | const state = streamState("thread-a", ["approval-1"]); |
| 145 | expect(resolveApprovalTarget("", threadTarget("thread-a"), state)).toEqual({ |
| 146 | ok: false, |
| 147 | reason: "no-approval", |
| 148 | }); |
| 149 | }); |
| 150 | |
| 151 | it("refuses when the thread has no approvals at all", () => { |
| 152 | expect( |
| 153 | resolveApprovalTarget("approval-1", threadTarget("thread-a"), { threadId: "thread-a" }), |
| 154 | ).toEqual({ ok: false, reason: "stale-approval" }); |
| 155 | }); |
| 156 | |
| 157 | it("never returns an approval id on refusal", () => { |
| 158 | const state = streamState("thread-a", ["approval-1"]); |
| 159 | for (const [id, target] of [ |
| 160 | ["approval-2", threadTarget("thread-a")], |
| 161 | ["approval-1", sessionTarget("session-a")], |
| 162 | ["", threadTarget("thread-a")], |
| 163 | ]) { |
| 164 | expect(resolveApprovalTarget(id, target, state).approvalId).toBeUndefined(); |
| 165 | } |
| 166 | }); |
| 167 | }); |
| 168 | |
| 169 | describe("refusalMessage", () => { |
| 170 | it("gives every refusal reason a distinct user-visible sentence", () => { |
| 171 | const reasons = ["session-not-live", "stale-target", "stale-approval", "no-approval", "no-target"]; |
| 172 | const messages = reasons.map(refusalMessage); |
| 173 | expect(new Set(messages).size).toBe(reasons.length); |
| 174 | for (const message of messages) { |
| 175 | // Every refusal must state the outcome, not just the cause: the user |
| 176 | // needs to know their message did not go anywhere. |
| 177 | expect(message).toContain("nothing was sent"); |
| 178 | } |
| 179 | }); |
| 180 | |
| 181 | it("falls back to the select-a-thread message for an unknown reason", () => { |
| 182 | expect(refusalMessage("something-new")).toBe(refusalMessage("no-target")); |
| 183 | }); |
| 184 | }); |
| 185 | |
| 186 | describe("streamCursor", () => { |
| 187 | it("reports the resume sequence and a live label", () => { |
| 188 | expect(streamCursor({ latestSeq: 12 })).toEqual({ |
| 189 | latestSeq: 12, |
| 190 | gap: false, |
| 191 | connected: true, |
| 192 | label: "Live — event #12", |
| 193 | }); |
| 194 | }); |
| 195 | |
| 196 | it("names a gap and a disconnect distinctly, both carrying the resume point", () => { |
| 197 | expect(streamCursor({ latestSeq: 5 }, { gap: true }).label).toBe( |
| 198 | "Gap detected — re-syncing from #5", |
| 199 | ); |
| 200 | expect(streamCursor({ latestSeq: 5 }, { connected: false }).label).toBe( |
| 201 | "Reconnecting — resuming from #5", |
| 202 | ); |
| 203 | }); |
| 204 | |
| 205 | it("normalizes a missing or nonsense sequence to zero rather than NaN", () => { |
| 206 | expect(streamCursor(undefined).latestSeq).toBe(0); |
| 207 | expect(streamCursor({ latestSeq: "not a number" }).latestSeq).toBe(0); |
| 208 | }); |
| 209 | }); |
| 210 |