Gopher says
Your integration tests proved the connector works in a test harness. But before you ship, you need to see it run for real - connected to an actual C1 tenant, syncing resources, responding to actions. For that, you need a target app to connect to.
In baton-junction, the test/mockserver directory contains a standalone
HTTP server that simulates the entire target API. You point the compiled
connector at it and run a sync against a C1 tenant.
Why a Standalone Server?
Level 17’s integration tests use httptest.NewServer - an in-process mock that lives and dies within go test. That’s perfect for automated CI, but it can’t help you when you need to:
- Run the compiled binary in service mode, connected to a C1 tenant
- Watch the sync happen in the C1 UI - see resources appear, entitlements populate, grants resolve
- Test provisioning end-to-end - Grant an entitlement in the UI and watch the mock server log the API call
- Debug connector behavior with live gRPC traffic between the connector and the platform
The standalone mock server is a separate Go program that runs on a port, serves the same endpoints as the target app, and logs every request. You start it, point your connector at it, and run a sync.
The connector sits between the real platform and the mock target app
The In-Memory Store
The mock server keeps all its data in memory using a store struct protected by a sync.RWMutex. This is the same concurrency pattern you learned in Level 5 (collections) and Level 7 (context) - a mutex guards shared state so concurrent HTTP requests don’t corrupt it:
type store struct {
mu sync.RWMutex
users []User
roles []Role
groups []Group
roleMembers map[string][]Member
groupMembers map[string][]Member
nextUserSeq int
}
The newStore() function seeds it with realistic test data - three users, two roles, two groups, and membership assignments. This mirrors the same data used in the integration tests, so behavior is consistent across both testing approaches.
sync.RWMutex allows multiple concurrent readers (RLock) but exclusive
writers (Lock). List endpoints that only read use RLock - they don’t block
each other. Mutation endpoints (PATCH, PUT, DELETE) use Lock - they wait for
all readers to finish. This matches how a production web server handles
concurrent requests.
Generics for Response Types
The mock server uses Go generics (introduced in Go 1.18) for its JSON response types. Instead of writing separate response structs for users, roles, and groups, a single generic type handles all of them:
type ListResponse[T any] struct {
Data []T `json:"data"`
NextCursor string `json:"next_cursor"`
}
type SingleResponse[T any] struct {
Data T `json:"data"`
}
ListResponse[User], ListResponse[Role], ListResponse[Group] - the compiler generates a concrete type for each. This is the same approach used in the client package (Level 13) to keep the API response handling DRY.
Routing with http.NewServeMux
The server registers all endpoints on a ServeMux using HandleFunc. Each handler is wrapped with a logRequest middleware that logs every incoming request - invaluable when debugging why a sync isn’t finding resources:
func logRequest(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL)
next(w, r)
}
}
This is a standard middleware pattern: a function that takes a handler, wraps it with logging, and returns a new handler.
The Endpoint Map
The mock server implements every endpoint the connector’s HTTP client calls. Here’s what’s covered:
| Endpoint | Methods | What It Does |
|---|---|---|
/oauth/token | POST | Returns a mock bearer token |
/api/users/me | GET | Returns the first user (credential validation) |
/api/users | GET, POST | List users with cursor pagination, create new users |
/api/users/{id} | GET, PATCH | Get or update a specific user |
/api/roles | GET | List all roles |
/api/roles/{id} | GET | Get a specific role |
/api/roles/{id}/members | GET | List role members |
/api/roles/{id}/members/{uid} | PUT, DELETE | Add or remove a role member |
/api/groups | GET | List all groups |
/api/groups/{id} | GET | Get a specific group |
/api/groups/{id}/members | GET | List group members |
/api/groups/{id}/members/{uid} | PUT, DELETE | Add or remove a group member |
Every one of these maps directly to a method in client.go (Level 13). The mock server is the other side of those HTTP calls.
Cursor-Based Pagination
The users endpoint implements cursor-based pagination with a configurable page size. This is important because many pagination bugs only surface when the connector actually has to make multiple requests:
const pageSize = 2
cursor := r.URL.Query().Get("cursor")
start := 0
if cursor != "" {
for i, u := range s.users {
if u.ID == cursor {
start = i
break
}
}
}
end := start + pageSize
if end > len(s.users) {
end = len(s.users)
}
next := ""
if end < len(s.users) {
next = s.users[end].ID
}
With a page size of 2 and 3 users, the connector has to make two requests to get all users. The cursor is a user ID - the same approach the target API uses. If the connector’s pagination token handling is broken, you’ll see it immediately: only 2 of 3 users will appear in the sync.
Mutation Endpoints
Provisioning (Grant/Revoke) and actions (enable/disable/update profile) all hit mutation endpoints. The mock server actually modifies its in-memory state, so you can verify changes stick:
case http.MethodPatch:
var attrs map[string]string
if err := json.NewDecoder(r.Body).Decode(&attrs); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if v, ok := attrs["status"]; ok {
s.users[idx].Status = v
}
if v, ok := attrs["first_name"]; ok {
s.users[idx].FirstName = v
}
// ... other fields
writeJSON(w, http.StatusOK, SingleResponse[User]{Data: s.users[idx]})
When you enable a user through C1, the connector calls PATCH on the mock server, the mock updates the user’s status to “active”, and the next sync reflects the change. This round-trip is exactly what happens when the connector runs against a real API.
Running the Mock Server
The mock server is a standalone Go program with a configurable port:
Build the mock server binary:
Start the mock server on the default port:
When it starts, it logs every available endpoint:
Mock API server starting on http://localhost:8765
OAuth token endpoint: POST /oauth/token
Users: GET /api/users, POST /api/users, GET/PATCH /api/users/{id}
Roles: GET /api/roles, GET /api/roles/{id}, GET /api/roles/{id}/members
Groups: GET /api/groups, GET /api/groups/{id}, GET /api/groups/{id}/members
Use --port to change the port: ./mockserver --port 9090.
Connecting the Connector
With the mock server running, you can run the connector in service mode pointed at it. The connector binary accepts CLI flags for configuration:
./baton-junction \
--client-id test-id \
--client-secret test-secret \
--base-url http://localhost:8765
The connector authenticates against the mock’s /oauth/token, then syncs users, groups, and roles. Every request appears in the mock server’s log output, so you can see exactly what the connector is doing.
To run it in service mode connected to a C1 tenant, you’d additionally pass the --client-id and --client-secret for your C1 connector instance. The connector registers with the platform, receives sync instructions via gRPC, and executes them against the mock server.
By default, baton-junction runs a single sync and exits. In service mode
(with C1 credentials), it stays running and listens for sync and provisioning
requests from the platform. The mock server needs to stay running the entire
time - it’s the “app” your connector is connecting to.
Adding Test Data
The seed data in newStore() is deliberately small but realistic. When you need to test specific scenarios, you can extend it:
users: []User{
{ID: "u1", Email: "alice@appville.com", FirstName: "Alice", LastName: "Smith",
Username: "alice", Status: "active", CreatedAt: now},
{ID: "u2", Email: "bob@appville.com", FirstName: "Bob", LastName: "Jones",
Username: "bob", Status: "active", CreatedAt: now},
{ID: "u3", Email: "carol@appville.com", FirstName: "Carol", LastName: "White",
Username: "carol", Status: "disabled", CreatedAt: now},
},
Want to test what happens when a group has 100 members? Add them to the seed data. Want to test empty states? Clear a membership map. The mock server is your sandbox - modify it freely to simulate any scenario.
Gopher says
You’ve built the full testing pyramid: unit tests for individual functions, integration tests for the wired-up connector, and now a standalone mock server for live end-to-end testing.
In baton-junction, the test/mockserver uses only stdlib packages - net/http,
encoding/json, flag, sync, log. Everything you learned in the Go
fundamentals levels comes together here. This mock server pattern works for
any Baton connector - swap the endpoints and seed data to match your target app.
Next: run a one-shot sync, inspect the output with the baton CLI tool, and
then take the connector live in service mode.
Next Lesson
Validate & Run

