// tests for the pending takeover claim: tests/test_claim.cpp // // Phase 03 task 01 opens one door a crack: the spare socket may now complete the // WebSocket handshake and authenticate as a *pending claim* instead of getting a 503. // Nothing else is on the wire yet, so everything that decides what that socket is // allowed to be -- which role, which commands, which clock -- lives in src/claim.h, // and this suite walks it: the whitelist as a matrix (every Cmd, both auth states, // both roles), the deadline arithmetic on both sides of the millis() rollover, and // the claim's buffer against the widest frame it may receive -- refused, not // buffered, measured through the real codec rather than asserted from the constant. #include #include "claim.h" #include "config.h" #include "proto.h" #include "test.h" #include "ws_codec.h" using kr::claim::acceptsUpgrade; using kr::claim::expired; using kr::claim::honours; using kr::claim::ViewerRole; using kr::proto::Cmd; using kr::proto::Message; using kr::proto::parse; namespace { // Every value proto::Cmd has. The matrix below walks it, so a new command added to // the protocol is a new row that must be judged allowed-or-refused for a claim -- // the whitelist decides by name, and this list is what notices a name appears. constexpr Cmd kAll[] = {Cmd::None, Cmd::Hello, Cmd::Auth, Cmd::Arm, Cmd::Disarm, Cmd::State, Cmd::SecureAttention, Cmd::Chord, Cmd::Text, Cmd::Mouse, Cmd::ReleaseAll, Cmd::KeepAlive, Cmd::Takeover, Cmd::Bad}; constexpr size_t kNumCmds = sizeof(kAll) / sizeof(kAll[0]); // One masked client text frame, the only shape the codec accepts, sized for a // payload below 126 bytes (the claim's whole budget is 64). size_t maskedTextFrame(uint8_t *out, const uint8_t *text, size_t n) { const uint8_t key[4] = {0x37, 0xfa, 0x11, 0xde}; size_t o = 0; out[o++] = 0x81; // FIN + text out[o++] = static_cast(0x80 | n); // masked, 7-bit length for (size_t i = 0; i < 4; i++) out[o++] = key[i]; for (size_t i = 0; i < n; i++) out[o++] = static_cast(text[i] ^ key[i & 3]); return o; } } // namespace int main() { CASE("the whitelist, unauthenticated: T is the only door"); for (size_t i = 0; i < kNumCmds; i++) { CHECK_EQ(honours(ViewerRole::Claim, false, kAll[i]), kAll[i] == Cmd::Auth); } CASE("the whitelist, authenticated: T, K and the deliberate X -- nothing else"); for (size_t i = 0; i < kNumCmds; i++) { const bool allowed = kAll[i] == Cmd::Auth || kAll[i] == Cmd::KeepAlive || kAll[i] == Cmd::Takeover; CHECK_EQ(honours(ViewerRole::Claim, true, kAll[i]), allowed); } // X is allowed ONLY here, in the authenticated branch: it is the one deliberate act // the door exists for, and control_server.cpp::handOverClaim -- the only path it // triggers -- runs the release funnel and lands the board DISARMED. The claim socket // that reaches it has already proven the passphrase. CHECK(honours(ViewerRole::Claim, true, Cmd::Takeover)); // Refused by name, because this list is the safety argument: reaching the target, // disturbing the incumbent, or moving the arming line are all refused here. CHECK(!honours(ViewerRole::Claim, true, Cmd::Arm)); // no arm CHECK(!honours(ViewerRole::Claim, true, Cmd::Disarm)); CHECK(!honours(ViewerRole::Claim, true, Cmd::State)); // no keystrokes CHECK(!honours(ViewerRole::Claim, true, Cmd::SecureAttention)); // no Ctrl+Alt+Del CHECK(!honours(ViewerRole::Claim, true, Cmd::Chord)); CHECK(!honours(ViewerRole::Claim, true, Cmd::Text)); // no paste CHECK(!honours(ViewerRole::Claim, true, Cmd::Mouse)); // no pointer CHECK(!honours(ViewerRole::Claim, true, Cmd::ReleaseAll)); // no R either CHECK(!honours(ViewerRole::Claim, true, Cmd::Hello)); // no status CHECK(!honours(ViewerRole::Claim, true, Cmd::Bad)); // not a conversation CASE("a Readonly slot honours no frame at all, authenticated or not"); for (size_t i = 0; i < kNumCmds; i++) { CHECK(!honours(ViewerRole::Readonly, false, kAll[i])); CHECK(!honours(ViewerRole::Readonly, true, kAll[i])); } // Including the takeover ask: a slot that never did the upgrade cannot claim a bridge. CHECK(!honours(ViewerRole::Readonly, true, Cmd::Takeover)); CASE("one claim at a time; a read-only slot may be turned into one"); CHECK(acceptsUpgrade(ViewerRole::Readonly)); CHECK(!acceptsUpgrade(ViewerRole::Claim)); CASE("deadlines: authenticate on the controller's clock, then wait our own"); { const uint32_t anchor = 1234567; // Unauthenticated: exactly the controller's kAuthTimeoutMs, strictly greater. CHECK(!expired(false, anchor, anchor)); CHECK(!expired(false, anchor + kr::kAuthTimeoutMs, anchor)); CHECK(expired(false, anchor + kr::kAuthTimeoutMs + 1, anchor)); // Authenticated: the auth clock no longer applies -- a claim that got its `OK` // at the worst legal moment is still a claim -- and kClaimTimeoutMs governs. CHECK(!expired(true, anchor + kr::kAuthTimeoutMs + 1, anchor)); CHECK(!expired(true, anchor + kr::kClaimTimeoutMs, anchor)); CHECK(expired(true, anchor + kr::kClaimTimeoutMs + 1, anchor)); CHECK(kr::kClaimTimeoutMs > kr::kAuthTimeoutMs); // 15 s for a click, 5 s for T // Across the 49.7-day millis() rollover the arithmetic is elapsed time, not // magnitude: a live claim is not evicted and a dead one is not immortal. const uint32_t nearWrap = 0xFFFFFF00u; CHECK(!expired(false, nearWrap + 4000, nearWrap)); CHECK(expired(false, nearWrap + kr::kAuthTimeoutMs + 1, nearWrap)); // wraps CHECK(!expired(true, nearWrap + 14000, nearWrap)); CHECK(expired(true, nearWrap + kr::kClaimTimeoutMs + 1, nearWrap)); // wraps } CASE("the claim's budget: every legal frame fits, anything larger is refused"); { // The sizing claim, pinned as numbers: the widest frame a claim may receive is // `T` at kTokenMax + 1, the cap is that with headroom, and it is exactly // one quarter of the controller's kWsMessageCap (config.h carries the reasoning; // a draft said sixteenth, the arithmetic says four, and the arithmetic wins). CHECK(kr::kTokenMax + 1 <= kr::kClaimMsgCap); CHECK_EQ(kr::kWsMessageCap / kr::kClaimMsgCap, 4u); CHECK(kr::kClaimMsgCap < kr::kWsMessageCap); // The widest legal T frame parses, and fits the buffer with room to spare. char widest[ kr::kTokenMax + 2]; widest[0] = 'T'; memset(widest + 1, 'a', kr::kTokenMax); widest[kr::kTokenMax + 1] = '\0'; Message m; CHECK(parse(widest, kr::kTokenMax + 1, m) == Cmd::Auth); CHECK(strlen(widest) < kr::kClaimMsgCap); } CASE("an oversized claim frame is refused by the codec, not buffered"); { uint8_t msg[kr::kClaimMsgCap + 16]; memset(msg, 'z', sizeof(msg)); // Exactly at the cap: the reader completes (the frame is refused or accepted by // the whitelist later -- the codec's answer is "it fits"). uint8_t buf[kr::kClaimMsgCap]; kr::ws::Reader r{buf, sizeof(buf)}; uint8_t frame[256]; size_t n = maskedTextFrame(frame, msg, kr::kClaimMsgCap); kr::ws::In in = kr::ws::In::NeedMore; for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r.feed(frame[i]); CHECK(in == kr::ws::In::Text); // One byte over: an error, not a truncation, not a growing buffer. The board's // RAM is not somebody else's allocation request. kr::ws::Reader r2{buf, sizeof(buf)}; n = maskedTextFrame(frame, msg, kr::kClaimMsgCap + 1); in = kr::ws::In::NeedMore; for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r2.feed(frame[i]); CHECK(in == kr::ws::In::Error); CHECK(r2.error() == kr::ws::Err::TooLong); } CASE("X is the takeover command, and the frames the whitelist names"); { // Task 02 put `X` on the wire: it parses to Takeover (trailing bytes are Bad -- // test_proto.cpp walks that in full), and it is the only frame that changed side // of the whitelist since task 01. The rest still parse as the whitelist comment // says they do, because that comment and this list are the same argument. Message m; CHECK(parse("X", 1, m) == Cmd::Takeover); CHECK(parse("XX", 2, m) == Cmd::Bad); // And the frames named by the whitelist comment really are the commands named: CHECK(parse("K", 1, m) == Cmd::KeepAlive); CHECK(parse("Tchangeme", 9, m) == Cmd::Auth); CHECK(parse("A1", 2, m) == Cmd::Arm); CHECK(parse("A0", 2, m) == Cmd::Disarm); CHECK(parse("R", 1, m) == Cmd::ReleaseAll); CHECK(parse("C", 1, m) == Cmd::SecureAttention); CHECK(parse("H", 1, m) == Cmd::Hello); CHECK(parse("S=00:", 5, m) == Cmd::State); CHECK(parse("Y=02:04", 7, m) == Cmd::Chord); CHECK(parse("P=0:6162", 8, m) == Cmd::Text); CHECK(parse("M=00:0:0:0:0", 12, m) == Cmd::Mouse); } return report("test_claim"); }