mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* refactor(secrets): lift the share-time credential rules into internal/secrets The rules only ever ran on the rarest path a file takes. Moving them out of internal/webapp is what lets internal/syncer run the same six rules on the path every file takes, without inverting the dependency. Pure move plus one addition: Label(), the six human strings that until now lived only in the frontend's SECRET_LABELS — so 'bdrive share' stops printing a bare rule id where the web dialog says 'an AWS access key'. Rule ids and the rule/line JSON tags are unchanged: Browser.tsx keys off them, so they are a wire contract. * feat(sync): warn when a synced file looks like it holds a credential The six share-time rules now run on the path every file takes. A file with an AWS key in it used to ride a normal sync to the hub, to every teammate's disk and into every future agent's context with no badge and no warning — while the Share dialog one click later blocked that exact file. Warn, never block: the op is journaled and pushed exactly as before. A hold arm would mean a false positive silently parks someone's changes, and it would break the cycle's degrade-to-offline posture. - scan() reads the blob PutBlobFile just wrote (the bytes that were actually journaled), only on the branches that wrote one — an unchanged file is still never re-read. - Findings persist per path in secrets-<mount>.json, merged rather than replaced: nearly every cycle scans zero files, and a whole-set rewrite would erase the warning seconds after it appeared. Fixing the file clears it. - bdrive status grows a secrets block; the agent hook appends one advisory sentence. Rule ids and line numbers only, never the matched bytes. - SaveSecrets failing logs and continues: advisory telemetry never gets a veto over convergence. * docs: the credential check now runs on sync, not only on share README, the CLI reference and project-files get the new bdrive status block and the warn-never-block posture, with the three limits stated (checked when it changes, first 1 MiB, writing device only). Diagrams: internal/secrets is a package of its own in the overview, secretLog joins the sync engine, and the share-gate class notes that it no longer owns the rules. * test(sync): assert an unchanged file is never re-read for credentials The check must ride the branch that already reads the file. Clearing the record by hand and cycling proves it: a scan that re-read unchanged files would put the finding back, and the daemon's 3-second tick would pay for it on every file.
527 lines
17 KiB
Go
527 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/secrets"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
)
|
|
|
|
// `bdrive sync --hook` must emit the gated-link formula as Claude Code
|
|
// hook JSON, stamp the session note, and stay a silent no-op everywhere
|
|
// else — a hook must never fail the turn.
|
|
func TestSyncHookMode(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
folder := t.TempDir()
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|
proj, err := config.SaveProject(folder, config.Project{
|
|
Volume: "wiki",
|
|
Remote: "https://hub.example.com/p/p-12345678", // unreachable: cycle degrades offline, formula still valid
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := config.EnrollMount(folder); err != nil { // enroll, as `bdrive init` would
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := syncCmd()
|
|
var out bytes.Buffer
|
|
c.SetOut(&out)
|
|
c.SetIn(strings.NewReader(`{"session_id":"sess-42","prompt":"hello"}`))
|
|
c.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c.Execute(); err != nil {
|
|
t.Fatalf("hook mode must never fail: %v", err)
|
|
}
|
|
got := out.String()
|
|
for _, want := range []string{
|
|
`"hookSpecificOutput"`,
|
|
`"hookEventName":"UserPromptSubmit"`,
|
|
"https://hub.example.com/p-12345678", // base URL: remote minus /p
|
|
"[🔗](", // the emoji-link convention
|
|
"code blocks", // paths in code blocks stay plain
|
|
"PUBLIC", // bdrive share stays opt-in
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// The session note was stamped for the daemon's follow-up scans.
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.Open(vdir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if note := st.LoadNote(); note != "claude-code session sess-42" {
|
|
t.Errorf("note = %q, want the stamped session", note)
|
|
}
|
|
}
|
|
|
|
func TestSyncHookModeNoOps(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
|
|
// Not a mount: silent success, no output.
|
|
c := syncCmd()
|
|
var out bytes.Buffer
|
|
c.SetOut(&out)
|
|
c.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
|
c.SetArgs([]string{t.TempDir(), "--hook", "claude-code"})
|
|
if err := c.Execute(); err != nil {
|
|
t.Fatalf("non-mount must be a silent no-op: %v", err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatalf("non-mount emitted output: %s", out.String())
|
|
}
|
|
|
|
// A config.json that arrived with the folder (git clone, copied dir)
|
|
// but was never enrolled on this device via `bdrive init`: silent no-op,
|
|
// and — crucially — no device enrollment as a side effect.
|
|
folder := t.TempDir()
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|
proj, err := config.SaveProject(folder, config.Project{
|
|
Volume: "wiki", Remote: "https://hub.example.com/p/p-12345678",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c2 := syncCmd()
|
|
out.Reset()
|
|
c2.SetOut(&out)
|
|
c2.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
|
c2.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c2.Execute(); err != nil {
|
|
t.Fatalf("unenrolled mount must be a silent no-op: %v", err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatalf("unenrolled mount emitted output: %s", out.String())
|
|
}
|
|
mounts, err := config.LoadMounts()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, enrolled := mounts[proj.ID]; enrolled {
|
|
t.Fatal("hook auto-enrolled the mount; only `bdrive init` may do that")
|
|
}
|
|
|
|
// Enrolled but paused by `bdrive stop`: silent no-op too.
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.SetPaused(vdir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c3 := syncCmd()
|
|
out.Reset()
|
|
c3.SetOut(&out)
|
|
c3.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
|
c3.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c3.Execute(); err != nil {
|
|
t.Fatalf("paused mount must be a silent no-op: %v", err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatalf("paused mount emitted output: %s", out.String())
|
|
}
|
|
|
|
// Garbage stdin on a live mount: still sync, still emit, never fail.
|
|
if err := store.SetPaused(vdir, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c4 := syncCmd()
|
|
out.Reset()
|
|
c4.SetOut(&out)
|
|
c4.SetIn(strings.NewReader("not json at all"))
|
|
c4.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c4.Execute(); err != nil {
|
|
t.Fatalf("garbage stdin must not fail: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), `"hookSpecificOutput"`) {
|
|
t.Fatalf("formula not emitted on garbage stdin: %s", out.String())
|
|
}
|
|
}
|
|
|
|
// mountAt creates a project folder under parent and enrolls it on this
|
|
// device, as `bdrive init` would.
|
|
func mountAt(t *testing.T, parent, name, remote string) config.Project {
|
|
t.Helper()
|
|
dir := filepath.Join(parent, name)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proj, err := config.SaveProject(dir, config.Project{Volume: name, Remote: remote})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := config.EnrollMount(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return proj
|
|
}
|
|
|
|
func runHook(t *testing.T, folder string) string {
|
|
t.Helper()
|
|
c := syncCmd()
|
|
var out bytes.Buffer
|
|
c.SetOut(&out)
|
|
c.SetIn(strings.NewReader(`{"session_id":"sess-42"}`))
|
|
c.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c.Execute(); err != nil {
|
|
t.Fatalf("hook mode must never fail: %v", err)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// A session at a root whose subfolders are separate projects must get EVERY
|
|
// project's URL, each keyed by the prefix the agent sees — emitting only the
|
|
// first mount's base made agents hang one project's paths on another
|
|
// project's URL.
|
|
func TestSyncHookModeMultipleMounts(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
|
|
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
|
|
|
|
got := runHook(t, root)
|
|
|
|
// One JSON object: the hook's stdout contract.
|
|
if n := strings.Count(strings.TrimSpace(got), "\n"); n != 0 {
|
|
t.Fatalf("hook emitted %d objects, want 1:\n%s", n+1, got)
|
|
}
|
|
for _, want := range []string{
|
|
"https://hub.example.com/p-aaaaaaaa",
|
|
"https://hub.example.com/p-bbbbbbbb",
|
|
"`projA/`",
|
|
"`projB/`",
|
|
"matches the path longest", // how to pick between them
|
|
"do not link it", // a path in neither is not synced
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// stdin is consumed once, so the note must still reach every mount.
|
|
for _, proj := range []config.Project{a, b} {
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.Open(vdir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if note := st.LoadNote(); note != "claude-code session sess-42" {
|
|
t.Errorf("%s: note = %q, want the stamped session", proj.Volume, note)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A mount that has no hub URL must not swallow the context for the mounts
|
|
// that do — the old "first mount emits" guard could never detect a mount
|
|
// that emitted nothing, because hook mode never returns an error.
|
|
func TestSyncHookModeSkipsNonHubMount(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
mountAt(t, root, "a-plain", "file://"+t.TempDir()) // sorts first, no hub
|
|
mountAt(t, root, "b-hub", "https://hub.example.com/p/p-bbbbbbbb")
|
|
|
|
got := runHook(t, root)
|
|
if !strings.Contains(got, "https://hub.example.com/p-bbbbbbbb") {
|
|
t.Errorf("hub mount lost its link behind a non-hub mount:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "`b-hub/`") {
|
|
t.Errorf("hub mount missing its prefix:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "a-plain") {
|
|
t.Errorf("non-hub mount has no URL and must not be listed:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// A session started inside a mount writes paths relative to its own
|
|
// directory, so that subpath belongs in the base URL — there is no prefix
|
|
// for the agent to strip.
|
|
func TestSyncHookModeInsideMount(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
sub := filepath.Join(root, "wiki", "docs", "notes")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := runHook(t, sub)
|
|
if !strings.Contains(got, "https://hub.example.com/p-12345678/docs/notes") {
|
|
t.Errorf("base URL missing the session's subpath (and `/` must stay literal):\n%s", got)
|
|
}
|
|
}
|
|
|
|
// Plain `bdrive sync` (the push hook's form, and what users type) refuses
|
|
// unenrolled and paused mounts with instructions instead of silently
|
|
// enrolling or resuming.
|
|
func TestSyncRefusesUnenrolledAndPaused(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
folder := t.TempDir()
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := syncCmd()
|
|
c.SetArgs([]string{folder})
|
|
err = c.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "bdrive init") {
|
|
t.Fatalf("unenrolled sync error = %v, want a `bdrive init` pointer", err)
|
|
}
|
|
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.SetPaused(vdir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c2 := syncCmd()
|
|
c2.SetArgs([]string{folder})
|
|
err = c2.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), "paused") {
|
|
t.Fatalf("paused sync error = %v, want a paused message", err)
|
|
}
|
|
}
|
|
|
|
// seedInbound pretends an earlier cycle — the daemon's, in the ordinary case
|
|
// — materialized these paths on this mount.
|
|
func seedInbound(t *testing.T, proj config.Project, paths ...string) {
|
|
t.Helper()
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.Open(vdir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range paths {
|
|
deleted := strings.HasPrefix(p, "-")
|
|
if err := st.LogInbound(strings.TrimPrefix(p, "-"), deleted); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The whole point of the spool: a path materialized by an EARLIER cycle (the
|
|
// daemon's) is still reported by the hook, whose own cycle sees nothing. A
|
|
// Result field would report nothing here.
|
|
func TestSyncHookModeReportsInboundChanges(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
|
|
seedInbound(t, proj, "notes/readme.md", "-old.md")
|
|
|
|
got := runHook(t, filepath.Join(root, "wiki"))
|
|
for _, want := range []string{
|
|
"re-read before editing",
|
|
"`notes/readme.md`",
|
|
"`old.md (deleted)`",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// The drain cleared: a second run with no peer activity says nothing.
|
|
if again := runHook(t, filepath.Join(root, "wiki")); strings.Contains(again, "re-read before editing") {
|
|
t.Errorf("second run repeated the changed list:\n%s", again)
|
|
}
|
|
}
|
|
|
|
// Each path carries its own mount's prefix — never another mount's.
|
|
func TestSyncHookModeInboundMultipleMounts(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
|
|
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
|
|
seedInbound(t, a, "notes/a.md")
|
|
seedInbound(t, b, "notes/b.md")
|
|
|
|
got := runHook(t, root)
|
|
for _, want := range []string{"`projA/notes/a.md`", "`projB/notes/b.md`"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A session started inside a mount sees paths relative to its own directory:
|
|
// its subpath is stripped, and a sibling folder's file is not its to re-read.
|
|
func TestSyncHookModeInboundInsideMount(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
sub := filepath.Join(root, "wiki", "docs", "notes")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedInbound(t, proj, "docs/notes/mine.md", "elsewhere/theirs.md")
|
|
|
|
got := runHook(t, sub)
|
|
if !strings.Contains(got, "`mine.md`") {
|
|
t.Errorf("path under the session folder not stripped to what the agent sees:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "theirs.md") {
|
|
t.Errorf("path outside the session folder must not be listed:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// The first cycle on a fresh mount materializes everything; the turn must not
|
|
// carry the whole project.
|
|
func TestSyncHookModeInboundCap(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
var paths []string
|
|
for i := 0; i < hookChangedMax+5; i++ {
|
|
paths = append(paths, fmt.Sprintf("f%02d.md", i))
|
|
}
|
|
seedInbound(t, proj, paths...)
|
|
|
|
got := runHook(t, filepath.Join(root, "wiki"))
|
|
if !strings.Contains(got, "+5 more") {
|
|
t.Errorf("capped list missing its tail:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "f24.md") {
|
|
t.Errorf("list rendered past the cap:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// An unreadable spool leaves the turn intact: exit 0, valid JSON, links still
|
|
// emitted.
|
|
func TestSyncHookModeInboundSpoolUnreadable(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A directory where the spool should be: every read of it fails.
|
|
if err := os.MkdirAll(filepath.Join(vdir, "inbound.jsonl"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := runHook(t, filepath.Join(root, "wiki"))
|
|
if !strings.Contains(got, `"hookSpecificOutput"`) || !strings.Contains(got, "https://hub.example.com/p-12345678") {
|
|
t.Errorf("unreadable spool broke the turn's context:\n%s", got)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
|
t.Fatalf("hook emitted invalid JSON: %v\n%s", err, got)
|
|
}
|
|
}
|
|
|
|
func seedSecrets(t *testing.T, proj config.Project, found map[string][]secrets.Finding) {
|
|
t.Helper()
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.Open(vdir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SaveSecrets(proj.ID, found); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// The credential warning reaches the agent, whose own cycle found nothing:
|
|
// the daemon scanned that write seconds ago, so — exactly like the inbound
|
|
// spool — the record is what carries it. Advisory: the sentence says the file
|
|
// has ALREADY synced, because it has.
|
|
func TestSyncHookModeReportsSecrets(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
seedSecrets(t, proj, map[string][]secrets.Finding{
|
|
"deploy.md": {{Rule: "aws_access_key_id", Line: 12}},
|
|
})
|
|
|
|
got := runHook(t, filepath.Join(root, "wiki"))
|
|
for _, want := range []string{
|
|
"looked like they contain credentials when they last changed",
|
|
"`deploy.md` (an AWS access key, line 12)",
|
|
"tell the user",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
// Unlike the inbound spool, findings are state and are NOT drained: the
|
|
// file still holds the key on the next turn, so the next turn still says so.
|
|
if again := runHook(t, filepath.Join(root, "wiki")); !strings.Contains(again, "`deploy.md`") {
|
|
t.Errorf("the warning vanished after one turn:\n%s", again)
|
|
}
|
|
}
|
|
|
|
// Every path carries its own mount's prefix — a credential in one project must
|
|
// never be reported as a path in another.
|
|
func TestSyncHookModeSecretsMultipleMounts(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
|
|
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
|
|
seedSecrets(t, a, map[string][]secrets.Finding{"a.md": {{Rule: "private_key", Line: 1}}})
|
|
seedSecrets(t, b, map[string][]secrets.Finding{"b.md": {{Rule: "slack_token", Line: 2}}})
|
|
|
|
got := runHook(t, root)
|
|
for _, want := range []string{"`projA/a.md` (a private key, line 1)", "`projB/b.md` (a Slack token, line 2)"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The empty case is the common one, paid on every turn of every session: the
|
|
// context must be byte-identical to what it was before this check existed.
|
|
func TestSyncHookModeNoSecretsSaysNothing(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
root := t.TempDir()
|
|
root, _ = filepath.EvalSymlinks(root)
|
|
mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
|
|
|
got := runHook(t, filepath.Join(root, "wiki"))
|
|
for _, unwanted := range []string{"credential", "secret"} {
|
|
if strings.Contains(strings.ToLower(got), unwanted) {
|
|
t.Errorf("a clean mount mentions %q on every turn:\n%s", unwanted, got)
|
|
}
|
|
}
|
|
}
|