|
7 | 7 | "math/rand" |
8 | 8 | "net/netip" |
9 | 9 | "sync" |
| 10 | + "sync/atomic" |
10 | 11 | "testing" |
11 | 12 | "time" |
12 | 13 |
|
@@ -242,6 +243,99 @@ func TestService_AuthDrop(t *testing.T) { |
242 | 243 | }) |
243 | 244 | } |
244 | 245 |
|
| 246 | +// TestService_RejectedInviteCacheReplay verifies that a second INVITE |
| 247 | +// reusing the same Call-ID and From-tag after a final 4xx response gets |
| 248 | +// the cached response replayed without invoking the auth/dispatch |
| 249 | +// handlers a second time. This guards the dedup that absorbs |
| 250 | +// provider-level retries (same Call-ID + From-tag, new SIP transaction) |
| 251 | +// after we've already sent a terminal rejection. |
| 252 | +func TestService_RejectedInviteCacheReplay(t *testing.T) { |
| 253 | + const ( |
| 254 | + fromUser = "caller@example.com" |
| 255 | + toUser = "callee@example.com" |
| 256 | + callID = "rejected-invite-replay-test@example.com" |
| 257 | + fromTag = "fixed-from-tag-replay" |
| 258 | + ) |
| 259 | + |
| 260 | + var authCalls, dispatchCalls atomic.Int32 |
| 261 | + |
| 262 | + h := &TestHandler{ |
| 263 | + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { |
| 264 | + authCalls.Add(1) |
| 265 | + return AuthInfo{Result: AuthAccept}, nil |
| 266 | + }, |
| 267 | + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { |
| 268 | + dispatchCalls.Add(1) |
| 269 | + return CallDispatch{Result: DispatchNoRuleReject} |
| 270 | + }, |
| 271 | + OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, callInfo *livekit.SIPCallInfo, reason string) { |
| 272 | + // no-op |
| 273 | + }, |
| 274 | + } |
| 275 | + |
| 276 | + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin |
| 277 | + localIP, err := config.GetLocalIP() |
| 278 | + require.NoError(t, err) |
| 279 | + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) |
| 280 | + |
| 281 | + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) |
| 282 | + require.NoError(t, err) |
| 283 | + |
| 284 | + log := logger.LogRLogger(logr.Discard()) |
| 285 | + s, err := NewService("", &config.Config{ |
| 286 | + SIPPort: sipPort, |
| 287 | + SIPPortListen: sipPort, |
| 288 | + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, |
| 289 | + }, mon, log, func(projectID string) rpc.IOInfoClient { return nil }) |
| 290 | + require.NoError(t, err) |
| 291 | + require.NotNil(t, s) |
| 292 | + s.SetHandler(h) |
| 293 | + require.NoError(t, s.Start()) |
| 294 | + t.Cleanup(s.Stop) |
| 295 | + |
| 296 | + ua, err := sipgo.NewUA(sipgo.WithUserAgent(fromUser), |
| 297 | + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log)))) |
| 298 | + require.NoError(t, err) |
| 299 | + client, err := sipgo.NewClient(ua) |
| 300 | + require.NoError(t, err) |
| 301 | + |
| 302 | + offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) |
| 303 | + require.NoError(t, err) |
| 304 | + offerData, err := offer.SDP.Marshal() |
| 305 | + require.NoError(t, err) |
| 306 | + |
| 307 | + sendInvite := func() *sip.Response { |
| 308 | + recipient := sip.Uri{User: toUser, Host: sipServerAddress} |
| 309 | + req := sip.NewRequest(sip.INVITE, recipient) |
| 310 | + req.SetDestination(sipServerAddress) |
| 311 | + req.SetBody(offerData) |
| 312 | + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) |
| 313 | + req.AppendHeader(sip.NewHeader("Call-ID", callID)) |
| 314 | + req.AppendHeader(&sip.FromHeader{ |
| 315 | + DisplayName: fromUser, |
| 316 | + Address: sip.Uri{User: fromUser, Host: sipServerAddress}, |
| 317 | + Params: sip.HeaderParams{{K: "tag", V: fromTag}}, |
| 318 | + }) |
| 319 | + tx, err := client.TransactionRequest(req) |
| 320 | + require.NoError(t, err) |
| 321 | + t.Cleanup(tx.Terminate) |
| 322 | + return getFinalResponseOrFail(t, tx, req) |
| 323 | + } |
| 324 | + |
| 325 | + // First INVITE: full handler invocation, 404 from DispatchNoRuleReject. |
| 326 | + res1 := sendInvite() |
| 327 | + require.Equal(t, sip.StatusCode(404), res1.StatusCode) |
| 328 | + require.Equal(t, int32(1), authCalls.Load()) |
| 329 | + require.Equal(t, int32(1), dispatchCalls.Load()) |
| 330 | + |
| 331 | + // Second INVITE with the same Call-ID + From-tag should be served from |
| 332 | + // the cache: same 404, but handlers must NOT be invoked again. |
| 333 | + res2 := sendInvite() |
| 334 | + require.Equal(t, sip.StatusCode(404), res2.StatusCode) |
| 335 | + require.Equal(t, int32(1), authCalls.Load(), "auth handler must not be re-invoked on replay") |
| 336 | + require.Equal(t, int32(1), dispatchCalls.Load(), "dispatch handler must not be re-invoked on replay") |
| 337 | +} |
| 338 | + |
245 | 339 | func TestService_OnSessionEnd(t *testing.T) { |
246 | 340 | const ( |
247 | 341 | expectedCallID = "test-call-id" |
|
0 commit comments