mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
The lazy store embedded appservice.StateStore, but the client's store is also type-asserted to crypto.StateStore -- a wider interface -- and NewCryptoHelper refuses to start when that assertion fails. Embedding the narrower interface dropped GetEncryptionEvent and FindSharedRooms from the concrete type, so the bot died on startup with "the client state store must implement crypto.StateStore". Embed both, assert both at compile time, and resolve GetEncryptionEvent lazily like IsEncrypted since the crypto machine reads megolm rotation settings through it.
196 lines
6.0 KiB
Go
196 lines
6.0 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/crypto/cryptohelper"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// fakeHS serves the two endpoints the lazy store resolves against, counting hits
|
|
// so we can assert it caches instead of re-asking on every send.
|
|
type fakeHS struct {
|
|
encryptionStatus int
|
|
encryptionBody string
|
|
membersBody string
|
|
|
|
encryptionHits atomic.Int32
|
|
memberHits atomic.Int32
|
|
}
|
|
|
|
func (f *fakeHS) start(t *testing.T) *mautrix.Client {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/state/m.room.encryption"):
|
|
f.encryptionHits.Add(1)
|
|
w.WriteHeader(f.encryptionStatus)
|
|
_, _ = w.Write([]byte(f.encryptionBody))
|
|
case strings.HasSuffix(r.URL.Path, "/members"):
|
|
f.memberHits.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(f.membersBody))
|
|
default:
|
|
t.Errorf("unexpected request to %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
client, err := mautrix.NewClient(srv.URL, "@bot:example.org", "token")
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore {
|
|
t.Helper()
|
|
store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore))
|
|
store.client = hs.start(t)
|
|
return store
|
|
}
|
|
|
|
// The store is handed to the crypto helper, which type-asserts it and refuses to
|
|
// start if it comes up short. That assertion is the whole bot's startup path, so
|
|
// pin it here rather than discovering it as a crash loop in prod.
|
|
func TestStoreSatisfiesCryptoHelper(t *testing.T) {
|
|
store := newTestStore(t, &fakeHS{})
|
|
|
|
client := store.client
|
|
client.StateStore = store
|
|
|
|
dir := t.TempDir()
|
|
if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil {
|
|
t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err)
|
|
}
|
|
}
|
|
|
|
const roomID = id.RoomID("!room:example.org")
|
|
|
|
// The regression: a room configured as encrypted before the process started. No
|
|
// transaction ever re-announces it, so the store must go ask.
|
|
func TestIsEncryptedResolvesFromServer(t *testing.T) {
|
|
hs := &fakeHS{
|
|
encryptionStatus: http.StatusOK,
|
|
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
|
}
|
|
store := newTestStore(t, hs)
|
|
|
|
enc, err := store.IsEncrypted(context.Background(), roomID)
|
|
if err != nil {
|
|
t.Fatalf("IsEncrypted: %v", err)
|
|
}
|
|
if !enc {
|
|
t.Fatal("room is encrypted on the server but IsEncrypted said false — this is the plaintext leak")
|
|
}
|
|
}
|
|
|
|
// A room with no m.room.encryption really is plaintext, and must not re-fetch.
|
|
func TestIsEncryptedCachesUnencrypted(t *testing.T) {
|
|
hs := &fakeHS{
|
|
encryptionStatus: http.StatusNotFound,
|
|
encryptionBody: `{"errcode":"M_NOT_FOUND","error":"Event not found."}`,
|
|
}
|
|
store := newTestStore(t, hs)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
enc, err := store.IsEncrypted(context.Background(), roomID)
|
|
if err != nil {
|
|
t.Fatalf("IsEncrypted: %v", err)
|
|
}
|
|
if enc {
|
|
t.Fatal("unencrypted room reported as encrypted")
|
|
}
|
|
}
|
|
if got := hs.encryptionHits.Load(); got != 1 {
|
|
t.Errorf("expected the M_NOT_FOUND to be cached, got %d fetches", got)
|
|
}
|
|
}
|
|
|
|
// The important half: an unresolvable room must fail the send, not quietly
|
|
// answer "not encrypted" and let the message out in the clear.
|
|
func TestIsEncryptedFailsClosed(t *testing.T) {
|
|
hs := &fakeHS{
|
|
encryptionStatus: http.StatusInternalServerError,
|
|
encryptionBody: `{"errcode":"M_UNKNOWN","error":"oops"}`,
|
|
}
|
|
store := newTestStore(t, hs)
|
|
|
|
if _, err := store.IsEncrypted(context.Background(), roomID); err == nil {
|
|
t.Fatal("a failed lookup returned no error; the send would proceed in plaintext")
|
|
}
|
|
// The failure must not poison the cache: a later send has to retry.
|
|
hs.encryptionStatus = http.StatusOK
|
|
hs.encryptionBody = `{"algorithm":"m.megolm.v1.aes-sha2"}`
|
|
enc, err := store.IsEncrypted(context.Background(), roomID)
|
|
if err != nil {
|
|
t.Fatalf("IsEncrypted after recovery: %v", err)
|
|
}
|
|
if !enc {
|
|
t.Fatal("store did not retry after a transient failure")
|
|
}
|
|
}
|
|
|
|
// A burst of proactive sends into a cold room should resolve it once.
|
|
func TestIsEncryptedResolvesOncePerRoom(t *testing.T) {
|
|
hs := &fakeHS{
|
|
encryptionStatus: http.StatusOK,
|
|
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
|
}
|
|
store := newTestStore(t, hs)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, err := store.IsEncrypted(context.Background(), roomID); err != nil {
|
|
t.Errorf("IsEncrypted: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := hs.encryptionHits.Load(); got != 1 {
|
|
t.Errorf("expected 1 state fetch for 20 concurrent sends, got %d", got)
|
|
}
|
|
}
|
|
|
|
// Encrypting to an empty member list would share the group session with nobody,
|
|
// so members must be resolved too.
|
|
func TestMembersResolveFromServer(t *testing.T) {
|
|
hs := &fakeHS{
|
|
encryptionStatus: http.StatusOK,
|
|
encryptionBody: `{"algorithm":"m.megolm.v1.aes-sha2"}`,
|
|
membersBody: `{"chunk":[
|
|
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@alice:example.org","sender":"@alice:example.org","content":{"membership":"join"}},
|
|
{"type":"m.room.member","room_id":"!room:example.org","state_key":"@bot:example.org","sender":"@bot:example.org","content":{"membership":"join"}}
|
|
]}`,
|
|
}
|
|
store := newTestStore(t, hs)
|
|
|
|
members, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID)
|
|
if err != nil {
|
|
t.Fatalf("GetRoomJoinedOrInvitedMembers: %v", err)
|
|
}
|
|
if len(members) != 2 {
|
|
t.Fatalf("expected 2 members, got %d (%v) — the group session would be shared to nobody", len(members), members)
|
|
}
|
|
|
|
if _, err := store.GetRoomJoinedOrInvitedMembers(context.Background(), roomID); err != nil {
|
|
t.Fatalf("second call: %v", err)
|
|
}
|
|
if got := hs.memberHits.Load(); got != 1 {
|
|
t.Errorf("expected members to be cached after one fetch, got %d", got)
|
|
}
|
|
}
|