Sitelet https://github.com/basecamp/basecamp-cli/pull/822
Skip to content

Add a fake Basecamp for testing basecamp connect end to end - #822

Open
robzolkos wants to merge 2 commits into
mainfrom
connect-e2e/fake
Open

robzolkos wants to merge 2 commits into
mainfrom
connect-e2e/fake

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

PR 0 of a stack of 8 that tests basecamp connect end to end. This one adds the fake Basecamp the others run against. It changes no behaviour of the CLI.

Why

basecamp connect has good unit tests, but nothing runs the real binary against a Basecamp that speaks both lanes of the event feed. Each command test brings its own small mock (connectSetupServer, connectAS, the feed servers in the connector tests), and none of them has a cable. So a connector that starts, connects, admits and prints a request line has never been tested as one process.

The later PRs need one fake that can:

  • serve a real basecamp connect subprocess: the agent connection ceremony, client_credentials tokens, the account reads, the stream ticket, the poll lane and the Action Cable socket, and the recording reads admission makes;
  • say exactly what each lane serves, so a catch-up, a gap or a duplicate is a deterministic scenario and not a race;
  • break on purpose: token 429 / 5xx / invalid_client, a dropped cable, silent pings, reads that fail N times, reads held open;
  • express every knob of connectSetupServer, so PR 1 can move those tests onto it and delete the mock.

What

internal/connector/fakebasecamp, a normal package (not a test file) so command tests, connector tests and a subprocess harness can all share it. It takes a small Reporter interface (the part of testing.TB it needs), so it imports no test library.

  • One world, one lock. A World holds the account, people, projects, recordings, the agents' OAuth clients and the bearer tokens. Every request is answered from it under one mutex, and tests change it with Update. Access follows Basecamp: a token names a person, and a person sees what their projects hold. Anything else answers 404, on the reads and on both lanes.
  • Lanes are published explicitly. Emit(ev, Live|Poll|Both) puts an event on the lanes named, and Publish adds an emitted event to another lane later. There is no clock and no lag. A live event is queued to each matching subscription under the world's lock, so it is ordered against subscription confirms. Socket writes happen outside the lock, one writer per connection.
  • The poll lane follows the contract eventfeed checks: events always present, ascending ids, an opaque position, and a next URL that repeats the request's own filters. A page walks a fixed number of rows, so a page can be empty while next says there is more.
  • The cable follows eventfeed's own loopback server: the actioncable-v1-json subprotocol, welcome, confirm or reject, a ping every three seconds, and a ws:// URL on the same loopback port.
  • The connection ceremony hands over the approved client under a freshly rotated secret, as Basecamp does, and spends the code. Minting checks the secret, so a secret rotated away is invalid_client with no fault needed.
  • Faults and waits. Inject answers a route with any status or body, for N requests or until removed, optionally only for some callers (ByAgents). Gate holds a route's requests open until Release. Before runs a function as a route is reached. DropCable and StopPings act on the live connections. Every request is logged, and Await waits for a condition without sleeping: it re-checks after every change.
  • Strict about routes. A request no route serves is answered 404 and reported through the Reporter, so a client that starts calling something the fake does not know fails where it is seen.

go run ./e2e/fakebasecamp serves the default world on a loopback port. It prints the environment and the three commands that connect, set up and run the agent, and prints each request as it is answered. Typing mention posts a comment that mentions the agent on both lanes, and drop severs the cable. I used it to run auth agent connect, connect setup and connect by hand. A mention came out as a request line on stdout.

Dependencies. github.com/coder/websocket moves from an indirect entry in go.sum to a direct requirement. The SDK already uses it for this same purpose in its tests. The Nix vendorHash is updated with make update-nix-hash, and that build was verified.

Tests

