| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "net/url" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "amadan.net/oficina/calls/callscore" |
| 13 | ) |
| 14 | |
| 15 | type nativeRoundTripper func(*http.Request) (*http.Response, error) |
| 16 | |
| 17 | func (f nativeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| 18 | |
| 19 | // The real shared Go client consumes a session produced by the existing Home |
| 20 | // assertion and explicit linking ceremony. No test-only auth route is mounted. |
| 21 | func TestNativeCoreUsesHomeSessionAndResumesCall(t *testing.T) { |
| 22 | f := newHomeRouterFixture(t) |
| 23 | f.link(t) |
| 24 | started := f.do("POST", "/meetings/start", "") |
| 25 | match := regexp.MustCompile(`href="/m/([A-Za-z0-9]+)/join"`).FindStringSubmatch(started.Body.String()) |
| 26 | if len(match) != 2 { |
| 27 | t.Fatal("missing new meeting") |
| 28 | } |
| 29 | room := match[1] |
| 30 | f.app.ttl.Store(int64(100 * time.Millisecond)) |
| 31 | server := httptest.NewServer(f.handler) |
| 32 | defer server.Close() |
| 33 | target, _ := url.Parse(server.URL) |
| 34 | cookies := []*http.Cookie{} |
| 35 | for _, c := range f.cookies { |
| 36 | cookies = append(cookies, c) |
| 37 | } |
| 38 | client := &http.Client{Transport: nativeRoundTripper(func(req *http.Request) (*http.Response, error) { |
| 39 | copy := req.Clone(req.Context()) |
| 40 | u := *req.URL |
| 41 | copy.URL = &u |
| 42 | copy.Host = req.URL.Host |
| 43 | copy.URL.Host = target.Host |
| 44 | copy.URL.Scheme = target.Scheme |
| 45 | for _, c := range cookies { |
| 46 | copy.AddCookie(c) |
| 47 | } |
| 48 | return http.DefaultTransport.RoundTrip(copy) |
| 49 | })} |
| 50 | events := make(chan callscore.Event, 128) |
| 51 | native, err := callscore.New(homeTestOrigin, room, client, func(e callscore.Event) { events <- e }) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if err := native.Start(); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | defer native.Close() |
| 59 | deadline := time.NewTimer(5 * time.Second) |
| 60 | defer deadline.Stop() |
| 61 | peer := "" |
| 62 | rosters := 0 |
| 63 | gotICE := false |
| 64 | for rosters < 2 || !gotICE { |
| 65 | select { |
| 66 | case e := <-events: |
| 67 | if e.Type == "ice" { |
| 68 | gotICE = true |
| 69 | } |
| 70 | if e.Message != nil && e.Message.Type == "roster" { |
| 71 | rosters++ |
| 72 | s := native.Snapshot() |
| 73 | if peer != "" && peer != string(s.Peer) { |
| 74 | t.Fatal("resume replaced participation") |
| 75 | } |
| 76 | peer = string(s.Peer) |
| 77 | } |
| 78 | case <-deadline.C: |
| 79 | t.Fatal("Home native client failed to join and resume") |
| 80 | } |
| 81 | } |
| 82 | if w := f.do("POST", "/meetings/"+room+"/end", ""); w.Code != 303 { |
| 83 | t.Fatalf("end: %d", w.Code) |
| 84 | } |
| 85 | for { |
| 86 | select { |
| 87 | case e := <-events: |
| 88 | if e.State != nil && e.State.Phase == "ended" { |
| 89 | if e.State.Reason != "ended" || len(e.State.Peers) != 0 { |
| 90 | t.Fatalf("bad terminal state: %+v", e.State) |
| 91 | } |
| 92 | return |
| 93 | } |
| 94 | case <-deadline.C: |
| 95 | t.Fatal("native client did not observe ended meeting") |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // Paul's "This meeting is open elsewhere" (Calls 0.3.1): an app that quit |
| 101 | // or crashed mid-call leaves its participation inside the thirty-second |
| 102 | // resume window, and the next run of the app, on the same kept session, |
| 103 | // was refused its fresh stream with 409 — which the core read as |
| 104 | // "replaced" before the call had begun. The core now takes its own place |
| 105 | // back through the web's takeover, on the real Home-mode router with its |
| 106 | // CSRF middleware, and joins. |
| 107 | func TestNativeCoreTakesBackItsOwnGhostParticipation(t *testing.T) { |
| 108 | f := newHomeRouterFixture(t) |
| 109 | f.link(t) |
| 110 | started := f.do("POST", "/meetings/start", "") |
| 111 | match := regexp.MustCompile(`href="/m/([A-Za-z0-9]+)/join"`).FindStringSubmatch(started.Body.String()) |
| 112 | if len(match) != 2 { |
| 113 | t.Fatal("missing new meeting") |
| 114 | } |
| 115 | room := match[1] |
| 116 | server := httptest.NewServer(f.handler) |
| 117 | defer server.Close() |
| 118 | target, _ := url.Parse(server.URL) |
| 119 | cookies := []*http.Cookie{} |
| 120 | for _, c := range f.cookies { |
| 121 | cookies = append(cookies, c) |
| 122 | } |
| 123 | client := &http.Client{Transport: nativeRoundTripper(func(req *http.Request) (*http.Response, error) { |
| 124 | copy := req.Clone(req.Context()) |
| 125 | u := *req.URL |
| 126 | copy.URL = &u |
| 127 | copy.Host = req.URL.Host |
| 128 | copy.URL.Host = target.Host |
| 129 | copy.URL.Scheme = target.Scheme |
| 130 | for _, c := range cookies { |
| 131 | copy.AddCookie(c) |
| 132 | } |
| 133 | return http.DefaultTransport.RoundTrip(copy) |
| 134 | })} |
| 135 | admitted := func(native *callscore.Room, events chan callscore.Event) string { |
| 136 | t.Helper() |
| 137 | deadline := time.NewTimer(5 * time.Second) |
| 138 | defer deadline.Stop() |
| 139 | for { |
| 140 | select { |
| 141 | case e := <-events: |
| 142 | if e.State == nil { |
| 143 | continue |
| 144 | } |
| 145 | if e.State.Phase == "ended" { |
| 146 | t.Fatalf("call ended before it began: %q", e.State.Reason) |
| 147 | } |
| 148 | if e.State.Phase == "admitted" && e.State.Peer != "" { |
| 149 | return string(e.State.Peer) |
| 150 | } |
| 151 | case <-deadline.C: |
| 152 | t.Fatal("native client was never admitted") |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | firstEvents := make(chan callscore.Event, 128) |
| 157 | first, err := callscore.New(homeTestOrigin, room, client, func(e callscore.Event) { |
| 158 | select { |
| 159 | case firstEvents <- e: |
| 160 | default: |
| 161 | } |
| 162 | }) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if err := first.Start(); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | ghost := admitted(first, firstEvents) |
| 170 | // The app goes away without leaving: no leave signal, the stream |
| 171 | // simply drops, as it does when the process ends. |
| 172 | first.Close() |
| 173 | |
| 174 | events := make(chan callscore.Event, 128) |
| 175 | second, err := callscore.New(homeTestOrigin, room, client, func(e callscore.Event) { events <- e }) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | if err := second.Start(); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | defer second.Close() |
| 183 | peer := admitted(second, events) |
| 184 | if peer == ghost { |
| 185 | t.Fatal("the new run should have a participation of its own") |
| 186 | } |
| 187 | for _, p := range second.Snapshot().Peers { |
| 188 | if string(p.Peer) == ghost { |
| 189 | t.Fatal("the ghost participation is still in the room") |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func TestNativeCoreCannotUseAgentBearerAsHumanSession(t *testing.T) { |
| 195 | f := newHomeRouterFixture(t) |
| 196 | f.link(t) |
| 197 | started := f.do("POST", "/meetings/start", "") |
| 198 | match := regexp.MustCompile(`href="(/m/[A-Za-z0-9]+)/join"`).FindStringSubmatch(started.Body.String()) |
| 199 | if len(match) != 2 { |
| 200 | t.Fatal("missing meeting") |
| 201 | } |
| 202 | created := f.do("POST", "/settings/agents", "name=native-test") |
| 203 | token := regexp.MustCompile(`value="(mt_[A-Za-z0-9_-]+)"`).FindStringSubmatch(created.Body.String()) |
| 204 | if len(token) != 2 { |
| 205 | t.Fatal("missing agent") |
| 206 | } |
| 207 | r := httptest.NewRequest("GET", homeTestOrigin+match[1]+"/stream", nil) |
| 208 | r.Header.Set("Authorization", "Bearer "+token[1]) |
| 209 | r.Header.Set("Accept", "text/event-stream") |
| 210 | w := httptest.NewRecorder() |
| 211 | f.handler.ServeHTTP(w, r) |
| 212 | if w.Code == 200 || strings.Contains(w.Body.String(), "event: hello") { |
| 213 | t.Fatal("agent entered human call") |
| 214 | } |
| 215 | } |
| 216 | |