package service import ( "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/joho/godotenv" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "go.uber.org/zap" ) // The service's rules — blocks, Kautilya being read-only, entities being parsed // server-side, a retry not re-broadcasting — are all statements about real rows, // so they're exercised against a real database. Skipped without POSTGRES_URI. func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *repository.Postgres) { t.Helper() uri := os.Getenv("POSTGRES_URI") if uri == "" { _ = godotenv.Load(filepath.Join("..", "..", ".env")) uri = os.Getenv("POSTGRES_URI") } if uri == "" { t.Skip("POSTGRES_URI not set — skipping chat service integration tests") } pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop()) if err != nil { t.Skipf("postgres unreachable: %v", err) } t.Cleanup(pg.Close) chat := repository.NewChatRepo(pg) // Nil realtime/R2/cache clients make publishing, uploads and caching // no-ops — these tests are about the rules, and none of those services are // reachable from a test run. return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg), nil, nil, nil, nil, nil, nil, zap.NewNop()), chat, pg } // chatUserWithHandle returns both identifiers: ids for authoring, handles for // the member-management API. func chatUserWithHandle(t *testing.T, pg *repository.Postgres, label string) (id, handle string) { t.Helper() id = uuid.NewString() suffix := id[:8] handle = "chatsvc_" + suffix if _, err := pg.Pool.Exec(context.Background(), ` INSERT INTO users (id, email, password_hash, name, handle) VALUES ($1, $2, 'x', $3, $4)`, id, "chatsvc-"+suffix+"@example.invalid", "Chat Svc "+label, handle); err != nil { t.Fatalf("create user: %v", err) } t.Cleanup(func() { _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id) }) return id, handle } func chatUser(t *testing.T, pg *repository.Postgres, label string) string { t.Helper() id, _ := chatUserWithHandle(t, pg, label) return id } // handleOf looks up a test user's handle for the member-management calls. func handleOf(t *testing.T, pg *repository.Postgres, userID string) string { t.Helper() var h string if err := pg.Pool.QueryRow(context.Background(), `SELECT handle FROM users WHERE id = $1`, userID).Scan(&h); err != nil { t.Fatalf("handle for %s: %v", userID, err) } return h } func cleanupConv(t *testing.T, pg *repository.Postgres, id string) { t.Cleanup(func() { _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id) }) } // Blocking must close a DM in BOTH directions. A one-way channel — where the // person who blocked can still be written to — would be worse than refusing. func TestOpenDM_BlockedBothDirections(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() blocks := repository.NewBlockRepo(pg) t.Run("blocked by peer", func(t *testing.T) { a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") if err := blocks.Block(ctx, b, a); err != nil { t.Fatal(err) } if _, err := svc.OpenDM(ctx, a, b); !errors.Is(err, ErrChatBlocked) { t.Errorf("err = %v, want ErrChatBlocked", err) } }) t.Run("caller blocked the peer", func(t *testing.T) { a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") if err := blocks.Block(ctx, a, b); err != nil { t.Fatal(err) } if _, err := svc.OpenDM(ctx, a, b); !errors.Is(err, ErrChatBlocked) { t.Errorf("err = %v, want ErrChatBlocked", err) } }) } // A block placed after the thread exists must stop new messages too — checking // only at thread creation would leave an open channel behind. func TestSend_BlockedAfterThreadExists(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "before"}); err != nil { t.Fatalf("send before block: %v", err) } if err := repository.NewBlockRepo(pg).Block(ctx, b, a); err != nil { t.Fatal(err) } if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "after"}); !errors.Is(err, ErrChatBlocked) { t.Errorf("send after block: err = %v, want ErrChatBlocked", err) } } func TestSend_RejectsOutsidersAndEmptyBodies(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if _, err := svc.Send(ctx, outsider, conv.ID, SendRequest{Body: "hi"}); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider send: err = %v, want ErrNotParticipant", err) } if _, err := svc.Messages(ctx, outsider, conv.ID, 0, 10); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider read: err = %v, want ErrNotParticipant", err) } if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: " "}); !errors.Is(err, ErrChatEmptyBody) { t.Errorf("whitespace-only send: err = %v, want ErrChatEmptyBody", err) } } // Kautilya's tab and plumbing exist, but nothing consumes messages yet, so the // thread must refuse rather than silently swallow them. func TestSend_KautilyaIsReadOnly(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a := chatUser(t, pg, "A") conv, err := svc.OpenAIThread(ctx, a) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "who is Kṛṣṇa?"}); !errors.Is(err, ErrChatReadOnly) { t.Errorf("err = %v, want ErrChatReadOnly", err) } } // Entities are derived server-side. A client that sends markup gets it parsed; // it cannot dictate how its text is decorated in someone else's thread. func TestSend_ParsesEntitiesServerSide(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "read *this* at https://prabhupadasays.org"}) if err != nil { t.Fatal(err) } if msg.Body != "read this at https://prabhupadasays.org" { t.Errorf("stored body = %q — markers should be stripped", msg.Body) } var spans []Entity if err := json.Unmarshal(msg.Entities, &spans); err != nil { t.Fatalf("entities: %v (%s)", err, msg.Entities) } kinds := map[string]bool{} for _, e := range spans { kinds[e.Type] = true } if !kinds[EntityBold] || !kinds[EntityURL] { t.Errorf("entities = %+v, want a bold span and a url span", spans) } } // A retried send must return the stored message, not a second bubble. func TestSend_RetryIsIdempotent(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) id := uuid.NewString() req := SendRequest{Body: "only once", ClientMsgID: &id} first, err := svc.Send(ctx, a, conv.ID, req) if err != nil { t.Fatal(err) } again, err := svc.Send(ctx, a, conv.ID, req) if err != nil { t.Fatal(err) } if again.ID != first.ID { t.Errorf("retry created a second message: %s vs %s", again.ID, first.ID) } msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10) if len(msgs) != 1 { t.Errorf("thread has %d messages after a retry, want 1", len(msgs)) } } func TestIsParticipant_DistinguishesDenialFromFailure(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) ok, err := svc.IsParticipant(ctx, a, conv.ID) if err != nil || !ok { t.Errorf("member: (%v, %v), want (true, nil)", ok, err) } // Not a member is a plain denial, not an error — otherwise the channel // rule would return 500 instead of 403 for every outsider. ok, err = svc.IsParticipant(ctx, outsider, conv.ID) if err != nil || ok { t.Errorf("outsider: (%v, %v), want (false, nil)", ok, err) } } func TestKindsForTab(t *testing.T) { if got := KindsForTab("kautilya"); len(got) != 1 || got[0] != model.ConversationAI { t.Errorf("kautilya tab = %v", got) } for _, tab := range []string{"chats", ""} { got := KindsForTab(tab) if len(got) != 2 || got[0] != model.ConversationDM || got[1] != model.ConversationGroup { t.Errorf("tab %q = %v, want dm+group", tab, got) } } } // Muting silences alerts for one participant without touching delivery: the // other member is unaffected, and unread keeps climbing for the muter. func TestSetMute(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if muted, _ := svc.IsMuted(ctx, b, conv.ID); muted { t.Error("thread starts muted") } until, err := svc.SetMute(ctx, b, conv.ID, MuteEightHours) if err != nil { t.Fatal(err) } if until == nil || !until.After(time.Now()) { t.Fatalf("muted_until = %v, want a future time", until) } if muted, _ := svc.IsMuted(ctx, b, conv.ID); !muted { t.Error("not muted after an 8h mute") } // Mute is per participant: A must be unaffected by B muting. if muted, _ := svc.IsMuted(ctx, a, conv.ID); muted { t.Error("muting for B also muted A") } // Delivery continues while muted — unread still accrues. if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "still arrives"}); err != nil { t.Fatal(err) } inbox, err := svc.Inbox(ctx, b, "chats", 10) if err != nil { t.Fatal(err) } var row *model.InboxItem for i := range inbox { if inbox[i].ID == conv.ID { row = &inbox[i] } } if row == nil { t.Fatal("muted thread vanished from the inbox") } if row.Unread == 0 { t.Error("muted thread stopped counting unread — mute silences alerts, not delivery") } if row.MutedUntil == nil { t.Error("inbox row does not report muted_until, so the client can't show the muted icon") } if _, err := svc.SetMute(ctx, b, conv.ID, MuteOff); err != nil { t.Fatal(err) } if muted, _ := svc.IsMuted(ctx, b, conv.ID); muted { t.Error("still muted after unmuting") } p, _ := chat.Participant(ctx, conv.ID, b) if p.MutedUntil != nil { t.Errorf("muted_until = %v after unmute, want NULL", p.MutedUntil) } } // A mute whose deadline has passed reads as unmuted, so no sweep job is needed. func TestIsMuted_ExpiredMuteReadsAsUnmuted(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) past := time.Now().Add(-time.Hour) if err := chat.SetMute(ctx, conv.ID, b, &past); err != nil { t.Fatal(err) } if muted, err := svc.IsMuted(ctx, b, conv.ID); err != nil || muted { t.Errorf("expired mute: muted = %v, err = %v — want false", muted, err) } } func TestSetMute_RejectsUnknownDurationAndOutsiders(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if _, err := svc.SetMute(ctx, a, conv.ID, "forever-and-ever"); !errors.Is(err, ErrChatBadMuteDuration) { t.Errorf("bad duration: err = %v, want ErrChatBadMuteDuration", err) } if _, err := svc.SetMute(ctx, outsider, conv.ID, MuteAlways); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider mute: err = %v, want ErrNotParticipant", err) } } // "Always" must not silently expire — it is stored far enough out that no // realistic clock reaches it. func TestSetMute_AlwaysDoesNotExpire(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) until, err := svc.SetMute(ctx, a, conv.ID, MuteAlways) if err != nil { t.Fatal(err) } if until.Year() < 9000 { t.Errorf("always-mute expires in %d", until.Year()) } if muted, _ := svc.IsMuted(ctx, a, conv.ID); !muted { t.Error("always-mute does not read as muted") } } // Replying must be confined to the thread you are in. The foreign key alone // only proves the target exists somewhere, so without the write-side check a // client could quote a conversation it was never part of — and the reply // preview would then render that text to everyone here. func TestSend_ReplyTargetMustBeInSameConversation(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") here, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, here.ID) elsewhere, err := svc.OpenDM(ctx, a, c) if err != nil { t.Fatal(err) } cleanupConv(t, pg, elsewhere.ID) secret, err := svc.Send(ctx, a, elsewhere.ID, SendRequest{Body: "private to C"}) if err != nil { t.Fatal(err) } _, err = svc.Send(ctx, a, here.ID, SendRequest{Body: "quoting", ReplyToID: &secret.ID}) if !errors.Is(err, repository.ErrReplyTargetNotHere) { t.Fatalf("cross-thread reply: err = %v, want ErrReplyTargetNotHere", err) } // And nothing was persisted by the rejected attempt. msgs, _ := svc.Messages(ctx, a, here.ID, 0, 10) if len(msgs) != 0 { t.Errorf("rejected reply still wrote %d message(s)", len(msgs)) } } // A reply within the thread is hydrated for rendering: the bubble needs the // quoted sender and a line of text. func TestSend_ReplyIsHydratedOnRead(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"}) if err != nil { t.Fatal(err) } if _, err := svc.Send(ctx, b, conv.ID, SendRequest{Body: "answering", ReplyToID: &original.ID}); err != nil { t.Fatal(err) } msgs, err := svc.Messages(ctx, a, conv.ID, 0, 10) if err != nil { t.Fatal(err) } // Newest first. reply := msgs[0] if reply.ReplyTo == nil { t.Fatal("reply has no hydrated preview") } if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" { t.Errorf("preview = %+v, want the original message", reply.ReplyTo) } if reply.ReplyTo.SenderName == "" { t.Error("preview has no sender name — the quote line needs it") } // A plain message carries no preview. if msgs[1].ReplyTo != nil { t.Errorf("non-reply carries a preview: %+v", msgs[1].ReplyTo) } } // The thread screen needs participants by name in one request — previously it // scanned both inbox tabs to find a single conversation. func TestDetail(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hello"}); err != nil { t.Fatal(err) } d, err := svc.Detail(ctx, b, conv.ID) if err != nil { t.Fatal(err) } if len(d.Participants) != 2 { t.Errorf("participants = %d, want 2", len(d.Participants)) } for _, p := range d.Participants { if p.Name == "" || p.Handle == "" { t.Errorf("participant %+v is missing a name/handle — the reply banner needs them", p) } } if d.Peer == nil || d.Peer.ID != a { t.Errorf("peer = %+v, want user A", d.Peer) } if d.Unread != 1 { t.Errorf("unread = %d, want 1", d.Unread) } // Same 404-shaped denial as every other chat read. if _, err := svc.Detail(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider: err = %v, want ErrNotParticipant", err) } } // A group's detail carries every member and no peer. func TestDetail_GroupHasNoPeer(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") conv, err := svc.CreateGroup(ctx, a, "Kīrtana", []string{handleOf(t, pg, b), handleOf(t, pg, c)}) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) d, err := svc.Detail(ctx, a, conv.ID) if err != nil { t.Fatal(err) } if d.Peer != nil { t.Errorf("group has a peer: %+v", d.Peer) } if len(d.Participants) != 3 { t.Errorf("participants = %d, want 3", len(d.Participants)) } } // A link card may only be attached for a URL the parser actually found in the // body. Without this a message could carry a card for a link it never mentions // — the text says one thing, the card says another. func TestSend_PreviewURLMustAppearInBody(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) elsewhere := "https://example.org/not-mentioned" msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ Body: "just some text with no link", PreviewURL: &elsewhere, }) if err != nil { t.Fatal(err) } for _, att := range msg.Attachments { if att.Kind == model.AttachmentLink { t.Errorf("attached a card for a URL not in the body: %+v", att) } } } // Media keys are namespaced by conversation and uploader. Attaching a key from // outside the caller's own prefix must be refused, or a guessed key could pull // a file from a thread the caller is not in. func TestSend_RejectsForeignAttachmentKeys(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) foreign := []MediaAttachment{ {Key: "chat/" + conv.ID + "/" + b + "/deadbeef", Mime: "image/png"}, // another user's prefix {Key: "chat/some-other-conversation/" + a + "/deadbeef", Mime: "image/png"}, {Key: "avatars/" + a + "/photo.png", Mime: "image/png"}, // outside chat entirely {Key: "../../etc/passwd", Mime: "image/png"}, } for _, m := range foreign { msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ Body: "look at this", Attachments: []MediaAttachment{m}, }) if err != nil { t.Fatalf("send: %v", err) } if len(msg.Attachments) != 0 { t.Errorf("key %q was attached; want it refused", m.Key) } } } // Uploads require the private bucket. Falling back to the public one would put // chat media at a URL anyone could fetch. func TestPresignAttachment_RequiresPrivateBucket(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) // This service was built with a nil R2 (see testChatService). if _, _, err := svc.PresignAttachment(ctx, a, conv.ID, "image/png"); !errors.Is(err, ErrChatMediaOff) { t.Errorf("err = %v, want ErrChatMediaOff", err) } // Membership is still checked before anything else. outsider := chatUser(t, pg, "C") if _, _, err := svc.PresignAttachment(ctx, outsider, conv.ID, "image/png"); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider: err = %v, want ErrNotParticipant", err) } } func TestAttachmentKindFor(t *testing.T) { cases := map[string]string{ "image/png": model.AttachmentImage, "image/webp": model.AttachmentImage, "video/mp4": model.AttachmentVideo, "audio/mpeg": model.AttachmentAudio, "application/pdf": model.AttachmentFile, } for mime, want := range cases { if got, ok := attachmentKindFor(mime); !ok || got != want { t.Errorf("attachmentKindFor(%q) = (%q, %v), want %q", mime, got, ok, want) } } // Anything executable or unknown is refused rather than stored as a file. for _, mime := range []string{"text/html", "application/x-sh", "application/octet-stream", ""} { if _, ok := attachmentKindFor(mime); ok { t.Errorf("attachmentKindFor(%q) accepted it", mime) } } } // Attachments always serialize as an array so clients need no nil check. func TestMessages_AttachmentsAlwaysPresent(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "plain"}); err != nil { t.Fatal(err) } msgs, err := svc.Messages(ctx, a, conv.ID, 0, 10) if err != nil { t.Fatal(err) } if msgs[0].Attachments == nil { t.Error("attachments is nil, want an empty slice") } encoded, _ := json.Marshal(msgs[0].Attachments) if string(encoded) != "[]" { t.Errorf("serialized as %s, want []", encoded) } } // A photo with no caption is an ordinary message — only a message with neither // text nor attachments is empty. func TestSend_AttachmentWithoutCaptionIsAllowed(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) key := "chat/" + conv.ID + "/" + a + "/abc123" msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ Body: "", Attachments: []MediaAttachment{{Key: key, Mime: "image/jpeg"}}, }) if err != nil { t.Fatalf("captionless attachment rejected: %v", err) } if len(msg.Attachments) != 1 || msg.Attachments[0].Kind != model.AttachmentImage { t.Errorf("attachments = %+v, want one image", msg.Attachments) } if msg.Body != "" { t.Errorf("body = %q, want empty", msg.Body) } // Neither text nor attachments is still empty. if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: " "}); !errors.Is(err, ErrChatEmptyBody) { t.Errorf("empty send: err = %v, want ErrChatEmptyBody", err) } } // Starring is scoped to the thread: a message id from another conversation // must be refused, or a caller could star something they cannot see. func TestSetStar_ScopedToConversation(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") here, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, here.ID) elsewhere, err := svc.OpenDM(ctx, a, c) if err != nil { t.Fatal(err) } cleanupConv(t, pg, elsewhere.ID) mine, err := svc.Send(ctx, a, here.ID, SendRequest{Body: "star me"}) if err != nil { t.Fatal(err) } other, err := svc.Send(ctx, a, elsewhere.ID, SendRequest{Body: "different thread"}) if err != nil { t.Fatal(err) } if err := svc.SetStar(ctx, a, here.ID, mine.ID, true); err != nil { t.Fatalf("star own thread's message: %v", err) } if err := svc.SetStar(ctx, a, here.ID, other.ID, true); !errors.Is(err, repository.ErrMessageNotHere) { t.Errorf("cross-thread star: err = %v, want ErrMessageNotHere", err) } starred, err := svc.Starred(ctx, a, here.ID, 10) if err != nil { t.Fatal(err) } if len(starred) != 1 || starred[0].ID != mine.ID { t.Errorf("starred = %d message(s), want just the one in this thread", len(starred)) } // Stars are per user — B sees none of A's. theirs, _ := svc.Starred(ctx, b, here.ID, 10) if len(theirs) != 0 { t.Errorf("other participant sees %d starred, want 0", len(theirs)) } // Unstar is idempotent. for i := 0; i < 2; i++ { if err := svc.SetStar(ctx, a, here.ID, mine.ID, false); err != nil { t.Fatalf("unstar: %v", err) } } if after, _ := svc.Starred(ctx, a, here.ID, 10); len(after) != 0 { t.Errorf("still starred after unstar: %d", len(after)) } } // The chat profile's tabs are a kind filter over the same gallery index. func TestGallery_SplitsMediaFromLinks(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ Body: "with a photo", Attachments: []MediaAttachment{{Key: "chat/" + conv.ID + "/" + a + "/pic", Mime: "image/jpeg"}}, }) if err != nil { t.Fatal(err) } url := "https://example.org/x" if _, err := chat.AddAttachment(ctx, repository.AddAttachmentParams{ MessageID: msg.ID, ConversationID: conv.ID, Ord: 1, Kind: model.AttachmentLink, URL: &url, }); err != nil { t.Fatal(err) } media, err := svc.Gallery(ctx, a, conv.ID, "media", nil, 50) if err != nil { t.Fatal(err) } if len(media) != 1 || media[0].Kind != model.AttachmentImage { t.Errorf("media tab = %+v, want one image", media) } links, err := svc.Gallery(ctx, a, conv.ID, "links", nil, 50) if err != nil { t.Fatal(err) } if len(links) != 1 || links[0].Kind != model.AttachmentLink { t.Errorf("links tab = %+v, want one link", links) } outsider := chatUser(t, pg, "C") if _, err := svc.Gallery(ctx, outsider, conv.ID, "media", nil, 50); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider gallery: err = %v, want ErrNotParticipant", err) } } // The profile line reads "friends since" off the accepted follow, in whichever // direction it happened, and reports one-way follows honestly. func TestDetail_Relationship(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) follows := repository.NewFollowRepo(pg) // No edges yet. d, err := svc.Detail(ctx, a, conv.ID) if err != nil { t.Fatal(err) } if d.Relationship == nil { t.Fatal("DM detail has no relationship") } if d.Relationship.Following || d.Relationship.FollowedBy || d.Relationship.FriendsSince != nil { t.Errorf("relationship = %+v, want all-false with no date", d.Relationship) } // A pending request is not a friendship. if _, err := follows.Request(ctx, a, b); err != nil { t.Fatal(err) } d, _ = svc.Detail(ctx, a, conv.ID) if d.Relationship.Following || d.Relationship.FriendsSince != nil { t.Errorf("pending request counted as a follow: %+v", d.Relationship) } // Accepted: one-way. if _, err := follows.Accept(ctx, a, b); err != nil { t.Fatal(err) } d, _ = svc.Detail(ctx, a, conv.ID) if !d.Relationship.Following || d.Relationship.FollowedBy { t.Errorf("one-way follow reported as %+v", d.Relationship) } if d.Relationship.FriendsSince == nil { t.Error("friends_since is nil after an accepted follow") } // From B's side the same edge is a follower, not a following. fromB, _ := svc.Detail(ctx, b, conv.ID) if fromB.Relationship.Following || !fromB.Relationship.FollowedBy { t.Errorf("peer's view = %+v, want followed_by only", fromB.Relationship) } } // A group has no single peer, so no relationship block. func TestDetail_GroupHasNoRelationship(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.CreateGroup(ctx, a, "Group", []string{handleOf(t, pg, b)}) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) d, err := svc.Detail(ctx, a, conv.ID) if err != nil { t.Fatal(err) } if d.Relationship != nil { t.Errorf("group carries a relationship: %+v", d.Relationship) } } // "Delete for everyone" is the sender's alone — letting a recipient erase // someone else's words for all participants would be a moderation power, not a // chat feature. Anyone may hide a message for themselves. func TestDeleteMessage_Scopes(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) fromA, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "A wrote this"}) if err != nil { t.Fatal(err) } // B cannot delete A's message for everyone. if err := svc.DeleteMessage(ctx, b, fromA.ID, DeleteForEveryone); !errors.Is(err, ErrChatNotSender) { t.Errorf("recipient delete-for-everyone: err = %v, want ErrChatNotSender", err) } // But B can hide it from their own view, leaving A's intact. if err := svc.DeleteMessage(ctx, b, fromA.ID, DeleteForMe); err != nil { t.Fatalf("delete for me: %v", err) } bSees, _ := svc.Messages(ctx, b, conv.ID, 0, 10) if len(bSees) != 0 { t.Errorf("hider still sees %d message(s)", len(bSees)) } aSees, _ := svc.Messages(ctx, a, conv.ID, 0, 10) if len(aSees) != 1 { t.Errorf("sender sees %d, want their message still there", len(aSees)) } // A deletes for everyone: the row survives as a tombstone with its text // gone, so seq stays gapless and the bubble can be replaced in place. if err := svc.DeleteMessage(ctx, a, fromA.ID, DeleteForEveryone); err != nil { t.Fatalf("delete for everyone: %v", err) } after, _ := svc.Messages(ctx, a, conv.ID, 0, 10) if len(after) != 1 { t.Fatalf("tombstone missing: %d messages", len(after)) } if after[0].DeletedAt == nil { t.Error("message not marked deleted") } if after[0].Body != "" { t.Errorf("body = %q, want the text cleared", after[0].Body) } } // Forwarding checks BOTH memberships. Checking only the destination would turn // a message id into a way to copy text out of a thread you were never in. func TestForwardMessage_RequiresAccessToSourceAndDestination(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") private, err := svc.OpenDM(ctx, b, c) // A is NOT in this one if err != nil { t.Fatal(err) } cleanupConv(t, pg, private.ID) mine, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, mine.ID) secret, err := svc.Send(ctx, b, private.ID, SendRequest{Body: "not for A"}) if err != nil { t.Fatal(err) } if _, err := svc.ForwardMessage(ctx, a, secret.ID, mine.ID); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("forwarding from an unseen thread: err = %v, want ErrNotParticipant", err) } // A legitimate forward carries the text and records its origin. ours, err := svc.Send(ctx, a, mine.ID, SendRequest{Body: "*worth* sharing"}) if err != nil { t.Fatal(err) } elsewhere, err := svc.OpenDM(ctx, a, c) if err != nil { t.Fatal(err) } cleanupConv(t, pg, elsewhere.ID) fwd, err := svc.ForwardMessage(ctx, a, ours.ID, elsewhere.ID) if err != nil { t.Fatalf("forward: %v", err) } if fwd.Body != ours.Body { t.Errorf("forwarded body = %q, want %q", fwd.Body, ours.Body) } if fwd.ForwardedFromID == nil || *fwd.ForwardedFromID != ours.ID { t.Errorf("forwarded_from_id = %v, want %s", fwd.ForwardedFromID, ours.ID) } if fwd.ConversationID != elsewhere.ID { t.Errorf("landed in %s, want %s", fwd.ConversationID, elsewhere.ID) } } func TestForwardMessage_RefusesDeleted(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) other, err := svc.OpenDM(ctx, a, chatUser(t, pg, "C")) if err != nil { t.Fatal(err) } cleanupConv(t, pg, other.ID) msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "gone soon"}) if err != nil { t.Fatal(err) } if err := svc.DeleteMessage(ctx, a, msg.ID, DeleteForEveryone); err != nil { t.Fatal(err) } if _, err := svc.ForwardMessage(ctx, a, msg.ID, other.ID); !errors.Is(err, ErrMessageDeleted) { t.Errorf("err = %v, want ErrMessageDeleted", err) } } // A report copies its evidence in, so moderators read the report rather than // the live thread — and the evidence survives the sender deleting it. func TestReportMessage_SnapshotsEvidence(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) for i := 0; i < 3; i++ { if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "context"}); err != nil { t.Fatal(err) } } bad, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the offending message"}) if err != nil { t.Fatal(err) } if err := svc.ReportMessage(ctx, outsider, bad.ID, "abuse", "n/a"); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider report: err = %v, want ErrNotParticipant", err) } if err := svc.ReportMessage(ctx, b, bad.ID, " ", "no category"); !errors.Is(err, ErrChatBadReport) { t.Errorf("blank category: err = %v, want ErrChatBadReport", err) } if err := svc.ReportMessage(ctx, b, bad.ID, "abuse", "please review"); err != nil { t.Fatalf("report: %v", err) } var snapshot []byte var reported *string if err := pg.Pool.QueryRow(ctx, ` SELECT snapshot, reported_user_id FROM message_reports WHERE message_id = $1`, bad.ID).Scan(&snapshot, &reported); err != nil { t.Fatal(err) } if reported == nil || *reported != a { t.Errorf("reported_user_id = %v, want the sender", reported) } var payload struct { Messages []struct { ID string `json:"id"` Body string `json:"body"` } `json:"messages"` } if err := json.Unmarshal(snapshot, &payload); err != nil { t.Fatalf("snapshot is not valid JSON: %v", err) } if len(payload.Messages) < 2 { t.Errorf("snapshot captured %d messages, want the reported one plus context", len(payload.Messages)) } found := false for _, m := range payload.Messages { if m.ID == bad.ID && m.Body == "the offending message" { found = true } } if !found { t.Error("snapshot does not contain the reported message's text") } // The evidence must outlive the sender deleting the message. if err := svc.DeleteMessage(ctx, a, bad.ID, DeleteForEveryone); err != nil { t.Fatal(err) } var stillThere []byte if err := pg.Pool.QueryRow(ctx, `SELECT snapshot FROM message_reports WHERE reporter_id = $1`, b).Scan(&stillThere); err != nil { t.Fatalf("report vanished with the message: %v", err) } if !bytes.Contains(stillThere, []byte("the offending message")) { t.Error("snapshot lost its evidence when the message was deleted") } } func groupOf(t *testing.T, svc *ChatService, pg *repository.Postgres, owner string, members ...string) string { t.Helper() handles := make([]string, 0, len(members)) for _, m := range members { handles = append(handles, handleOf(t, pg, m)) } conv, err := svc.CreateGroup(context.Background(), owner, "Kīrtana", handles) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) return conv.ID } // An admins-only group is an announcement channel: members read, admins post. func TestSendPolicy_AdminsOnly(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") id := groupOf(t, svc, pg, owner, member) // Default lets everyone post — the migration must not mute existing groups. if _, err := svc.Send(ctx, member, id, SendRequest{Body: "hello"}); err != nil { t.Fatalf("member send under the default policy: %v", err) } admins := model.SendPolicyAdmins if err := svc.UpdateGroup(ctx, owner, id, nil, &admins, nil); err != nil { t.Fatal(err) } if _, err := svc.Send(ctx, member, id, SendRequest{Body: "still talking"}); !errors.Is(err, ErrChatAdminsOnly) { t.Errorf("member send under admins-only: err = %v, want ErrChatAdminsOnly", err) } if _, err := svc.Send(ctx, owner, id, SendRequest{Body: "announcement"}); err != nil { t.Errorf("owner blocked by their own policy: %v", err) } // Promoting the member restores their ability to post. if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), model.RoleAdmin); err != nil { t.Fatal(err) } if _, err := svc.Send(ctx, member, id, SendRequest{Body: "now an admin"}); err != nil { t.Errorf("promoted member still blocked: %v", err) } // Reverting the policy lets everyone back in. everyone := model.SendPolicyEveryone if err := svc.UpdateGroup(ctx, owner, id, nil, &everyone, nil); err != nil { t.Fatal(err) } if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), model.RoleMember); err != nil { t.Fatal(err) } if _, err := svc.Send(ctx, member, id, SendRequest{Body: "talking again"}); err != nil { t.Errorf("member blocked after reverting the policy: %v", err) } } // Only admins administer, and only the owner changes roles — an admin // promoting other admins would let one account escalate the whole group. func TestGroupAdministration_Permissions(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() owner := chatUser(t, pg, "owner") admin := chatUser(t, pg, "admin") member := chatUser(t, pg, "member") outsider := chatUser(t, pg, "outsider") id := groupOf(t, svc, pg, owner, admin, member) if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, admin), model.RoleAdmin); err != nil { t.Fatal(err) } t.Run("a plain member cannot administer", func(t *testing.T) { if err := svc.AddMembers(ctx, member, id, []string{handleOf(t, pg, outsider)}); !errors.Is(err, ErrChatNotAdmin) { t.Errorf("add: err = %v, want ErrChatNotAdmin", err) } title := "Renamed" if err := svc.UpdateGroup(ctx, member, id, &title, nil, nil); !errors.Is(err, ErrChatNotAdmin) { t.Errorf("rename: err = %v, want ErrChatNotAdmin", err) } }) t.Run("an admin can add but cannot change roles", func(t *testing.T) { if err := svc.AddMembers(ctx, admin, id, []string{handleOf(t, pg, outsider)}); err != nil { t.Errorf("admin add: %v", err) } if err := svc.SetMemberRole(ctx, admin, id, handleOf(t, pg, member), model.RoleAdmin); !errors.Is(err, ErrChatNotAdmin) { t.Errorf("admin promoting: err = %v, want ErrChatNotAdmin", err) } }) t.Run("the owner is immutable", func(t *testing.T) { if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, owner), model.RoleMember); !errors.Is(err, ErrChatOwnerImmutable) { t.Errorf("self-demote: err = %v, want ErrChatOwnerImmutable", err) } if err := svc.RemoveMember(ctx, admin, id, handleOf(t, pg, owner)); !errors.Is(err, ErrChatOwnerImmutable) { t.Errorf("admin removing owner: err = %v, want ErrChatOwnerImmutable", err) } }) t.Run("an admin cannot remove another admin", func(t *testing.T) { second := chatUser(t, pg, "admin2") if err := svc.AddMembers(ctx, owner, id, []string{handleOf(t, pg, second)}); err != nil { t.Fatal(err) } if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, second), model.RoleAdmin); err != nil { t.Fatal(err) } if err := svc.RemoveMember(ctx, admin, id, handleOf(t, pg, second)); !errors.Is(err, ErrChatNotAdmin) { t.Errorf("admin removing admin: err = %v, want ErrChatNotAdmin", err) } // The owner can. if err := svc.RemoveMember(ctx, owner, id, handleOf(t, pg, second)); err != nil { t.Errorf("owner removing admin: %v", err) } }) t.Run("bad roles and policies are rejected", func(t *testing.T) { if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), "superuser"); !errors.Is(err, ErrChatBadRole) { t.Errorf("err = %v, want ErrChatBadRole", err) } bad := "nobody" if err := svc.UpdateGroup(ctx, owner, id, nil, &bad, nil); !errors.Is(err, ErrChatBadPolicy) { t.Errorf("err = %v, want ErrChatBadPolicy", err) } }) t.Run("group-only operations refuse DMs", func(t *testing.T) { dm, err := svc.OpenDM(ctx, owner, member) if err != nil { t.Fatal(err) } cleanupConv(t, pg, dm.ID) if err := svc.AddMembers(ctx, owner, dm.ID, []string{handleOf(t, pg, outsider)}); !errors.Is(err, ErrChatNotGroup) { t.Errorf("err = %v, want ErrChatNotGroup", err) } }) } // Anyone may leave, and a departing owner hands the group on — otherwise it // would be left with nobody able to administer it. func TestRemoveMember_LeavingAndSuccession(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() owner := chatUser(t, pg, "owner") admin := chatUser(t, pg, "admin") member := chatUser(t, pg, "member") id := groupOf(t, svc, pg, owner, admin, member) if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, admin), model.RoleAdmin); err != nil { t.Fatal(err) } // A member removes themselves without any admin rights. if err := svc.RemoveMember(ctx, member, id, handleOf(t, pg, member)); err != nil { t.Fatalf("self-removal: %v", err) } if _, err := chat.Participant(ctx, id, member); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("after leaving: err = %v, want ErrNotParticipant", err) } // The owner leaves; the admin inherits. if err := svc.RemoveMember(ctx, owner, id, handleOf(t, pg, owner)); err != nil { t.Fatalf("owner leaving: %v", err) } successor, err := chat.Participant(ctx, id, admin) if err != nil { t.Fatal(err) } if successor.Role != model.RoleOwner { t.Errorf("successor role = %q, want owner", successor.Role) } } // Re-adding someone who left restarts them as a plain member; it must not // resurrect the admin rights they had before. func TestAddMembers_RejoinDoesNotRestoreAdmin(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() owner, other := chatUser(t, pg, "owner"), chatUser(t, pg, "other") id := groupOf(t, svc, pg, owner, other) if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, other), model.RoleAdmin); err != nil { t.Fatal(err) } if err := svc.RemoveMember(ctx, other, id, handleOf(t, pg, other)); err != nil { t.Fatal(err) } if err := svc.AddMembers(ctx, owner, id, []string{handleOf(t, pg, other)}); err != nil { t.Fatal(err) } back, err := chat.Participant(ctx, id, other) if err != nil { t.Fatalf("rejoined member is not a participant: %v", err) } if back.Role != model.RoleMember { t.Errorf("role after rejoining = %q, want member", back.Role) } } // The caller's own role rides on Detail so the UI can show admin controls // without deriving it from the member list. func TestDetail_ReportsMyRoleAndMemberRoles(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") id := groupOf(t, svc, pg, owner, member) asOwner, err := svc.Detail(ctx, owner, id) if err != nil { t.Fatal(err) } if asOwner.MyRole != model.RoleOwner { t.Errorf("my_role = %q, want owner", asOwner.MyRole) } if asOwner.SendPolicy != model.SendPolicyEveryone { t.Errorf("send_policy = %q, want everyone", asOwner.SendPolicy) } // The owner sorts first in the member list. if len(asOwner.Participants) != 2 || asOwner.Participants[0].Role != model.RoleOwner { t.Errorf("participants = %+v, want the owner first", asOwner.Participants) } asMember, _ := svc.Detail(ctx, member, id) if asMember.MyRole != model.RoleMember { t.Errorf("member's my_role = %q, want member", asMember.MyRole) } } // A group's photo must come from that group's own upload prefix. Without the // check an admin could point avatar_key at another thread's media, and every // member here would be served a file from a conversation they are not in. func TestUpdateGroup_AvatarKeyMustBelongToThisGroup(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") id := groupOf(t, svc, pg, owner, member) other := groupOf(t, svc, pg, owner) foreign := []string{ "chat/" + other + "/avatar/deadbeef", // another group's avatar "chat/" + id + "/" + owner + "/photo", // a message attachment here "avatars/" + owner + "/me.png", // outside chat entirely "../../etc/passwd", } for _, key := range foreign { k := key if err := svc.UpdateGroup(ctx, owner, id, nil, nil, &k); !errors.Is(err, ErrChatBadAvatarKey) { t.Errorf("key %q: err = %v, want ErrChatBadAvatarKey", key, err) } } // A key from this group's own prefix is accepted and stored. valid := "chat/" + id + "/avatar/abc123" if err := svc.UpdateGroup(ctx, owner, id, nil, nil, &valid); err != nil { t.Fatalf("valid avatar key rejected: %v", err) } conv, err := chat.Conversation(ctx, id) if err != nil { t.Fatal(err) } if conv.AvatarKey == nil || *conv.AvatarKey != valid { t.Errorf("avatar_key = %v, want %q", conv.AvatarKey, valid) } // And a plain member cannot set it at all. another := "chat/" + id + "/avatar/xyz" if err := svc.UpdateGroup(ctx, member, id, nil, nil, &another); !errors.Is(err, ErrChatNotAdmin) { t.Errorf("member setting the photo: err = %v, want ErrChatNotAdmin", err) } } // Reading a group photo is members-only, and says so clearly when unset. func TestGroupAvatarURL_Access(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() owner, outsider := chatUser(t, pg, "owner"), chatUser(t, pg, "outsider") id := groupOf(t, svc, pg, owner) if _, err := svc.GroupAvatarURL(ctx, outsider, id); !errors.Is(err, repository.ErrNotParticipant) { t.Errorf("outsider: err = %v, want ErrNotParticipant", err) } // A member of a group with no photo gets a clear "unset", not a 500. if _, err := svc.GroupAvatarURL(ctx, owner, id); !errors.Is(err, ErrAvatarUnset) { t.Errorf("no photo: err = %v, want ErrAvatarUnset", err) } } // Only image uploads are offered, and only to admins. func TestPresignGroupAvatar_Restrictions(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") id := groupOf(t, svc, pg, owner, member) if _, _, err := svc.PresignGroupAvatar(ctx, member, id, "image/png"); !errors.Is(err, ErrChatNotAdmin) { t.Errorf("member: err = %v, want ErrChatNotAdmin", err) } // A PDF is a valid attachment but not a valid photo. if _, _, err := svc.PresignGroupAvatar(ctx, owner, id, "application/pdf"); !errors.Is(err, ErrChatBadMedia) { t.Errorf("pdf: err = %v, want ErrChatBadMedia", err) } // With R2 unconfigured in tests, an admin with a valid image gets as far as // the storage check — proving the admin and MIME gates both passed. if _, _, err := svc.PresignGroupAvatar(ctx, owner, id, "image/jpeg"); !errors.Is(err, ErrChatMediaOff) { t.Errorf("admin with an image: err = %v, want ErrChatMediaOff", err) } } // Absent settings must read as the product defaults, or a user who never opened // the settings page would get whatever zero values happen to be. func TestSettings_DefaultsWhenNeverSaved(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a := chatUser(t, pg, "A") got, err := svc.Settings(ctx, a) if err != nil { t.Fatal(err) } want := model.DefaultChatSettings() if got.ReadReceipts != want.ReadReceipts || got.WhoCanMessage != want.WhoCanMessage || got.MinAccountAgeMonths != want.MinAccountAgeMonths || got.TrainKautilya != want.TrainKautilya { t.Errorf("defaults = %+v, want %+v", got, want) } if !got.ReadReceipts || got.WhoCanMessage != model.WhoAnyone || !got.TrainKautilya { t.Error("defaults must be: receipts on, anyone can message, training on") } } func TestSaveSettings_Validation(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a := chatUser(t, pg, "A") // test users are created with no gender base := model.DefaultChatSettings() bad := base bad.WhoCanMessage = "nobody-at-all" if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatBadWhoCanMessage) { t.Errorf("unknown policy: err = %v, want ErrChatBadWhoCanMessage", err) } for _, months := range []int{0, -1, 121} { bad = base bad.WhoCanMessage = model.WhoAccountAge bad.MinAccountAgeMonths = months if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatBadAccountAge) { t.Errorf("%d months: err = %v, want ErrChatBadAccountAge", months, err) } } // same_gender without a gender on the profile would silently do nothing, // so it is refused with an error the UI can explain. bad = base bad.WhoCanMessage = model.WhoSameGender if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatGenderUnset) { t.Errorf("same_gender with no gender: err = %v, want ErrChatGenderUnset", err) } // Once a gender is set it becomes selectable. if _, err := pg.Pool.Exec(ctx, `UPDATE users SET gender='female' WHERE id=$1`, a); err != nil { t.Fatal(err) } if saved, err := svc.SaveSettings(ctx, a, bad); err != nil { t.Errorf("same_gender with a gender: %v", err) } else if saved.WhoCanMessage != model.WhoSameGender || !saved.GenderSet { t.Errorf("saved = %+v", saved) } } // "Who can message you" gates STARTING a thread. func TestWhoCanMessage_GatesNewConversations(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() follows := repository.NewFollowRepo(pg) t.Run("friends only", func(t *testing.T) { recipient, stranger := chatUser(t, pg, "R"), chatUser(t, pg, "S") s := model.DefaultChatSettings() s.WhoCanMessage = model.WhoFriends if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { t.Fatal(err) } if _, err := svc.OpenDM(ctx, stranger, recipient); !errors.Is(err, ErrChatNotAccepting) { t.Errorf("stranger: err = %v, want ErrChatNotAccepting", err) } // An accepted follow in either direction counts as a connection. if _, err := follows.Request(ctx, stranger, recipient); err != nil { t.Fatal(err) } if _, err := follows.Accept(ctx, stranger, recipient); err != nil { t.Fatal(err) } conv, err := svc.OpenDM(ctx, stranger, recipient) if err != nil { t.Fatalf("friend: %v", err) } cleanupConv(t, pg, conv.ID) }) t.Run("account age", func(t *testing.T) { recipient, fresh := chatUser(t, pg, "R"), chatUser(t, pg, "F") s := model.DefaultChatSettings() s.WhoCanMessage = model.WhoAccountAge s.MinAccountAgeMonths = 6 if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { t.Fatal(err) } if _, err := svc.OpenDM(ctx, fresh, recipient); !errors.Is(err, ErrChatNotAccepting) { t.Errorf("new account: err = %v, want ErrChatNotAccepting", err) } // Age the account past the threshold. if _, err := pg.Pool.Exec(ctx, `UPDATE users SET created_at = now() - interval '1 year' WHERE id=$1`, fresh); err != nil { t.Fatal(err) } conv, err := svc.OpenDM(ctx, fresh, recipient) if err != nil { t.Fatalf("old enough: %v", err) } cleanupConv(t, pg, conv.ID) }) t.Run("same gender", func(t *testing.T) { recipient, same, other := chatUser(t, pg, "R"), chatUser(t, pg, "S"), chatUser(t, pg, "O") if _, err := pg.Pool.Exec(ctx, `UPDATE users SET gender = CASE id WHEN $1 THEN 'female' WHEN $2 THEN 'female' ELSE 'male' END WHERE id = ANY($3)`, recipient, same, []string{recipient, same, other}); err != nil { t.Fatal(err) } s := model.DefaultChatSettings() s.WhoCanMessage = model.WhoSameGender if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { t.Fatal(err) } if _, err := svc.OpenDM(ctx, other, recipient); !errors.Is(err, ErrChatNotAccepting) { t.Errorf("different gender: err = %v, want ErrChatNotAccepting", err) } conv, err := svc.OpenDM(ctx, same, recipient) if err != nil { t.Fatalf("same gender: %v", err) } cleanupConv(t, pg, conv.ID) }) // Tightening the setting must not strand an existing conversation. t.Run("existing threads keep working", func(t *testing.T) { recipient, stranger := chatUser(t, pg, "R"), chatUser(t, pg, "S") conv, err := svc.OpenDM(ctx, stranger, recipient) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) s := model.DefaultChatSettings() s.WhoCanMessage = model.WhoFriends if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { t.Fatal(err) } if _, err := svc.Send(ctx, stranger, conv.ID, SendRequest{Body: "still here"}); err != nil { t.Errorf("existing thread was closed by the setting: %v", err) } }) } // Read receipts are reciprocal: switching them off stops you broadcasting your // reads AND stops you seeing everyone else's. A one-way version would let a // user watch others while staying invisible. func TestReadReceipts_AreReciprocal(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") conv, err := svc.OpenDM(ctx, a, b) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "read me"}) if err != nil { t.Fatal(err) } if err := svc.MarkRead(ctx, b, conv.ID, msg.Seq); err != nil { t.Fatal(err) } // Both on: A sees B's read. if r, err := svc.Receipts(ctx, a, conv.ID); err != nil || r.ReadSeq != msg.Seq { t.Fatalf("receipts = %+v (err %v), want read_seq %d", r, err, msg.Seq) } // B switches them off: A no longer sees B's read... off := model.DefaultChatSettings() off.ReadReceipts = false if _, err := svc.SaveSettings(ctx, b, off); err != nil { t.Fatal(err) } if r, _ := svc.Receipts(ctx, a, conv.ID); r.ReadSeq != 0 { t.Errorf("A still sees a read cursor for someone who switched receipts off: %+v", r) } // ...and B doesn't get to see A's either. if err := svc.MarkRead(ctx, a, conv.ID, msg.Seq); err != nil { t.Fatal(err) } if r, _ := svc.Receipts(ctx, b, conv.ID); r.ReadSeq != 0 { t.Errorf("B switched receipts off but still sees others': %+v", r) } // Delivery is unaffected — it reports that a device received the message, // not that a person read it. if err := svc.MarkDelivered(ctx, b, conv.ID, msg.Seq); err != nil { t.Fatal(err) } if r, _ := svc.Receipts(ctx, a, conv.ID); r.DeliveredSeq != msg.Seq { t.Errorf("delivery cursor = %+v, want %d", r, msg.Seq) } // The muter's own unread still clears — their cursor advanced locally. inbox, _ := svc.Inbox(ctx, b, "chats", 10) for _, row := range inbox { if row.ID == conv.ID && row.Unread != 0 { t.Errorf("unread = %d for someone who read the thread with receipts off", row.Unread) } } } // The Message button is offered from RelationshipState.CanMessage, so it must // agree with what OpenDM actually does — a button that fails on tap is worse // than no button. func TestCanStartConversation_MatchesOpenDM(t *testing.T) { svc, chat, pg := testChatService(t) ctx := context.Background() blocks := repository.NewBlockRepo(pg) follows := repository.NewFollowRepo(pg) cases := []struct { name string setup func(t *testing.T, viewer, target string) want bool }{ {"no restrictions", func(*testing.T, string, string) {}, true}, {"friends only, not a friend", func(t *testing.T, _, target string) { s := model.DefaultChatSettings() s.WhoCanMessage = model.WhoFriends if _, err := svc.SaveSettings(ctx, target, s); err != nil { t.Fatal(err) } }, false}, {"friends only, is a friend", func(t *testing.T, viewer, target string) { s := model.DefaultChatSettings() s.WhoCanMessage = model.WhoFriends if _, err := svc.SaveSettings(ctx, target, s); err != nil { t.Fatal(err) } if _, err := follows.Request(ctx, viewer, target); err != nil { t.Fatal(err) } if _, err := follows.Accept(ctx, viewer, target); err != nil { t.Fatal(err) } }, true}, {"target blocked the viewer", func(t *testing.T, viewer, target string) { if err := blocks.Block(ctx, target, viewer); err != nil { t.Fatal(err) } }, true}, // CanStartConversation doesn't consider blocks; OpenDM checks them first } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { viewer, target := chatUser(t, pg, "V"), chatUser(t, pg, "T") tc.setup(t, viewer, target) got, err := chat.CanStartConversation(ctx, viewer, target) if err != nil { t.Fatal(err) } if got != tc.want { t.Errorf("CanStartConversation = %v, want %v", got, tc.want) } // Whatever the predicate says, OpenDM must behave the same way for // the settings-based cases. conv, err := svc.OpenDM(ctx, viewer, target) if conv != nil { cleanupConv(t, pg, conv.ID) } refusedBySetting := errors.Is(err, ErrChatNotAccepting) if got && refusedBySetting { t.Error("predicate allowed it but OpenDM refused on the setting") } if !got && !refusedBySetting { t.Errorf("predicate refused it but OpenDM gave err = %v", err) } }) } } // Opening by handle is what the profile's Message button uses. func TestOpenDMByHandle(t *testing.T) { svc, _, pg := testChatService(t) ctx := context.Background() viewer := chatUser(t, pg, "V") target, targetHandle := chatUserWithHandle(t, pg, "T") conv, err := svc.OpenDMByHandle(ctx, viewer, targetHandle) if err != nil { t.Fatal(err) } cleanupConv(t, pg, conv.ID) // Same thread as opening by id — the handle path is a resolver, not a // second way to create conversations. byID, err := svc.OpenDM(ctx, viewer, target) if err != nil { t.Fatal(err) } if byID.ID != conv.ID { t.Errorf("handle path made a different thread: %s vs %s", conv.ID, byID.ID) } if _, err := svc.OpenDMByHandle(ctx, viewer, "nobody_by_this_handle"); !errors.Is(err, repository.ErrUserGone) { t.Errorf("unknown handle: err = %v, want ErrUserGone", err) } } // A quote renders as trusted in-app content, so its link must be unable to // leave the app. Anything that could navigate off-site has to be refused. func TestQuoteAttachment_RejectsOffSitePaths(t *testing.T) { good := []string{ "/books/bg/2/13", "/books/bg/2/13?hl=abc%20def", "/u/someone/posts#post-1", "/", } for _, p := range good { q := QuoteAttachment{Path: p, Title: "Bg 2.13", Text: "the passage"} if !q.valid() { t.Errorf("valid() = false for in-app path %q", p) } } bad := map[string]string{ "absolute http": "http://evil.example/x", "absolute https": "https://evil.example/x", "protocol relative": "//evil.example/x", "backslash relative": "/\\evil.example/x", "scheme only": "javascript:alert(1)", "data uri": "data:text/html,