Level 18 /

Standalone Test Server

The tracks are inspected, the paperwork is signed, but before the maiden voyage you need a dress rehearsal. Gopher fires up a model of Appville right here in the station yard - a miniature town with tiny buildings, tiny users, and tiny API endpoints. The connector runs for real, against this scale model, while C1 watches from Conductor City.

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.

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:

EndpointMethodsWhat It Does
/oauth/tokenPOSTReturns a mock bearer token
/api/users/meGETReturns the first user (credential validation)
/api/usersGET, POSTList users with cursor pagination, create new users
/api/users/{id}GET, PATCHGet or update a specific user
/api/rolesGETList all roles
/api/roles/{id}GETGet a specific role
/api/roles/{id}/membersGETList role members
/api/roles/{id}/members/{uid}PUT, DELETEAdd or remove a role member
/api/groupsGETList all groups
/api/groups/{id}GETGet a specific group
/api/groups/{id}/membersGETList group members
/api/groups/{id}/members/{uid}PUT, DELETEAdd 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.

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.

Explore the related connector code files

Next Lesson

Validate & Run

Continue