package oltp
import (
"errors"
"path/filepath"
"strings"
"testing"
"time"
)
func bridgeDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
// The credential goes in and is readable only by the poller's own
// accessor; nothing a listing returns carries it.
func TestChatBridgeKeepsTheSecretOffEveryListing(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://example.org/repo", "bot", "s3cret"); err != nil {
t.Fatal(err)
}
got, ok, err := db.ChatBridgeFor("alpha")
if err != nil || !ok {
t.Fatalf("bridge not stored: ok=%v err=%v", ok, err)
}
if got.URL != "https://example.org/repo" || got.User != "bot" {
t.Fatalf("bridge = %+v", got)
}
secret, ok := db.ChatBridgeSecret("alpha")
if !ok || secret != "s3cret" {
t.Fatalf("the poller cannot read the credential: %q %v", secret, ok)
}
list, err := db.ChatBridges()
if err != nil || len(list) != 1 || list[0].Repo != "alpha" {
t.Fatalf("bridges = %+v (%v)", list, err)
}
}
// Only http(s), the rule every URL quarry fetches on its own
// initiative lives under.
func TestChatBridgeRefusesAForeignScheme(t *testing.T) {
db := bridgeDB(t)
for _, bad := range []string{"file:///etc/passwd", "ssh://host/repo", "", "notaurl"} {
if err := db.SetChatBridge("alpha", bad, "bot", "pw"); err == nil {
t.Fatalf("%q was accepted as a bridge url", bad)
}
}
if err := db.SetChatBridge("alpha", "https://x.example/r", "", "pw"); err == nil {
t.Fatal("a bridge with no login was accepted")
}
}
// Reconfiguring keeps the cursor when the upstream is the same and
// resets it when it is not: msgids belong to the server that minted
// them, and replaying another's numbering imports the wrong messages.
func TestChatBridgeCursorFollowsTheUpstream(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw"); err != nil {
t.Fatal(err)
}
if err := db.SetChatBridgeCursor("alpha", 42, time.Now()); err != nil {
t.Fatal(err)
}
// Same URL, new password: the cursor stands.
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw2"); err != nil {
t.Fatal(err)
}
if b, _, _ := db.ChatBridgeFor("alpha"); b.LastMsgID != 42 {
t.Fatalf("fixing a password reset the cursor to %d", b.LastMsgID)
}
// New URL: a different fossil, a different numbering.
if err := db.SetChatBridge("alpha", "https://b.example/r", "bot", "pw2"); err != nil {
t.Fatal(err)
}
if b, _, _ := db.ChatBridgeFor("alpha"); b.LastMsgID != 0 {
t.Fatalf("a new upstream kept the old cursor: %d", b.LastMsgID)
}
}
// A failure is remembered without moving the cursor — a failed poll
// read nothing — and the next message clears it.
func TestChatBridgeErrorDoesNotAdvanceTheCursor(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw"); err != nil {
t.Fatal(err)
}
if err := db.SetChatBridgeCursor("alpha", 7, time.Now()); err != nil {
t.Fatal(err)
}
if err := db.RecordChatBridgeError("alpha", errors.New("upstream is down")); err != nil {
t.Fatal(err)
}
b, _, _ := db.ChatBridgeFor("alpha")
if b.LastMsgID != 7 || b.LastErr != "upstream is down" {
t.Fatalf("after a failure: %+v", b)
}
if err := db.SetChatBridgeCursor("alpha", 8, time.Now()); err != nil {
t.Fatal(err)
}
if b, _, _ := db.ChatBridgeFor("alpha"); b.LastErr != "" || b.LastMsgID != 8 {
t.Fatalf("a message did not clear the error: %+v", b)
}
}
// The msgid map answers what a later mdel needs, prunes with the
// window, and dies with the bridge — a re-attach must not tombstone a
// quarry message on a number the new upstream reused.
func TestChatBridgeMsgMapLivesAndDiesWithTheBridge(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw"); err != nil {
t.Fatal(err)
}
for _, m := range []struct {
msgid int64
seq uint64
}{{1, 10}, {2, 20}, {3, 30}} {
if err := db.MapChatBridgeMsg("alpha", m.msgid, m.seq); err != nil {
t.Fatal(err)
}
}
if seq, ok := db.ChatBridgeSeq("alpha", 2); !ok || seq != 20 {
t.Fatalf("msgid 2 = %d %v, want 20", seq, ok)
}
// Idempotent: a replayed poll must not double anything.
if err := db.MapChatBridgeMsg("alpha", 2, 999); err != nil {
t.Fatal(err)
}
if seq, _ := db.ChatBridgeSeq("alpha", 2); seq != 20 {
t.Fatalf("a replay rewrote the mapping to %d", seq)
}
if err := db.PruneChatBridgeMsgs("alpha", 25); err != nil {
t.Fatal(err)
}
if _, ok := db.ChatBridgeSeq("alpha", 1); ok {
t.Fatal("a row outside the window survived the prune")
}
if _, ok := db.ChatBridgeSeq("alpha", 3); !ok {
t.Fatal("the prune took a row still inside the window")
}
if err := db.DetachChatBridge("alpha"); err != nil {
t.Fatal(err)
}
if _, ok, _ := db.ChatBridgeFor("alpha"); ok {
t.Fatal("detach left the bridge configured")
}
if _, ok := db.ChatBridgeSeq("alpha", 3); ok {
t.Fatal("detach left the msgid map behind")
}
}
// Purging a repository takes its bridge and map with it, like every
// other repo-keyed table.
func TestPurgeRepoTakesTheChatBridge(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw"); err != nil {
t.Fatal(err)
}
if err := db.MapChatBridgeMsg("alpha", 1, 10); err != nil {
t.Fatal(err)
}
if err := db.PurgeRepo("alpha"); err != nil {
t.Fatal(err)
}
if _, ok, _ := db.ChatBridgeFor("alpha"); ok {
t.Fatal("the bridge survived a repository purge")
}
if _, ok := db.ChatBridgeSeq("alpha", 1); ok {
t.Fatal("the msgid map survived a repository purge")
}
}
// A changed upstream invalidates the msgid map as well as the cursor.
// Msgids belong to the fossil that minted them, so a map carried
// across would let a new server's msgid tombstone a quarry message the
// old server's msgid had claimed — the hazard DetachChatBridge already
// handles, reached by reconfiguring instead of detaching.
func TestChatBridgeURLChangeClearsTheMsgMap(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw"); err != nil {
t.Fatal(err)
}
if err := db.MapChatBridgeMsg("alpha", 1, 10); err != nil {
t.Fatal(err)
}
// Same upstream: the map is still about the right server.
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw2"); err != nil {
t.Fatal(err)
}
if _, ok := db.ChatBridgeSeq("alpha", 1); !ok {
t.Fatal("changing the password dropped the msgid map")
}
if err := db.SetChatBridge("alpha", "https://b.example/r", "bot", "pw2"); err != nil {
t.Fatal(err)
}
if _, ok := db.ChatBridgeSeq("alpha", 1); ok {
t.Fatal("a new upstream kept the old server's msgid map")
}
}
// Credentials belong in the columns the poller reads, not in a URL
// that every log line and status row would carry.
func TestChatBridgeRefusesCredentialsInTheURL(t *testing.T) {
db := bridgeDB(t)
for _, bad := range []string{
"https://bot:[email protected]/r",
"https://[email protected]/r",
} {
if err := db.SetChatBridge("alpha", bad, "bot", "pw"); err == nil {
t.Fatalf("%q was accepted", bad)
}
}
}
// A stored error is rendered on an admin page, so any URL inside it
// arrives without its credentials — stdlib transport errors quote the
// whole request URL.
func TestRecordChatBridgeErrorRedactsCredentials(t *testing.T) {
db := bridgeDB(t)
if err := db.SetChatBridge("alpha", "https://a.example/r", "bot", "pw"); err != nil {
t.Fatal(err)
}
if err := db.RecordChatBridgeError("alpha",
errors.New(`Get "https://bot:[email protected]/r/chat-poll": dial tcp: refused`)); err != nil {
t.Fatal(err)
}
b, _, _ := db.ChatBridgeFor("alpha")
if strings.Contains(b.LastErr, "hunter2") || strings.Contains(b.LastErr, "bot:") {
t.Fatalf("the stored error carries the credential: %q", b.LastErr)
}
if !strings.Contains(b.LastErr, "https://a.example/r/chat-poll") {
t.Fatalf("redaction took the host with it: %q", b.LastErr)
}
if !strings.Contains(b.LastErr, "dial tcp: refused") {
t.Fatalf("redaction took the reason with it: %q", b.LastErr)
}
}