Skip to content
quarry Artifact [dbb6653b64]
Login

Artifact [dbb6653b64]

Artifact dbb6653b64a1f7d4d3c162efeaf9bc161dab3221c2c62c5de59181d960f7deb4:


// chat_test.go proves RFC 0011 Phase 1 end to end: a real chat plane
// (embedded NATS) behind the real ops, reachable only through HTTP —
// the same path a client outside this process would use. No web layer
// is mounted here; these are API-only proofs.
package harness

import (
	"bufio"
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/events"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// chatServer wires a repo-less forge with just the chat plane and an
// OLTP for read-state, capability-gated exactly as production gates
// chat.post (Token + C). "dan" and "eve" both hold C on repo "sqlite";
// "admin" additionally holds "a".
func chatServer(t *testing.T) (*httptest.Server, string) {
	t.Helper()
	dataDir := t.TempDir()
	ev, err := events.Start(filepath.Join(dataDir, "nats"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(ev.Close)
	room, err := chat.New(ev.Conn(), ev.JS())
	if err != nil {
		t.Fatal(err)
	}
	odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { odb.Close() })
	for _, acct := range []struct{ name, caps string }{
		{"dan", "C"}, {"eve", "C"}, {"admin", "Ca"},
	} {
		if err := odb.AddAccount(acct.name, "pw12345678", false); err != nil {
			t.Fatal(err)
		}
		if err := odb.SetCaps(acct.name, "sqlite", acct.caps); err != nil {
			t.Fatal(err)
		}
	}
	repos := api.NewRepos(nil)
	repos.Put("sqlite", &api.Repo{Code: "sqlite"})
	capsFor := func(account, repo string) api.CapSet { return odb.EffectiveCaps(account, repo) }
	deps := api.Deps{Version: "0.0.0-harness", Repos: repos, Chat: room, Forum: odb,
		CanRead: func(_ context.Context, name string) bool { return name == "sqlite" },
		CapsFor: capsFor}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, deps); err != nil {
		t.Fatal(err)
	}
	verify := func(repo, user, pass string, need ops.Auth) api.Decision {
		if user == "" || pass != "pw12345678" {
			return api.Denied
		}
		return api.Allowed
	}
	mux, err := api.RouterWithCaps(r, "0.0.0-harness", capsFor, verify)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv, dataDir
}

type chatCaller struct {
	t    *testing.T
	srv  *httptest.Server
	user string
}

func (c chatCaller) do(method, path string, form url.Values) (int, []byte) {
	c.t.Helper()
	var body io.Reader
	if form != nil {
		body = strings.NewReader(form.Encode())
	}
	req, err := http.NewRequest(method, c.srv.URL+path, body)
	if err != nil {
		c.t.Fatal(err)
	}
	if form != nil {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}
	if c.user != "" {
		req.SetBasicAuth(c.user, "pw12345678")
	}
	resp, err := c.srv.Client().Do(req)
	if err != nil {
		c.t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	return resp.StatusCode, b
}

func (c chatCaller) post(text string, ref uint64) (uint64, int) {
	c.t.Helper()
	form := url.Values{"text": {text}}
	if ref != 0 {
		form.Set("ref", strconv.FormatUint(ref, 10))
	}
	code, body := c.do("POST", "/api/v1/r/sqlite/chat", form)
	if code != http.StatusOK {
		return 0, code
	}
	var out struct {
		Seq uint64 `json:"seq"`
	}
	json.Unmarshal(body, &out)
	return out.Seq, code
}

// Posting, disconnecting, posting twice more, then reconnecting with
// Last-Event-ID replays exactly what was missed, in order, with
// increasing ids — the "cannot lose a message" property in one flow.
func TestChatStreamNeverLosesAMessage(t *testing.T) {
	srv, _ := chatServer(t)
	dan := chatCaller{t: t, srv: srv, user: "dan"}

	seq1, code := dan.post("first", 0)
	if code != http.StatusOK || seq1 == 0 {
		t.Fatalf("post 1: code=%d seq=%d", code, seq1)
	}

	// Open a stream, immediately close it (simulating a hidden tab),
	// then post twice more while nobody is listening. Connection reuse
	// would let the first, abandoned stream's still-running handler
	// share a socket with the second request; forcing a fresh
	// connection per request is what makes "closed" actually mean
	// closed here, same as a real browser dropping the tab.
	client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
	req, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/sqlite/chat/stream", nil)
	req.SetBasicAuth("dan", "pw12345678")
	resp, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close() // close before anything is posted

	seq2, code := dan.post("second", 0)
	if code != http.StatusOK {
		t.Fatalf("post 2: code=%d", code)
	}
	seq3, code := dan.post("third", 0)
	if code != http.StatusOK {
		t.Fatalf("post 3: code=%d", code)
	}

	req2, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/sqlite/chat/stream", nil)
	req2.Header.Set("Last-Event-ID", strconv.FormatUint(seq1, 10))
	req2.SetBasicAuth("dan", "pw12345678")
	resp2, err := client.Do(req2)
	if err != nil {
		t.Fatal(err)
	}
	defer resp2.Body.Close()

	var ids []uint64
	rd := bufio.NewReader(resp2.Body)
	deadline := time.After(10 * time.Second)
	for len(ids) < 2 {
		lineCh := make(chan string, 1)
		go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
		select {
		case l := <-lineCh:
			if strings.HasPrefix(l, "id: ") {
				n, _ := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(l, "id: ")), 10, 64)
				ids = append(ids, n)
			}
		case <-deadline:
			t.Fatalf("only got ids %v, wanted seq2=%d then seq3=%d", ids, seq2, seq3)
		}
	}
	if ids[0] != seq2 || ids[1] != seq3 {
		t.Fatalf("resumed stream ids = %v, want [%d %d] in order", ids, seq2, seq3)
	}
}

