quple/server/app_test.go
Philip Peterson 96f7134c51 Add contacts and per-call Jitsi servers
Everyone signs in at the one central Quine identity server and is called
by username. The caller picks which Jitsi Meet server each call happens
on (meet.jit.si by default, or any other deployment), and the invite
sends the callee to the same one. Jitsi servers need no credentials from
Quple.

- server: POST /call takes an optional jitsiUrl (http(s) only, no
  credentials/query/fragment; default DEFAULT_JITSI) and returns it with
  the meeting ID; invites carry it. Adds GET /me and per-person contacts
  with presence (persisted in DATA_DIR), replacing GET /users.
- desktop: "On server" field for calls, remembered as the last-used
  server; incoming calls show which server they're on; open_meeting
  refuses non-http(s) URLs. meet.jit.si sign-in is now an inline prompt
  when calling there rather than a gate in front of the whole app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:37:31 -08:00

151 lines
4.7 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
// fakeVerifier treats the bearer token as the caller's subject.
type fakeVerifier struct{}
func (fakeVerifier) Verify(_ context.Context, token string) (string, error) {
if token == "" || token == "bad" {
return "", errors.New("invalid")
}
return token, nil
}
func newTestApp(t *testing.T) *httptest.Server {
t.Helper()
contacts, err := LoadContacts(filepath.Join(t.TempDir(), "contacts.json"))
if err != nil {
t.Fatal(err)
}
app := &App{
verifier: fakeVerifier{},
store: NewStore(),
contacts: contacts,
defaultJitsiURL: "https://meet.jit.si",
}
mux := http.NewServeMux()
app.Routes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// do sends a request as `as` (the fake token) and decodes the JSON reply.
func do(t *testing.T, srv *httptest.Server, as, method, path, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req, _ := http.NewRequest(method, srv.URL+path, r)
if as != "" {
req.Header.Set("Authorization", "Bearer "+as)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func TestAuth(t *testing.T) {
srv := newTestApp(t)
if status, _ := do(t, srv, "", "GET", "/me", ""); status != 401 {
t.Errorf("no token: %d", status)
}
if status, _ := do(t, srv, "bad", "GET", "/me", ""); status != 401 {
t.Errorf("bad token: %d", status)
}
status, me := do(t, srv, "alice", "GET", "/me", "")
if status != 200 || me["username"] != "alice" || me["defaultJitsiUrl"] != "https://meet.jit.si" {
t.Errorf("/me: %d %v", status, me)
}
}
func TestCallOnDefaultServer(t *testing.T) {
srv := newTestApp(t)
status, call := do(t, srv, "alice", "POST", "/call", `{"to":"Bob"}`)
if status != 200 || call["jitsiUrl"] != "https://meet.jit.si" || call["meetingId"] == "" {
t.Fatalf("call: %d %v", status, call)
}
_, poll := do(t, srv, "bob", "GET", "/poll", "")
inv := poll["invites"].([]any)[0].(map[string]any)
if inv["from"] != "alice" || inv["jitsiUrl"] != "https://meet.jit.si" || inv["meetingId"] != call["meetingId"] {
t.Fatalf("invite: %v", inv)
}
}
func TestCallOnAnotherServer(t *testing.T) {
srv := newTestApp(t)
// The caller picks any Jitsi; the callee is sent to the same one.
status, call := do(t, srv, "alice", "POST", "/call", `{"to":"bob","jitsiUrl":"Jitsi.Example.org/"}`)
if status != 200 || call["jitsiUrl"] != "https://jitsi.example.org" {
t.Fatalf("call: %d %v", status, call)
}
_, poll := do(t, srv, "bob", "GET", "/poll", "")
inv := poll["invites"].([]any)[0].(map[string]any)
if inv["jitsiUrl"] != "https://jitsi.example.org" || inv["meetingId"] != call["meetingId"] {
t.Fatalf("invite: %v", inv)
}
}
func TestCallValidation(t *testing.T) {
srv := newTestApp(t)
for _, tc := range []struct{ body, why string }{
{`{"to":"alice"}`, "self"},
{`{"to":"not a name"}`, "malformed name"},
{`{"to":"bob@example.com"}`, "not a username"},
{`{"to":"bob","jitsiUrl":"javascript:alert(1)"}`, "non-http server"},
{`{"to":"bob","jitsiUrl":"file:///etc/passwd"}`, "file server"},
{`{"to":"bob","jitsiUrl":"https://x.example/?a=b"}`, "query in server"},
{`{"to":"bob","jitsiUrl":"https://user@x.example"}`, "credentials in server"},
} {
if status, _ := do(t, srv, "alice", "POST", "/call", tc.body); status != 400 {
t.Errorf("%s: status %d", tc.why, status)
}
}
}
func TestContacts(t *testing.T) {
srv := newTestApp(t)
if status, _ := do(t, srv, "alice", "POST", "/contacts", `{"username":"Joe"}`); status != 200 {
t.Fatalf("add: %d", status)
}
do(t, srv, "alice", "POST", "/contacts", `{"username":"bob"}`)
if status, _ := do(t, srv, "alice", "POST", "/contacts", `{"username":"alice"}`); status != 400 {
t.Errorf("adding yourself: %d", status)
}
do(t, srv, "joe", "GET", "/poll", "") // joe comes online
_, list := do(t, srv, "alice", "GET", "/contacts", "")
got, _ := json.Marshal(list["contacts"])
want := `[{"online":false,"username":"bob"},{"online":true,"username":"joe"}]`
if string(got) != want {
t.Fatalf("contacts = %s, want %s", got, want)
}
do(t, srv, "alice", "DELETE", "/contacts/bob", "")
_, list = do(t, srv, "alice", "GET", "/contacts", "")
if n := len(list["contacts"].([]any)); n != 1 {
t.Fatalf("after delete: %d contacts", n)
}
// Contact lists are per person.
_, other := do(t, srv, "bob", "GET", "/contacts", "")
if n := len(other["contacts"].([]any)); n != 0 {
t.Fatalf("bob sees %d of alice's contacts", n)
}
}