Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file test_service_frames.c
6 : * @brief TEST-88 / US-37 — functional coverage for server-side service frames.
7 : *
8 : * src/infrastructure/api_call.c::classify_service_frame handles five
9 : * non-result CRCs — bad_server_salt, bad_msg_notification,
10 : * new_session_created, msgs_ack, pong — plus the SERVICE_FRAME_LIMIT (8)
11 : * drain loop. Until now no functional test injected them; salt rotation
12 : * on long-running `watch` / TUI sessions was the most likely untested
13 : * failure mode in the wild.
14 : *
15 : * Coverage here exercises the full api_call() pipeline end-to-end:
16 : * - bad_server_salt → automatic retry with refreshed salt succeeds.
17 : * - new_session_created → transparent salt refresh, real result surfaces.
18 : * - msgs_ack → transparent skip.
19 : * - pong → transparent skip.
20 : * - bad_msg_notification → surfaces as -1 without dropping auth_key.
21 : * - service-frame storm → 9 queued frames exceed SERVICE_FRAME_LIMIT and
22 : * api_call returns -1 cleanly.
23 : *
24 : * Uses the six `mt_server_reply_*` / `mt_server_stack_service_frames`
25 : * helpers added to tests/mocks/mock_tel_server.{h,c} alongside this suite.
26 : */
27 :
28 : #include "test_helpers.h"
29 :
30 : #include "mock_socket.h"
31 : #include "mock_tel_server.h"
32 :
33 : #include "api_call.h"
34 : #include "mtproto_rpc.h"
35 : #include "mtproto_session.h"
36 : #include "transport.h"
37 : #include "app/session_store.h"
38 : #include "tl_registry.h"
39 : #include "tl_serial.h"
40 :
41 : #include <stdio.h>
42 : #include <stdlib.h>
43 : #include <string.h>
44 : #include <unistd.h>
45 :
46 : /* help.getConfig#c4f9186b — a convenient "any RPC" the mock can reply to. */
47 : #define CRC_help_getConfig 0xc4f9186bU
48 : /* Mirror the production constants so we can assert on the refreshed salt. */
49 : #define SVC_NEW_SESSION_SALT 0xCAFEF00DBAADC0DEULL
50 :
51 : /* ================================================================ */
52 : /* Boilerplate */
53 : /* ================================================================ */
54 :
55 12 : static void with_tmp_home(const char *tag) {
56 : char tmp[256];
57 12 : snprintf(tmp, sizeof(tmp), "/tmp/tg-cli-ft-svcfr-%s", tag);
58 : char bin[512];
59 12 : snprintf(bin, sizeof(bin), "%s/.config/tg-cli/session.bin", tmp);
60 12 : (void)unlink(bin);
61 12 : setenv("HOME", tmp, 1);
62 12 : }
63 :
64 12 : static void connect_mock(Transport *t) {
65 12 : transport_init(t);
66 12 : ASSERT(transport_connect(t, "127.0.0.1", 443) == 0, "connect");
67 : }
68 :
69 12 : static void init_cfg(ApiConfig *cfg) {
70 12 : api_config_init(cfg);
71 12 : cfg->api_id = 12345;
72 12 : cfg->api_hash = "deadbeefcafebabef00dbaadfeedc0de";
73 12 : }
74 :
75 12 : static void load_session(MtProtoSession *s, uint64_t *initial_salt_out) {
76 12 : uint64_t salt = 0;
77 12 : ASSERT(mt_server_seed_session(2, NULL, &salt, NULL) == 0, "seed");
78 12 : mtproto_session_init(s);
79 12 : int dc = 0;
80 12 : ASSERT(session_store_load(s, &dc) == 0, "load session");
81 12 : if (initial_salt_out) *initial_salt_out = salt;
82 : }
83 :
84 : /* Build a minimal help.getConfig query (just the CRC + no arguments). */
85 12 : static void build_get_config(TlWriter *w) {
86 12 : tl_writer_init(w);
87 12 : tl_write_uint32(w, CRC_help_getConfig);
88 12 : }
89 :
90 : /* Responder: return a 4-byte sentinel as the rpc_result body so tests can
91 : * verify the payload survived service-frame drain. 0x5A5AA5A5 is arbitrary
92 : * but distinguishable; api_call unwraps rpc_result and hands the sentinel
93 : * to the caller through the resp buffer. */
94 : #define RESULT_SENTINEL 0x5A5AA5A5U
95 12 : static void on_get_config_sentinel(MtRpcContext *ctx) {
96 : uint8_t body[8];
97 12 : TlWriter pw; tl_writer_init(&pw);
98 12 : tl_write_uint32(&pw, RESULT_SENTINEL);
99 12 : tl_write_uint32(&pw, 0); /* keep 4-byte alignment */
100 12 : memcpy(body, pw.data, pw.len);
101 12 : size_t body_len = pw.len;
102 12 : tl_writer_free(&pw);
103 12 : mt_server_reply_result(ctx, body, body_len);
104 12 : }
105 :
106 : /* Check that the decoded api_call response carries our sentinel. */
107 8 : static void assert_sentinel(const uint8_t *resp, size_t resp_len) {
108 8 : ASSERT(resp_len >= 4, "response contains at least one uint32");
109 : uint32_t first;
110 8 : memcpy(&first, resp, 4);
111 8 : ASSERT(first == RESULT_SENTINEL,
112 : "response payload is the handler sentinel — real reply surfaced");
113 : }
114 :
115 : /* ================================================================ */
116 : /* Tests */
117 : /* ================================================================ */
118 :
119 : /* 1. bad_server_salt → automatic retry succeeds, salt updated. */
120 2 : static void test_bad_server_salt_auto_retries_and_succeeds(void) {
121 2 : with_tmp_home("bad-salt");
122 2 : mt_server_init(); mt_server_reset();
123 2 : mt_server_expect(CRC_help_getConfig, on_get_config_sentinel, NULL);
124 :
125 2 : ApiConfig cfg; init_cfg(&cfg);
126 2 : Transport t; connect_mock(&t);
127 2 : MtProtoSession s; uint64_t original_salt = 0;
128 2 : load_session(&s, &original_salt);
129 :
130 : /* Arm: the first RPC the client sends will bounce off a bad_server_salt
131 : * reply carrying THIS new salt; the client retries and the handler
132 : * serves the sentinel. */
133 2 : const uint64_t NEW_SALT = 0x1122334455667788ULL;
134 2 : mt_server_reply_bad_server_salt(NEW_SALT);
135 :
136 2 : TlWriter q; build_get_config(&q);
137 : uint8_t resp[256];
138 2 : size_t resp_len = 0;
139 2 : int rc = api_call(&cfg, &s, &t, q.data, q.len, resp, sizeof(resp), &resp_len);
140 2 : tl_writer_free(&q);
141 :
142 2 : ASSERT(rc == 0, "api_call returns 0 after salt-rotation retry");
143 2 : assert_sentinel(resp, resp_len);
144 2 : ASSERT(s.server_salt == NEW_SALT,
145 : "s.server_salt replaced with server-issued new_salt");
146 2 : ASSERT(s.server_salt != original_salt,
147 : "salt actually changed (sanity vs initial)");
148 2 : ASSERT(mt_server_rpc_call_count() == 1,
149 : "exactly one handler dispatch — bad_salt preempted the first frame "
150 : "so only the retry hit the handler");
151 2 : ASSERT(s.has_auth_key == 1,
152 : "auth_key retained across salt rotation");
153 :
154 2 : transport_close(&t);
155 2 : mt_server_reset();
156 : }
157 :
158 : /* 2. new_session_created → transparent; salt refreshed; real result surfaces. */
159 2 : static void test_new_session_created_refreshes_salt(void) {
160 2 : with_tmp_home("new-session");
161 2 : mt_server_init(); mt_server_reset();
162 2 : mt_server_expect(CRC_help_getConfig, on_get_config_sentinel, NULL);
163 :
164 2 : ApiConfig cfg; init_cfg(&cfg);
165 2 : Transport t; connect_mock(&t);
166 2 : MtProtoSession s; uint64_t original_salt = 0;
167 2 : load_session(&s, &original_salt);
168 :
169 2 : mt_server_reply_new_session_created();
170 :
171 2 : TlWriter q; build_get_config(&q);
172 : uint8_t resp[256];
173 2 : size_t resp_len = 0;
174 2 : int rc = api_call(&cfg, &s, &t, q.data, q.len, resp, sizeof(resp), &resp_len);
175 2 : tl_writer_free(&q);
176 :
177 2 : ASSERT(rc == 0, "api_call surfaces real result past new_session_created");
178 2 : assert_sentinel(resp, resp_len);
179 2 : ASSERT(s.server_salt == SVC_NEW_SESSION_SALT,
180 : "s.server_salt picked up from new_session_created frame");
181 2 : ASSERT(s.server_salt != original_salt,
182 : "salt moved from seeded value");
183 2 : ASSERT(mt_server_rpc_call_count() == 1,
184 : "single RPC round-trip despite preceding service frame");
185 :
186 2 : transport_close(&t);
187 2 : mt_server_reset();
188 : }
189 :
190 : /* 3. msgs_ack → transparent; caller sees only the real result. */
191 2 : static void test_msgs_ack_is_transparent(void) {
192 2 : with_tmp_home("msgs-ack");
193 2 : mt_server_init(); mt_server_reset();
194 2 : mt_server_expect(CRC_help_getConfig, on_get_config_sentinel, NULL);
195 :
196 2 : ApiConfig cfg; init_cfg(&cfg);
197 2 : Transport t; connect_mock(&t);
198 2 : MtProtoSession s; uint64_t original_salt = 0;
199 2 : load_session(&s, &original_salt);
200 :
201 : /* Three distinct msg_ids — simulates the server acking a small batch
202 : * of previously-sent client frames. */
203 2 : uint64_t ids[] = {0xA1A1A1A100000001ULL,
204 : 0xA1A1A1A100000002ULL,
205 : 0xA1A1A1A100000003ULL};
206 2 : mt_server_reply_msgs_ack(ids, 3);
207 :
208 2 : TlWriter q; build_get_config(&q);
209 : uint8_t resp[256];
210 2 : size_t resp_len = 0;
211 2 : int rc = api_call(&cfg, &s, &t, q.data, q.len, resp, sizeof(resp), &resp_len);
212 2 : tl_writer_free(&q);
213 :
214 2 : ASSERT(rc == 0, "api_call succeeds through transparent msgs_ack");
215 2 : assert_sentinel(resp, resp_len);
216 2 : ASSERT(s.server_salt == original_salt,
217 : "msgs_ack does not touch the session salt");
218 :
219 2 : transport_close(&t);
220 2 : mt_server_reset();
221 : }
222 :
223 : /* 4. pong → transparent; caller sees only the real result. */
224 2 : static void test_pong_is_transparent(void) {
225 2 : with_tmp_home("pong");
226 2 : mt_server_init(); mt_server_reset();
227 2 : mt_server_expect(CRC_help_getConfig, on_get_config_sentinel, NULL);
228 :
229 2 : ApiConfig cfg; init_cfg(&cfg);
230 2 : Transport t; connect_mock(&t);
231 2 : MtProtoSession s; uint64_t original_salt = 0;
232 2 : load_session(&s, &original_salt);
233 :
234 2 : mt_server_reply_pong(0xDEADBEEF01020304ULL,
235 : 0xCAFE00DD12345678ULL);
236 :
237 2 : TlWriter q; build_get_config(&q);
238 : uint8_t resp[256];
239 2 : size_t resp_len = 0;
240 2 : int rc = api_call(&cfg, &s, &t, q.data, q.len, resp, sizeof(resp), &resp_len);
241 2 : tl_writer_free(&q);
242 :
243 2 : ASSERT(rc == 0, "api_call succeeds through transparent pong");
244 2 : assert_sentinel(resp, resp_len);
245 2 : ASSERT(s.server_salt == original_salt, "pong does not touch the session salt");
246 :
247 2 : transport_close(&t);
248 2 : mt_server_reset();
249 : }
250 :
251 : /* 5. bad_msg_notification → specific RPC fails, session retained intact. */
252 2 : static void test_bad_msg_notification_surfaces_error_without_dropping_session(void) {
253 2 : with_tmp_home("bad-msg");
254 2 : mt_server_init(); mt_server_reset();
255 : /* Register a handler so the dispatch path is complete — classify returns
256 : * SVC_ERROR before the queued handler reply is reached, so the handler
257 : * never actually surfaces a payload to the client. But the server still
258 : * computes a reply and queues it (harmless). */
259 2 : mt_server_expect(CRC_help_getConfig, on_get_config_sentinel, NULL);
260 :
261 2 : ApiConfig cfg; init_cfg(&cfg);
262 2 : Transport t; connect_mock(&t);
263 2 : MtProtoSession s; uint64_t original_salt = 0;
264 2 : load_session(&s, &original_salt);
265 :
266 : /* Capture a copy of the auth_key so we can verify it is preserved. */
267 : uint8_t auth_key_before[MTPROTO_AUTH_KEY_SIZE];
268 2 : memcpy(auth_key_before, s.auth_key, sizeof(auth_key_before));
269 :
270 : /* Queue the bad_msg_notification ahead of the real reply.
271 : * bad_msg_id = 0xFFEE (synthetic), error_code = 16 (msg_id too low). */
272 2 : mt_server_reply_bad_msg_notification(0xFFEEFFEEFFEEFFEEULL, 16);
273 :
274 2 : TlWriter q; build_get_config(&q);
275 : uint8_t resp[256];
276 2 : size_t resp_len = 0;
277 2 : int rc = api_call(&cfg, &s, &t, q.data, q.len, resp, sizeof(resp), &resp_len);
278 2 : tl_writer_free(&q);
279 :
280 2 : ASSERT(rc == -1,
281 : "api_call surfaces bad_msg_notification as -1");
282 : /* Session retained: auth_key bytes unchanged, salt unchanged. */
283 2 : ASSERT(memcmp(s.auth_key, auth_key_before, sizeof(auth_key_before)) == 0,
284 : "auth_key preserved across bad_msg_notification");
285 2 : ASSERT(s.server_salt == original_salt,
286 : "server_salt preserved across bad_msg_notification");
287 2 : ASSERT(s.has_auth_key == 1,
288 : "has_auth_key flag retained — session not discarded");
289 :
290 2 : transport_close(&t);
291 2 : mt_server_reset();
292 : }
293 :
294 : /* 6. Service-frame storm — 9 queued acks exceed SERVICE_FRAME_LIMIT (8).
295 : * api_call returns -1 cleanly instead of looping forever. */
296 2 : static void test_service_frame_storm_bails_at_limit(void) {
297 2 : with_tmp_home("storm");
298 2 : mt_server_init(); mt_server_reset();
299 2 : mt_server_expect(CRC_help_getConfig, on_get_config_sentinel, NULL);
300 :
301 2 : ApiConfig cfg; init_cfg(&cfg);
302 2 : Transport t; connect_mock(&t);
303 2 : MtProtoSession s; uint64_t original_salt = 0;
304 2 : load_session(&s, &original_salt);
305 :
306 : /* Stack exactly 9 msgs_ack frames — one more than the client's
307 : * 8-frame drain limit. The 9th classify iteration hits the loop
308 : * cap and api_call_once falls out of the for-loop with whatever
309 : * happened to be in `raw_resp` (still a msgs_ack frame) — rpc_unwrap_gzip
310 : * refuses to decode that as a real payload, so api_call returns -1. */
311 2 : mt_server_stack_service_frames(9);
312 :
313 2 : TlWriter q; build_get_config(&q);
314 : uint8_t resp[256];
315 2 : size_t resp_len = 0;
316 2 : int rc = api_call(&cfg, &s, &t, q.data, q.len, resp, sizeof(resp), &resp_len);
317 2 : tl_writer_free(&q);
318 :
319 2 : ASSERT(rc == -1,
320 : "api_call returns -1 after SERVICE_FRAME_LIMIT is exceeded");
321 : /* Session untouched even though the call failed. */
322 2 : ASSERT(s.server_salt == original_salt,
323 : "salt unchanged after service-frame storm");
324 2 : ASSERT(s.has_auth_key == 1,
325 : "auth_key retained through storm");
326 :
327 2 : transport_close(&t);
328 2 : mt_server_reset();
329 : }
330 :
331 : /* ================================================================ */
332 : /* Suite entry point */
333 : /* ================================================================ */
334 :
335 2 : void run_service_frames_tests(void) {
336 2 : RUN_TEST(test_bad_server_salt_auto_retries_and_succeeds);
337 2 : RUN_TEST(test_new_session_created_refreshes_salt);
338 2 : RUN_TEST(test_msgs_ack_is_transparent);
339 2 : RUN_TEST(test_pong_is_transparent);
340 2 : RUN_TEST(test_bad_msg_notification_surfaces_error_without_dropping_session);
341 2 : RUN_TEST(test_service_frame_storm_bails_at_limit);
342 2 : }
|