The fake is held to the real clients, not to itself:

  • eventfeed in process. eventfeed.NewLive with the connector's own filters, over its real seams and WebSocket transport:
    • a catch-up walk across three pages, one of them emptied by the filters, with the exact since / position / filter queries asserted;
    • an event on both lanes, emitted while the walk is held at a gate, is delivered once;
    • a cable dropped while the reconnect is held at its mint delivers the missed event through the catch-up.
  • The cable by hand. welcome, confirm, reject of a second subscription, the push frame's exact members, filters and project visibility on the live lane, pings, and silence after StopPings.
  • The connection ceremony through internal/auth. ConnectAgent with the production HTTP clients, the secret rotation, a second connection invalidating the first secret, a spent code refused, and a read-only approval.
  • The reads through the SDK. profile (with boss), people (client, readable only by some, unknown), projects, project people, authorization.json, and Recordings().Summarize for message, comment, to-do, card and chat line, each finding the agent's mention. Also subscriptions and the recording history that admission reads.
  • Faults. token 429 with Retry-After: 1, 503 and invalid_client, faults that lapse after N, faults only for agents, a 200 override, gates, hooks, the poll lane's 400s, and unrouted requests being reported.

Every wait is an Await or a channel with a deadline, never a sleep. The package passes go test -race -count=20 and -race -count=10 -cpu=1,2. bin/ci is green.

Next in the stack

  1. Move connect_setup_test.go onto the fake and delete connectSetupServer.
  2. The subprocess harness and the happy path.
  3. Failure scenarios that need no new knobs.
  4. Queue callbacks in runConnect, with the held-reads backlog test.
  5. The handoff grace knob, from the dev build tag.
  6. A dev BC3 target running the same scenarios.
  7. The lock message, docs, skill and evals.

Summary by cubic

Adds a fake Basecamp server for end-to-end testing of basecamp connect: the agent connection ceremony, the account reads, and both lanes of the event feed. It changes no CLI behavior.

Faking

  • One world under one lock answers every request; tests change it through Update.
  • Events are published to the live, poll, or both lanes explicitly, so catch-ups, gaps, and duplicates are deterministic scenarios.
  • Inject faults a route with any status or body, for N requests, optionally scoped to agents; Gate holds requests open, and DropCable / StopPings act on live connections.
  • Faults are also readable: Await re-checks after every change and returns ErrClosed once the fake closes.

Real clients

  • Held to eventfeed's live connector, internal/auth's connection ceremony, and the SDK's reads.
  • Stream tickets expire after their 120-second lifetime and stay replayable within it; the client_credentials mint refuses a scope wider than the client's approval.
  • go run ./e2e/fakebasecamp serves it interactively; coder/websocket becomes a direct dependency and the Nix vendorHash is updated.

Written for commit b520ac6. Summary will update on new commits.

Review in cubic

internal/connector/fakebasecamp serves, on a loopback port, everything
`basecamp connect` talks to: the agent connection ceremony and its
client_credentials tokens, the account reads setup and admission make,
the stream ticket, the poll lane and the Action Cable socket. One world
under one lock answers every request; events are published per lane
explicitly, so catch-ups, gaps and duplicates are deterministic; and
faults (token 429/5xx/invalid_client, dropped cables, silent pings,
failing or held reads) are injected per route.

It is held to the real clients: eventfeed's live connector in process,
internal/auth's connection ceremony, and the SDK's reads. `go run
./e2e/fakebasecamp` serves it by hand.

coder/websocket becomes a direct dependency (the SDK already uses it for
its own loopback cable server), and the Nix vendorHash is updated.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 17:32
@github-actions github-actions Bot added tests Tests (unit and e2e) deps labels Oct 2, 2026
Comment thread internal/connector/fakebasecamp/server.go Fixed
Comment thread e2e/fakebasecamp/main.go

func (*logReporter) Helper() {}

func (*logReporter) Errorf(format string, args ...any) { log.Printf(format, args...) }

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved correctness and reliability issues in the fake server and test harness prevent approval.

Review effort: Balanced
Findings: 3 High severity · 3 Medium severity

Open (6)
What changed in this PR

Adds a shared fake Basecamp for future end-to-end basecamp connect tests, without changing CLI behavior.

