package main import ( "bytes" "encoding/json" "net/http" "os" "testing" "lchat/internal/bot" "lchat/internal/e2ee" ) // mintAgentInviteForTest does what the admin console does when "This is an // agent" is ticked: derive id + claim_hash from a fresh secret T client-side, // then store them via the admin API. Returns T (the link #fragment). func mintAgentInviteForTest(t *testing.T, tsURL string) string { return mintInviteForTest(t, tsURL, "TestBot", true) } // mintInviteForTest mints a labelled invite (agent or plain member). The label // matters to the runtime tests: it becomes the roster label, which is what the // bots-never-talk-to-bots rule matches payload names against. func mintInviteForTest(t *testing.T, tsURL, label string, agent bool) string { t.Helper() secret, err := e2ee.RandomSecret() if err != nil { t.Fatal(err) } inv, err := e2ee.DeriveInvite(secret) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{ "label": label, "id": inv.ID, "claim_hash": inv.ClaimHash, "agent": agent, }) req, _ := http.NewRequest("POST", tsURL+"/api/admin/key-invites", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer admintok") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out struct { OK bool `json:"ok"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || !out.OK { t.Fatalf("mint invite: status %d ok=%v err=%v", resp.StatusCode, out.OK, err) } return secret } func TestBotProvisionAgainstRealServer(t *testing.T) { ts, s := newTestServer(t) secret := mintAgentInviteForTest(t, ts.URL) sess, err := bot.Provision(ts.URL, secret, "TestBot") if err != nil { t.Fatalf("Provision: %v", err) } if sess.Token == "" || sess.UserPub == "" || sess.UserPriv == "" { t.Fatal("session is missing fields") } // The minted device token must authenticate like any member's. if !s.tokenOK(sess.Token) { t.Error("provisioned token doesn't authenticate") } uid, ok := s.db.deviceTokenUser(sess.Token) if !ok { t.Fatal("token maps to no user") } if !s.db.isAgent(uid) { t.Error("provisioned user is not marked as an agent") } // The registered public key must be the session's — and usable: wrap a // thread key to it (as an inviter's browser would) and unwrap with the // session's private key. registered := s.db.userPublicKey(uid) if registered != sess.UserPub { t.Fatalf("roster pubkey %q != session pubkey %q", registered, sess.UserPub) } threadKey, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(threadKey), registered) if err != nil { t.Fatal(err) } back, err := e2ee.UnwrapThreadKey(wrapped, sess.UserPriv) if err != nil { t.Fatalf("unwrap with session key: %v", err) } if back != e2ee.B64uEncode(threadKey) { t.Error("thread key wrapped to the roster pubkey doesn't unwrap with the session key") } // Single-use: a second claim of the same invite must fail. if _, err := bot.Provision(ts.URL, secret, "TestBot2"); err == nil { t.Error("re-provisioning a used invite succeeded") } } func TestBotSessionFileNodeCompat(t *testing.T) { // The Go bot must read the Node sidecar's session.json as-is (same identity, // no re-provisioning) and write files Node can read back. path := t.TempDir() + "/session.json" nodeJSON := `{ "token": "tok123", "name": "Eleven", "userPub": "BPub", "userPriv": "Priv" }` if err := os.WriteFile(path, []byte(nodeJSON), 0o600); err != nil { t.Fatal(err) } s, err := bot.LoadSession(path) if err != nil { t.Fatal(err) } if s == nil || s.Token != "tok123" || s.Name != "Eleven" || s.UserPub != "BPub" || s.UserPriv != "Priv" { t.Fatalf("bad session: %+v", s) } if err := s.Save(path); err != nil { t.Fatal(err) } var round map[string]string raw, _ := os.ReadFile(path) if err := json.Unmarshal(raw, &round); err != nil { t.Fatal(err) } for _, k := range []string{"token", "name", "userPub", "userPriv"} { if round[k] == "" { t.Errorf("saved session is missing Node-compat key %q", k) } } missing, err := bot.LoadSession(t.TempDir() + "/nope.json") if err != nil || missing != nil { t.Errorf("missing session file should be (nil, nil), got (%v, %v)", missing, err) } }