// An edit inside the 5-minute window replaces the text chat.get shows;
// a delete tombstones it. Both an anonymous edit and editing someone
// else's message are refused; an "a" caller may delete anyone's.
func TestChatEditDeleteAuthzAndFold(t *testing.T) {
	srv, _ := chatServer(t)
	dan := chatCaller{t: t, srv: srv, user: "dan"}
	eve := chatCaller{t: t, srv: srv, user: "eve"}
	admin := chatCaller{t: t, srv: srv, user: "admin"}
	anon := chatCaller{t: t, srv: srv}

	seq, code := dan.post("original", 0)
	if code != http.StatusOK {
		t.Fatalf("post: %d", code)
	}

	if code, _ := anon.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/edit",
		url.Values{"text": {"hijack"}}); code == http.StatusOK {
		t.Fatal("an anonymous edit must be refused")
	}
	if code, _ := eve.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/edit",
		url.Values{"text": {"hijack"}}); code == http.StatusOK {
		t.Fatal("editing someone else's message must be refused")
	}
	if code, _ := dan.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/edit",
		url.Values{"text": {"edited"}}); code != http.StatusOK {
		t.Fatalf("own edit within the window: %d", code)
	}

	getSeq := func(seq uint64) struct {
		Message struct {
			Text    string `json:"text"`
			Edited  bool   `json:"edited"`
			Deleted bool   `json:"deleted"`
		} `json:"message"`
	} {
		t.Helper()
		// chat.get requires "C" like every other chat surface, so a
		// signed-in reader fetches it (this repo has no public-read
		// default configured); anonymous refusal is a separate concern
		// this test isn't proving.
		code, body := dan.do("GET", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10), nil)
		if code != http.StatusOK {
			t.Fatalf("chat.get: %d", code)
		}
		var out struct {
			Message struct {
				Text    string `json:"text"`
				Edited  bool   `json:"edited"`
				Deleted bool   `json:"deleted"`
			} `json:"message"`
		}
		json.Unmarshal(body, &out)
		return out
	}
	if g := getSeq(seq); g.Message.Text != "edited" || !g.Message.Edited {
		t.Fatalf("after edit: %+v", g.Message)
	}

	if code, _ := eve.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/delete", nil); code == http.StatusOK {
		t.Fatal("deleting someone else's message without 'a' must be refused")
	}
	if code, _ := admin.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/delete", nil); code != http.StatusOK {
		t.Fatalf("admin (a) delete: %d", code)
	}
	if g := getSeq(seq); !g.Message.Deleted || g.Message.Text != "" {
		t.Fatalf("after admin delete: %+v", g.Message)
	}
}

// chat.read clears the account's unread count for that repo, and a
// subsequent post raises it again — read is a comparison, not a flag.
func TestChatReadClearsAndRepostRaisesUnread(t *testing.T) {
	srv, _ := chatServer(t)
	dan := chatCaller{t: t, srv: srv, user: "dan"}
	eve := chatCaller{t: t, srv: srv, user: "eve"}

	unreadFor := func(c chatCaller) (unread int, present bool) {
		t.Helper()
		code, body := c.do("GET", "/api/v1/me/chat/unread", nil)
		if code != http.StatusOK {
			t.Fatalf("chat.unread: %d", code)
		}
		var out struct {
			Repos map[string]struct {
				Unread int `json:"unread"`
			} `json:"repos"`
		}
		json.Unmarshal(body, &out)
		r, ok := out.Repos["sqlite"]
		return r.Unread, ok
	}

	if _, code := dan.post("hello", 0); code != http.StatusOK {
		t.Fatalf("post: %d", code)
	}
	if unread, present := unreadFor(eve); !present || unread < 1 {
		t.Fatalf("eve's unread after dan's post = %d (present=%v), want >=1", unread, present)
	}
	if code, _ := eve.do("POST", "/api/v1/r/sqlite/chat/read", nil); code != http.StatusOK {
		t.Fatalf("chat.read: %d", code)
	}
	if unread, present := unreadFor(eve); present && unread > 0 {
		t.Fatalf("eve's unread after marking read = %d (present=%v), want 0/absent", unread, present)
	}
	if _, code := dan.post("again", 0); code != http.StatusOK {
		t.Fatalf("post: %d", code)
	}
	if unread, present := unreadFor(eve); !present || unread < 1 {
		t.Fatalf("a new post must re-raise unread; got %d (present=%v)", unread, present)
	}
}

// A reply's ref is recorded and comes back through chat.get's replies;
// a reply to a nonexistent seq is refused.
func TestChatReplyRecordedAndValidated(t *testing.T) {
	srv, _ := chatServer(t)
	dan := chatCaller{t: t, srv: srv, user: "dan"}

	root, code := dan.post("root message", 0)
	if code != http.StatusOK {
		t.Fatalf("post root: %d", code)
	}
	if _, code := dan.post("a reply", root); code != http.StatusOK {
		t.Fatalf("post reply: %d", code)
	}
	if _, code := dan.post("bad reply", 999999); code == http.StatusOK {
		t.Fatal("a reply to a nonexistent seq must be refused")
	}
	code, body := dan.do("GET", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(root, 10), nil)
	if code != http.StatusOK {
		t.Fatalf("chat.get: %d", code)
	}
	var out struct {
		Replies []struct {
			Text string `json:"text"`
		} `json:"replies"`
	}
	json.Unmarshal(body, &out)
	if len(out.Replies) != 1 || out.Replies[0].Text != "a reply" {
		t.Fatalf("replies = %+v", out.Replies)
	}
}