Changes:

  • Implements authentication, account reads, polling, and Action Cable with deterministic scenario controls.
  • Adds real-client contract tests and an interactive manual runner.
  • Makes WebSocket support a direct dependency and updates the Nix vendor hash.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
nix/​package.nix Updates the vendor hash.
internal/​connector/​fakebasecamp/​world.go Defines shared fixtures and state.
internal/​connector/​fakebasecamp/​server.go Adds routing, faults, gates, and waits.
internal/​connector/​fakebasecamp/​recordings.go Serves recording reads and history.
internal/​connector/​fakebasecamp/​reads_test.go Tests reads through the SDK.
internal/​connector/​fakebasecamp/​oauth.go Implements agent connection and token minting.
internal/​connector/​fakebasecamp/​helpers_test.go Provides shared test helpers.
internal/​connector/​fakebasecamp/​feed.go Implements event publishing and polling.
internal/​connector/​fakebasecamp/​feed_test.go Tests catch-up, deduplication, and reconnects.
internal/​connector/​fakebasecamp/​faults_test.go Tests faults, gates, and routing contracts.
internal/​connector/​fakebasecamp/​doc.go Documents the fake's design and controls.
internal/​connector/​fakebasecamp/​connect_test.go Tests the production authentication ceremony.
internal/​connector/​fakebasecamp/​cable.go Implements ticketed Action Cable connections.
internal/​connector/​fakebasecamp/​cable_test.go Tests cable frames and connection controls.
internal/​connector/​fakebasecamp/​account.go Serves account, person, and project reads.
go.mod Adds the direct WebSocket dependency.
e2e/​fakebasecamp/​main.go Adds the interactive manual runner.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/connector/fakebasecamp/oauth.go
newest := slices.Clone(r.Events)
slices.Reverse(newest)
out := []map[string]any{}
for i := (page - 1) * recordingEventsPage; i < len(newest) && i < page*recordingEventsPage; i++ {
Comment on lines +138 to +140
for _, opt := range opts {
opt(&o)
}
Comment thread e2e/fakebasecamp/main.go

fmt.Printf(`fake Basecamp at %[1]s

export BASECAMP_BASE_URL=%[1]s BASECAMP_OAUTH_ISSUER=%[1]s BASECAMP_NO_KEYRING=1
Comment on lines +92 to +104
answered := make(chan int, 1)
go func() {
status, _, _ := do(t, s, http.MethodGet, "/999/projects.json", bearer, "")
answered <- status
}()
await(t, s, "the held request", func() bool { return gate.Waiting() == 1 })
requests := s.Requests()
require.Len(t, requests, 1)
assert.Zero(t, requests[0].Status, "held, not answered")
assert.Equal(t, fakebasecamp.AgentID, requests[0].Caller)
assert.True(t, requests[0].Agent)
gate.Release()
assert.Equal(t, http.StatusOK, <-answered)
s.pushLocked(ev)
}
s.notifyLocked()
return ev

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

14 issues found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/connector/fakebasecamp/faults_test.go">

<violation number="1" location="internal/connector/fakebasecamp/faults_test.go:94">
P2: The worker calls `do`, whose `require.NoError` exits only that goroutine on request errors, so the test blocks forever waiting on `answered`. Return the request error through the channel and assert from the test goroutine.</violation>

<violation number="2" location="internal/connector/fakebasecamp/faults_test.go:151">
P2: This substring check passes even if `performed_by_id` appears at the wrong JSON level, so it does not validate the event shape. Decode the event JSON and assert the field's presence and value.

(Based on your team's feedback about structured JSON assertions.) .</violation>
</file>

<file name="internal/connector/fakebasecamp/reads_test.go">

<violation number="1" location="internal/connector/fakebasecamp/reads_test.go:36">
P2: These JSON assertions search raw text, so they can pass when `boss` or `identity.id` is missing from its expected location. Decode each response and assert the structured fields instead.

(Based on your team's feedback about structured JSON assertions.)</violation>
</file>

<file name="internal/connector/fakebasecamp/doc.go">

<violation number="1" location="internal/connector/fakebasecamp/doc.go:23">
P3: Nonmembers do not get 404s from the feed lanes: the poll endpoint omits inaccessible events, and the cable skips them. Distinguish those behaviors from direct reads so tests do not rely on the wrong contract.</violation>
</file>

<file name="internal/connector/fakebasecamp/feed.go">

<violation number="1" location="internal/connector/fakebasecamp/feed.go:331">
P2: A valid cursor of `9223372036854775807` overflows `cursor+1`, so the search restarts at the first event and replays older events. Search for the first event whose ID is greater than `cursor` without incrementing it.</violation>

<violation number="2" location="internal/connector/fakebasecamp/feed.go:332">
P2: `WithPageSize(0)` leaves `end == start`, so every `next` URL repeats the same cursor forever; a negative value instead panics when slicing `poll[start:end]`. Clamp or reject non-positive sizes before computing the end.</violation>
</file>

<file name="internal/connector/fakebasecamp/oauth.go">

<violation number="1" location="internal/connector/fakebasecamp/oauth.go:93">
P2: Invalid client credentials receive malformed JSON: `oauthError` emits literal backslashes before the property quotes, so OAuth clients cannot decode the error. Format a valid JSON object in `oauthError` instead.</violation>
</file>

<file name="internal/connector/fakebasecamp/recordings.go">

<violation number="1" location="internal/connector/fakebasecamp/recordings.go:73">
P2: A visible recording can expose a parent title and URL from a project the caller cannot access. Include the parent only when the caller is a member of its bucket.</violation>

<violation number="2" location="internal/connector/fakebasecamp/recordings.go:193">
P2: A large positive `page` overflows these multiplications, making the loop index negative and panicking at `newest[i]`. Iterate over the slice and select entries by page, or reject pages beyond the available history before multiplying.</violation>
</file>

<file name="internal/connector/fakebasecamp/account.go">

<violation number="1" location="internal/connector/fakebasecamp/account.go:215">
P2: `projectPeople()` returns a restricted member's full profile even when `person()` would deny that caller with 403. Apply the same `ReadableBy` check before rendering roster entries so the roster cannot bypass the person-read access rule.</violation>
</file>

<file name="internal/connector/fakebasecamp/server.go">

<violation number="1" location="internal/connector/fakebasecamp/server.go:92">
P2: A zero page size leaves the poll position unchanged, while a negative size makes the page slice panic; catch-up can loop forever or abort. Clamp this option to at least one.</violation>

<violation number="2" location="internal/connector/fakebasecamp/server.go:97">
P2: `WithPingInterval(0)` and negative values reach `time.NewTicker` in the cable goroutine and panic when a cable opens. Reject nonpositive intervals while applying options.</violation>

<violation number="3" location="internal/connector/fakebasecamp/server.go:173">
P2: A concurrent second `Close` returns as soon as it sees `closed`, before the first call finishes `srv.Close()` and `cables.Wait()`. Make later callers wait for shutdown completion.</violation>
</file>

<file name="e2e/fakebasecamp/main.go">

<violation number="1" location="e2e/fakebasecamp/main.go:137">
P2: Quote the formatted message before logging so control characters in a request path cannot forge extra log entries.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

page, body := poll("since=0&exclude_performers=self")
require.Len(t, page.Events, 1)
assert.Equal(t, first.ID, page.Events[0].ID)
assert.Contains(t, body, `"performed_by_id":null`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This substring check passes even if performed_by_id appears at the wrong JSON level, so it does not validate the event shape. Decode the event JSON and assert the field's presence and value.

(Based on your team's feedback about structured JSON assertions.) .

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/faults_test.go, line 151:

<comment>This substring check passes even if `performed_by_id` appears at the wrong JSON level, so it does not validate the event shape. Decode the event JSON and assert the field's presence and value.

(Based on your team's feedback about structured JSON assertions.) .</comment>

<file context>
@@ -0,0 +1,181 @@
+	page, body := poll("since=0&exclude_performers=self")
+	require.Len(t, page.Events, 1)
+	assert.Equal(t, first.ID, page.Events[0].ID)
+	assert.Contains(t, body, `"performed_by_id":null`)
+	assert.Equal(t, s.url()+"/999/events.json?exclude_performers=self&position=fake-1", page.Next)
+
</file context>

gate := s.Gate(fakebasecamp.RouteProjects)
answered := make(chan int, 1)
go func() {
status, _, _ := do(t, s, http.MethodGet, "/999/projects.json", bearer, "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The worker calls do, whose require.NoError exits only that goroutine on request errors, so the test blocks forever waiting on answered. Return the request error through the channel and assert from the test goroutine.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/faults_test.go, line 94:

<comment>The worker calls `do`, whose `require.NoError` exits only that goroutine on request errors, so the test blocks forever waiting on `answered`. Return the request error through the channel and assert from the test goroutine.</comment>

<file context>
@@ -0,0 +1,181 @@
+	gate := s.Gate(fakebasecamp.RouteProjects)
+	answered := make(chan int, 1)
+	go func() {
+		status, _, _ := do(t, s, http.MethodGet, "/999/projects.json", bearer, "")
+		answered <- status
+	}()
</file context>

assert.True(t, ok)
assert.Equal(t, fakebasecamp.AgentID, id, "the sgid names the person, as a mention's does")
_, _, body := do(t, s, http.MethodGet, "/999/my/profile.json", bearer, "")
assert.Contains(t, body, `"boss":{"id":26909558,"name":"Operator"}`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These JSON assertions search raw text, so they can pass when boss or identity.id is missing from its expected location. Decode each response and assert the structured fields instead.

(Based on your team's feedback about structured JSON assertions.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/reads_test.go, line 36:

<comment>These JSON assertions search raw text, so they can pass when `boss` or `identity.id` is missing from its expected location. Decode each response and assert the structured fields instead.

(Based on your team's feedback about structured JSON assertions.) </comment>

<file context>
@@ -0,0 +1,160 @@
+	assert.True(t, ok)
+	assert.Equal(t, fakebasecamp.AgentID, id, "the sgid names the person, as a mention's does")
+	_, _, body := do(t, s, http.MethodGet, "/999/my/profile.json", bearer, "")
+	assert.Contains(t, body, `"boss":{"id":26909558,"name":"Operator"}`)
+
+	client, err := agent.People().Get(bounded(t), 1003)
</file context>

cursor = n
}

start, _ := slices.BinarySearchFunc(poll, cursor+1, func(ev Event, id int64) int { return cmp.Compare(ev.ID, id) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A valid cursor of 9223372036854775807 overflows cursor+1, so the search restarts at the first event and replays older events. Search for the first event whose ID is greater than cursor without incrementing it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/feed.go, line 331:

<comment>A valid cursor of `9223372036854775807` overflows `cursor+1`, so the search restarts at the first event and replays older events. Search for the first event whose ID is greater than `cursor` without incrementing it.</comment>

<file context>
@@ -0,0 +1,350 @@
+		cursor = n
+	}
+
+	start, _ := slices.BinarySearchFunc(poll, cursor+1, func(ev Event, id int64) int { return cmp.Compare(ev.ID, id) })
+	end := min(start+c.s.opts.pageSize, len(poll))
+	rows := []pollRow{}
</file context>

}

start, _ := slices.BinarySearchFunc(poll, cursor+1, func(ev Event, id int64) int { return cmp.Compare(ev.ID, id) })
end := min(start+c.s.opts.pageSize, len(poll))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: WithPageSize(0) leaves end == start, so every next URL repeats the same cursor forever; a negative value instead panics when slicing poll[start:end]. Clamp or reject non-positive sizes before computing the end.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/feed.go, line 332:

<comment>`WithPageSize(0)` leaves `end == start`, so every `next` URL repeats the same cursor forever; a negative value instead panics when slicing `poll[start:end]`. Clamp or reject non-positive sizes before computing the end.</comment>

<file context>
@@ -0,0 +1,350 @@
+	}
+
+	start, _ := slices.BinarySearchFunc(poll, cursor+1, func(ev Event, id int64) int { return cmp.Compare(ev.ID, id) })
+	end := min(start+c.s.opts.pageSize, len(poll))
+	rows := []pollRow{}
+	at := cursor
</file context>
Suggested change
end := min(start+c.s.opts.pageSize, len(poll))
end := start + min(max(1, c.s.opts.pageSize), len(poll)-start)

// it does. It is safe to call more than once.
func (s *Server) Close() {
s.mu.Lock()
if s.closed {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A concurrent second Close returns as soon as it sees closed, before the first call finishes srv.Close() and cables.Wait(). Make later callers wait for shutdown completion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/server.go, line 173:

<comment>A concurrent second `Close` returns as soon as it sees `closed`, before the first call finishes `srv.Close()` and `cables.Wait()`. Make later callers wait for shutdown completion.</comment>

<file context>
@@ -0,0 +1,651 @@
+// it does. It is safe to call more than once.
+func (s *Server) Close() {
+	s.mu.Lock()
+	if s.closed {
+		s.mu.Unlock()
+		return
</file context>

// WithPingInterval sets how often the cable pings. The default is Action
// Cable's three seconds, which is what eventfeed's staleness rule is built
// around.
func WithPingInterval(d time.Duration) Option { return func(o *options) { o.pingInterval = d } }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: WithPingInterval(0) and negative values reach time.NewTicker in the cable goroutine and panic when a cable opens. Reject nonpositive intervals while applying options.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/server.go, line 97:

<comment>`WithPingInterval(0)` and negative values reach `time.NewTicker` in the cable goroutine and panic when a cable opens. Reject nonpositive intervals while applying options.</comment>

<file context>
@@ -0,0 +1,651 @@
+// WithPingInterval sets how often the cable pings. The default is Action
+// Cable's three seconds, which is what eventfeed's staleness rule is built
+// around.
+func WithPingInterval(d time.Duration) Option { return func(o *options) { o.pingInterval = d } }
+
+// WithListener serves on l instead of a fresh loopback port.
</file context>
Suggested change
func WithPingInterval(d time.Duration) Option { return func(o *options) { o.pingInterval = d } }
func WithPingInterval(d time.Duration) Option {
return func(o *options) {
if d <= 0 {
panic("fakebasecamp: ping interval must be positive")
}
o.pingInterval = d
}
}

Comment thread e2e/fakebasecamp/main.go

func (*logReporter) Helper() {}

func (*logReporter) Errorf(format string, args ...any) { log.Printf(format, args...) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Quote the formatted message before logging so control characters in a request path cannot forge extra log entries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/fakebasecamp/main.go, line 137:

<comment>Quote the formatted message before logging so control characters in a request path cannot forge extra log entries.</comment>

<file context>
@@ -0,0 +1,151 @@
+
+func (*logReporter) Helper() {}
+
+func (*logReporter) Errorf(format string, args ...any) { log.Printf(format, args...) }
+
+func (r *logReporter) Cleanup(fn func()) {
</file context>

Comment on lines +23 to +24
// is not a member of, and anything in it, answers 404, on the reads and on
// both lanes of the feed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Nonmembers do not get 404s from the feed lanes: the poll endpoint omits inaccessible events, and the cable skips them. Distinguish those behaviors from direct reads so tests do not rely on the wrong contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/doc.go, line 23:

<comment>Nonmembers do not get 404s from the feed lanes: the poll endpoint omits inaccessible events, and the cable skips them. Distinguish those behaviors from direct reads so tests do not rely on the wrong contract.</comment>

<file context>
@@ -0,0 +1,56 @@
+//
+// Requests are authorized as Basecamp authorizes them: a bearer token names a
+// person, and a person reads what their projects hold. A project the caller
+// is not a member of, and anything in it, answers 404, on the reads and on
+// both lanes of the feed.
+//
</file context>
Suggested change
// is not a member of, and anything in it, answers 404, on the reads and on
// both lanes of the feed.
// is not a member of, and anything in it, answers 404 on direct reads;
// events from it are omitted from both lanes of the feed.

Comment thread internal/connector/fakebasecamp/server.go Outdated
- Log a request's status before its response is written, so a client
  holding its answer finds it in Requests; an upgrade logs its 101 the
  same way.
- Await returns ErrClosed once the fake is closed, instead of waiting out
  its context.
- Overlapping gates hold a request until every one is released.
- Requests and Before hooks get copies of Query and Form.
- The cable resolves its caller from the stream ticket, so When
  predicates such as ByAgents narrow cable faults.
- Stream tickets expire after their 120-second expires_in. They stay
  replayable within that window, as the SDK documents them.
- A connection holds any number of subscriptions, as Action Cable does;
  an identical resubscribe answers nothing.
- The client_credentials mint honors scope, and refuses one wider than
  the client's approval with invalid_scope.
- WithClock stamps events and ages tickets. Events keep a real created
  time, because the handoff discards requests created more than
  HandoffGrace before its run started.
- A bare poll entry (neither since nor position) stays the present page:
  Basecamp reads it as since=now, and basecamp events poll sends it.
- Public declarations before private ones in server.go; the secret
  rotation uses the fake's serial; the e2e server counts comment ids.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 17:52
func (s *Server) begin(route Route, w http.ResponseWriter, req *http.Request) (*call, bool) {
var form url.Values
if req.Method == http.MethodPost && strings.HasPrefix(req.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
if err := req.ParseForm(); err == nil {

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/connector/fakebasecamp/recordings.go">

<violation number="1" location="internal/connector/fakebasecamp/recordings.go:20">
P3: `Assignments.AddedPersonIDs` is capped at `MaxAssignmentPages` and stops as soon as it finds the event, so this says an empty page is the only stopping condition. Describe the bounded search instead.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +20 to +21
// connector walks pages until one comes back empty and never counts on
// the size, so the fake keeps one size for every page; a small one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Assignments.AddedPersonIDs is capped at MaxAssignmentPages and stops as soon as it finds the event, so this says an empty page is the only stopping condition. Describe the bounded search instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/recordings.go, line 20:

<comment>`Assignments.AddedPersonIDs` is capped at `MaxAssignmentPages` and stops as soon as it finds the event, so this says an empty page is the only stopping condition. Describe the bounded search instead.</comment>

<file context>
@@ -15,7 +15,11 @@ import (
-	// recordingEventsPage is how many history entries a page carries.
+	// recordingEventsPage is how many history entries a page carries:
+	// fifteen, the first page Basecamp's geared pagination serves. The
+	// connector walks pages until one comes back empty and never counts on
+	// the size, so the fake keeps one size for every page; a small one
+	// makes a walk past the first page cheap to set up.
</file context>
Suggested change
// connector walks pages until one comes back empty and never counts on
// the size, so the fake keeps one size for every page; a small one
// the connector searches a bounded number of pages, stopping when it
// finds the event or an empty page; it never counts on the size, so

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Scope handling and cable synchronization can invalidate connector scenarios, while ping handling can leave test waits unbounded.

Review effort: Balanced
Findings: 3 High severity · 3 Medium severity

Open (6)
Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Use one deadline across skipped ping waits

internal/​connector/​fakebasecamp/​cable_test.go:66

Each skipped ping restarts the full waitFor timeout. With the normal three-second pings, a missing confirmation or event can keep next waiting until the test suite times out, instead of failing after fifteen seconds. Use one deadline for the whole call, including skipped pings.

Medium severity Preserve and enforce requested device scope

internal/​connector/​fakebasecamp/​oauth.go:71

The intake discards the requested scope, so auth agent connect --scope read against the default world hands over full. Production ConnectAgent rejects that widened handover (internal/auth/agent_connect.go:271–281), after the fake has already spent the code and rotated the secret. Store the requested scope with each device code and limit the approval to that scope. Use an injected response when a test needs an invalid widening.

// lane before it streams again.
func (s *Server) DropCable() int {
s.mu.Lock()
conns := s.detachConnsLocked()

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deps tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants