Covers ~30 integration tests across 8 sections from the apiserver-backend-integration-test-plan: - §1: Cluster config push (store population, merge semantics, zero-preserve) - §2: Host control (OnHostUpdated merge, validateHost, preCheck, batch ops) - §3: Resource value push (all 7 value types, nil/unknown discriminant, overwrite) - §4: Heartbeat keep-alive (interval update, zero-preserve) - §5: Cross-feature (config+heartbeat, config+host, worker+config field preservation) - §6: RBAC (cluster_admin bypass, algorithm confusion, expired tokens, hostgroup bypass) - §7: Store thread safety (concurrent config pushes, host control+config, resource values) - §8: Input safety (hostname length, empty lists, malformed payloads) All tests use CGO_ENABLED=0 with //go:build !cgo build tag. §8.1 hostname-length skipped pending P1-2b implementation.
22 KiB
Integration Test Plan — P0 + P1 Features
Purpose
Verify end-to-end correctness of every feature delivered by tasks P0-1 through P1-3,
exercising the full stack: handler → store → handler merge → store readback.
These tests do not require a live C controller or CGO — they use the pure-Go stores
and DefaultHandler directly, which is what unit tests already do. The integration layer
adds: multi-step sequences, cross-feature interactions, and edge-case coverage
that unit tests leave out.
Test Environment
- All tests run with
CGO_ENABLED=0(pure Go) - Each test calls
store.InitStores()to reset all global state utils.Log = zap.NewNop()to suppress log output- §1–§5, §7, §8: call handler/store methods directly — no HTTP server required
- §6 (RBAC): requires a test HTTP server via
httptest.NewRecorderwith a full Gin router; JWT tokens must be real HS256-signed tokens using the configured signing secret (typically"xce-api-key"in test config);ctx.Set("user", "admin")does not satisfyGetUsernameFromToken()which reads and parses theAuthorizationheader- JWT IP binding:
isValidTokenchecksclaims.IP == ctx.ClientIP(); inhttptest,ClientIP()reads fromreq.RemoteAddr— setreq.RemoteAddr = "127.0.0.1:0"on every test request and embed"ip": "127.0.0.1"in the JWT claims; a mismatch returns 401 for the wrong reason, silently invalidating the test - Auth config:
isValidTokendereferencesutils.AuthenticationYamlConfig, a package-level pointer that isniluntilLoadConfig()is called — before any JWT test setutils.AuthenticationYamlConfig = &utils.Authentication{Token: utils.TokenConfig{SecretKey: "xce-api-key"}}directly; do not callLoadConfig()(reads a file that won't exist in CI)
- JWT IP binding:
1. P0-1: Cluster Config Push
1.1 Config push populates all stores in a single call
Steps:
- Call
dispatchConfigPush(handler, queues, resources, hostConfigs, cluster)with non-nil data for all four. - Verify
store.Queues,store.Resources,store.Hosts,store.Clustersall contain the pushed data.
Asserts: A single config push updates all four store types in sequence (queues → resources → hosts → cluster); the four updates are NOT atomic — a concurrent reader may see a partial state.
1.2 Second config push merges (does not replace)
Steps:
- Seed
store.Hostswith worker-sourced data (HostStatus, CPUFactor, NumCPUs, etc.) - Call
h.OnHostConfigUpdated()with config-sourced fields (HostModel, HostType, MaxSlot) - Call
h.OnHostConfigUpdated()a second time withHostModel: "modelB"(distinct from step 2's "modelA") - Verify worker-sourced fields from step 1 are still preserved
- Verify
HostModel == "modelB"(config-sourced field reflects the second push, not the first)
Asserts: Partial merge is idempotent and correctly preserves the other source's fields across multiple pushes.
1.3 Cluster update with zero HeartbeatInterval preserves existing value
Steps:
store.Clusters.Put("c1", ClusterInfo{HeartbeatInterval: 45})- Call
h.OnClusterUpdated(&ClusterInfo{ClusterName: "c1", HeartbeatInterval: 0, Vcpus: 8}) - Verify
HeartbeatIntervalis still 45 (zero is ignored perif incoming.HeartbeatInterval > 0guard)
Asserts: Cluster push with HeartbeatInterval=0 does not zero out the stored interval.
1.4 Config push with nil cluster argument is a no-op
Steps:
- Call
dispatchConfigPush(handler, nil, nil, nil, nil) - Verify
store.Clusters.Count() == 0
Covers: dispatchConfigPush skips the cluster update when the cluster argument is nil. (The empty-name guard lives in the CGO layer cClusterConfToClusterInfo and is not reachable in CGO_ENABLED=0 tests; a ClusterInfo{ClusterName:""} passed directly would be stored under an empty key.)
1.5 Config push with nil queues/resources/hosts is safe
Steps:
- Call
dispatchConfigPush(handler, nil, nil, nil, nil) - Verify no panics, no store entries.
Covers: Nil-safety of the dispatch loop.
2. P0-2: Host Control via Backend
2.1 Valid control action (open/close) routes through to backend
Steps:
store.Hosts.Put("node01", HostInfo{HostName: "node01"})- Call
preCheckHostControlRequest(&HostControlRequest{ControlAction: "open", Hostname: []string{"node01"}}) - Assert
allFlag == false,err == nil
Asserts: Valid actions pass pre-check.
2.2 Invalid action is rejected
Steps:
- Call
preCheckHostControlRequest(&HostControlRequest{ControlAction: "reboot", Hostname: []string{"node01"}}) - Assert error is returned.
Asserts: Only "open" and "close" are accepted.
2.3 Comment length limit (512 chars)
Steps:
- Call
preCheckHostControlRequestwithCommentof 513 chars - Assert error
- Call with 512 chars — assert no error
Already covered in unit tests. Integration adds: verify the error message is human-readable.
2.4 Hostname validation checks store existence
Steps:
store.Hosts.Put("node01", HostInfo{HostName: "node01"})- Call
validateHost(ctx, action, []string{"node01", "nonexistent"}) - Assert
node01in success list,nonexistentin failed map
Note: validateHost calls GetUsernameFromToken() internally, requiring the same JWT setup as §6 — set utils.AuthenticationYamlConfig, set req.RemoteAddr = "127.0.0.1:0", embed "ip": "127.0.0.1" in the JWT claims, and pass the token via Authorization header. ctx.Set("user", "admin") is not sufficient. See §6 Test Environment notes for the full setup checklist. Unlike §2.1–2.3, this test is grouped with the §6 JWT-required tests in controllers/integration_test.go.
2.5 RBAC: host:control requires cluster_admin role
Steps:
- Load RBAC config with
cluster_adminhavinghost:control - Use
httptest.NewRecorder+ Gin router with a spy handler that sets a response body - Call with a user that is NOT cluster_admin — assert 403 response
- Call with cluster_admin user — assert 2xx and spy handler body present
Note: RequirePermission short-circuits for cluster_admin via early return (without calling ctx.Next()). Asserting ctx.IsAborted() == false gives a false pass — the middleware doesn't abort, but the handler is also never called. Use an HTTP recorder to verify the handler response actually appeared.
Covers: Route middleware integration.
2.6 RBAC: host:view accessible to basic_user
Steps:
- Load RBAC config with
basic_userhavinghost:view - Call
RequirePermission("host:view")with basic_user - Assert request proceeds
2.7 Concurrent control requests do not race
Already covered: TestPreCheckHostControlRequest_NoRace (50 goroutines).
Integration adds: test that concurrent ControlHost calls to the same host don't corrupt store state.
2.8 Batch response construction
Steps:
- Build success and failed slices directly (do NOT call
ControlHost— it calls the CGO stub which returnserrNoCGOunderCGO_ENABLED=0):succeededItems := []map[string]interface{}{{"node": "node01"}, {"node": "node02"}}failedItems := []map[string]interface{}{{"node": "bad", "reason": "not found"}}
- Call
BuildBatchOperationResponse("close", "host", succeededItems, failedItems) - Verify returned status is
"partially_accepted", success list has 2 entries, failed map has 1
Covers: Batch response construction logic; the CGO backend call is out of scope for pure-Go integration tests.
3. P1-1: Resource Value Push
3.1 All 7 value types convert correctly (pure-Go path)
Steps:
- For each value type (i32, i64, u32, u64, fval, sval, bval):
- Create
rawResvalDatawith that type - Call
rawResvalToDistribution(r) - Assert
ValueType,NumericValue/StringValue/BooleanValuematch expected
- Create
Already covered in unit tests. Integration adds: round-trip through OnResourceLocationUpdated:
2. Call h.OnResourceLocationUpdated(rd) for each type
3. Read back from store.ResourceLocations using composite key name:host
4. Assert stored values match
3.2 Nil val produces nil return (no store update)
Steps:
- Create
rawResvalData{valNil: true, ...} - Assert
rawResvalToDistributionreturns nil - Assert
store.ResourceLocations.Count() == 0
3.3 Unknown discriminant produces nil return with warn log
Steps:
- Create
rawResvalData{valWhich: 99, ...} - Assert
rawResvalToDistributionreturns nil - Assert
store.ResourceLocations.Count() == 0
3.4 Multiple resource values in a single push
Steps:
- Call
h.OnResourceLocationUpdated(rd1)thenh.OnResourceLocationUpdated(rd2)with differentname:hostkeys - Assert
store.ResourceLocations.Count() == 2 - Assert each key returns the correct value
3.5 Resource value update overwrites previous value
Steps:
h.OnResourceLocationUpdated(rd)withNumericValue: 100.0h.OnResourceLocationUpdated(rd)with samename:hostbutNumericValue: 200.0- Assert stored value is 200.0 (last-write-wins)
Covers: Store Put is a full replacement, not merge.
3.6 Composite key format is res_name:location
Steps:
- Push a resource with
ResourceName: "mem"andLocation: "node01" - Assert
store.ResourceLocations.Has("mem:node01") == true - Assert
store.ResourceLocations.Has("mem") == false
4. P1-3: Heartbeat Keep-Alive
Isolation requirement:
heartbeatIntervalSecsis a package-levelvar int32in thebackendpackage shared across all tests in that package. Tests in this section MUST NOT callt.Parallel(). Run the full heartbeat test group sequentially to avoid cross-test interference viaSetHeartbeatInterval.
4.1 Heartbeat interval updates on cluster config push
Steps:
atomic.StoreInt32(&heartbeatIntervalSecs, 30)(default)- Call
h.OnClusterUpdated(&ClusterInfo{ClusterName: "c1", HeartbeatInterval: 45}) - Assert
atomic.LoadInt32(&heartbeatIntervalSecs) == 45
Covers: The P1-3 integration point — cluster config push triggers heartbeat interval change.
4.2 Cluster push with HeartbeatInterval=0 preserves current interval
Steps:
atomic.StoreInt32(&heartbeatIntervalSecs, 45)- Call
h.OnClusterUpdated(&ClusterInfo{ClusterName: "c1", HeartbeatInterval: 0}) - Assert
atomic.LoadInt32(&heartbeatIntervalSecs) == 45(unchanged)
Covers: The > 0 guard in OnClusterUpdated and SetHeartbeatInterval.
5. Cross-Feature Integration
5.1 Config push updates heartbeat interval AND stores cluster data
Steps:
- Default
heartbeatIntervalSecs = 30 - Call
dispatchConfigPush(h, nil, nil, nil, &ClusterInfo{ClusterName: "c1", HeartbeatInterval: 60, Vcpus: 32}) - Assert
store.Clusters.Get("c1")hasHeartbeatInterval=60, Vcpus=32 - Assert
atomic.LoadInt32(&heartbeatIntervalSecs) == 60
Covers: P0-1 + P1-3 interaction in a single dispatch.
5.2 Config push + host control on same host
Steps:
dispatchConfigPush(h, nil, nil, []HostInfo{{HostName: "node01", HostModel: "x86_64"}}, nil)- Assert
store.Hosts.Get("node01")hasHostModel="x86_64" - Assert
store.Hosts.Has("node01") == true— verifies config-push data is visible in the host store - Call
preCheckHostControlRequest(&HostControlRequest{ControlAction: "close", Hostname: []string{"node01"}})— assertallFlag == false,err == nil(validates action format; store lookup is handled byvalidateHostwhich requires JWT — see §2.4 note)
Covers: Data created by config push is written to the host store and is visible to subsequent operations.
5.3 Resource push after host config push — same host referenced
Steps:
h.OnHostConfigUpdated(&HostInfo{HostName: "node01", HostModel: "x86_64"})h.OnResourceLocationUpdated(&ResourceDistributionInfo{ResourceName: "mem", Location: "node01", NumericValue: 65536.0, ValueType: NUMERIC, IsDynamic: true})- Assert
store.Hosts.Get("node01")hasHostModel="x86_64"(preserved) - Assert
store.ResourceLocations.Get("mem:node01")hasNumericValue=65536.0 - Assert
store.Hosts.Get("node01").HostModelstill "x86_64" (host config was not overwritten)
Covers: Two independent push paths don't interfere with each other's store data.
5.4 Worker push after config push preserves config fields
Steps:
h.OnHostConfigUpdated(&HostInfo{HostName: "node01", HostModel: "x86_64", MaxSlot: 8})h.OnHostUpdated(&HostInfo{HostName: "node01", HostStatus: 3, CPUFactor: 2.5, NumCPUs: 16})- Assert
got.HostModel == "x86_64" && got.MaxSlot == 8(config preserved) - Assert
got.HostStatus == 3 && got.CPUFactor == 2.5(worker updated)
Covers: Two-push merge correctness — already tested in unit tests but verified here at integration scope.
5.5 Multiple sequential config pushes (idempotent)
Steps:
- Push config with
ClusterInfo{Vcpus: 16, HeartbeatInterval: 30} - Push config with
ClusterInfo{Vcpus: 32, HeartbeatInterval: 60} - Assert store has
Vcpus=32, HeartbeatInterval=60(last-write-wins for config fields) - Assert
heartbeatIntervalSecs == 60
Covers: Rapid config push updates don't leave stale data.
6. RBAC Integration (End-to-End)
RBAC isolation: Call
resetRBACState()(notstore.InitStores()) at the start of each RBAC test.resetRBACState()zerosrbacConfig, preventingAddPermissionsForRolefrom appending duplicate permissions across test reloads.store.InitStores()does not reset RBAC state.
6.1 Full middleware chain: JWT → RBAC → handler
Steps:
-
Set up Gin router with real middleware (
RequirePermission("host:control")) -
Create a valid JWT token for a cluster_admin user
-
POST
/xce/v1/hosts/controlwithaction=close, host_name=["node01"] -
Assert 202 Accepted (or mocked backend response)
-
Create a valid JWT token for a basic_user
-
POST
/xce/v1/hosts/control— assert 403 Forbidden -
POST
/xce/v1/hosts/selectwith basic_user — assert passes (host:view)
Note: This requires spinning up a test HTTP server via httptest.NewRecorder. Do not mock at the middleware level — the purpose of §6 is to exercise the real middleware chain end-to-end.
6.2 RBAC: cluster_admin bypasses permission check
Steps:
- Set up Gin router with
RequirePermission("host:control") - Sign a JWT for "admin1" with role "cluster_admin" and issue
POST /xce/v1/hosts/control - Assert response status is not 403 — verifies the middleware did not block the request
Note: RequirePermission returns early for cluster_admin without calling ctx.Next(), so downstream handlers are never reached. Do not assert a spy handler body or ctx.IsAborted() == false — both give false results. To verify full handler execution for a privileged user, write a separate test with a non-cluster_admin user that has explicit host:control permission (see §6.3).
6.3 RBAC: basic_user cannot control hosts but can view
Steps:
- Assign user "user1" role "basic_user"
- Call
RequirePermission("host:control")— assert 403 - Call
RequirePermission("host:view")— assert passes
6.4 JWT algorithm confusion is rejected
Steps:
- Configure test server with HS256 signing secret
"xce-api-key" - Craft a token header claiming
"alg": "none"or a mismatched algorithm (RS256) - POST to
/xce/v1/hosts/controlwith that token (use valid IP binding so IP mismatch is not the cause of rejection) - Assert 401 Unauthorized
- Assert response body contains a token/signature error string (e.g.,
"token","invalid", or"signature") — not"ip"or"expired"— to confirm the rejection is due to algorithm/signature failure, not an unrelated check
Covers: Prevents algorithm confusion attacks where an attacker downgrades signing to none.
6.5 Expired JWT is rejected
Steps:
- Sign a JWT with
exp = time.Now().Unix() - 3600(expired 1 hour ago) - POST to any protected endpoint
- Assert 401 Unauthorized
Covers: Token replay after expiry — ensures expired tokens cannot be reused.
6.6 Known gap: hostgroup domain restriction bypass
Documents a known limitation, not a working feature.
validateHostathosts.go:505-507contains a// TODOthat silently skips hostgroup domain enforcement. This test makes the gap explicit so it is visible in code review and prevents accidental regression if the TODO is removed.
Steps:
- Provision a user with
host:controlscoped to hostgroup "gpu-nodes" - Issue
POST /xce/v1/hosts/controltargeting a host NOT in "gpu-nodes" - Assert request is accepted (not 403) — documents that hostgroup scoping is not yet enforced
Note: When P1-2b implements hostgroup enforcement, flip this assertion to assert 403 and remove the // TODO marker.
6.7 Post-logout token reuse
Design gap: JWTs are stateless and the current server does not maintain a JTI blacklist. A token valid at logout time remains valid until expiry. This test documents the current behavior as an explicit design decision.
Steps:
- Sign a valid JWT with a 1-hour expiry
- Call
POST /xce/v1/users/logout(or equivalent) — record any logout response - Immediately reuse the same token on
GET /xce/v1/hosts— assert 200 OK (not 401)
Note: If a JTI blacklist is added in future, flip this assertion to expect 401 and add the blacklist provisioning step.
7. Store Thread Safety
CI requirement: All §7 tests must run with
-race:go test -race ./store/.... A test that passes without-raceprovides no guarantee of absence of data races — the Go race detector instruments memory accesses at runtime and is the authoritative check.
7.1 Concurrent config pushes don't corrupt data
Steps:
- Start 10 goroutines, each calling
h.OnHostConfigUpdated(&HostInfo{HostName: "node01", ...})with slightly different HostModel values - Wait for all goroutines to complete
- Assert
store.Hosts.Get("node01")returns a valid HostInfo (no zero-value corruption) - Assert
HostNamefield is "node01"
Race detection note: A simple value-validity assertion cannot prove absence of a data race — concurrent writes all produce plausible values. Run this test with go test -race ./store/... to detect RMW races via the Go race detector. The assertion above verifies structural integrity; -race detects the underlying race condition.
7.2 Concurrent host control + config push
Steps:
- Goroutine A: call
OnHostConfigUpdatedfor "node01" in a loop - Goroutine B: call
validateHostchecking "node01" existence in a loop - Assert no panics and no store corruption after 100 iterations
7.3 Concurrent resource value pushes for same key
Steps:
- Start 10 goroutines, each calling
OnResourceLocationUpdatedfor"mem:node01"with different NumericValue - Assert final stored value is one of the 10 values (no corruption)
- Assert key exists exactly once
8. Input Safety
8.1 Hostname length limits
Skip until P1-2b is implemented. No hostname length validation exists in
preCheckHostControlRequestyet — this test will pass vacuously. Add//go:build p1_2bbuild tag or comment out pending that task.
Steps:
- Call
preCheckHostControlRequestwith hostname exceeding the P1-2b limit (>1024 chars) - Assert
err != nilwith message indicating length exceeded
8.2 Empty hostname list
Steps:
- Call
preCheckHostControlRequestwithHostname: [] - Assert
err != nil— inspect the actual error message frompreCheckHostControlRequestand assert it contains the expected string (e.g., "no hostnames provided" or similar)
8.3 Select expression length (current limit: 4094)
Steps:
- Call handler with
Selectstring > 4094 chars - Assert error
- Call with 4094 chars — assert accepted
8.4 Special characters in hostnames
Steps:
- Call with hostname containing
<script>alert(1)</script> - Assert the hostname is stored as a string without interpretation (XSS safety at API layer)
Test File Organization
| File | Tests |
|---|---|
backend/integration_test.go |
§1 (config push), §3 (resource push), §4 (heartbeat), §5 (cross-feature) |
controllers/integration_test.go |
§2.1–2.3, §2.5–2.8 (host control — no JWT required), §2.4 (host validation — requires full JWT setup; see §6 prerequisites), §6 (RBAC middleware), §8 (input safety) |
store/concurrency_test.go |
§7 (thread safety) |
All test files use //go:build !cgo or CGO_ENABLED=0 compatible patterns.
Package declarations:
backend/integration_test.gomust declarepackage backend(notpackage backend_test) to access unexported symbols:heartbeatIntervalSecs,rawResvalToDistribution,dispatchConfigPushcontrollers/integration_test.gomay usepackage controllers_testfor black-box tests, but any test accessing unexported helpers must usepackage controllers
JWT signing secret in tests: Integration tests in controllers/integration_test.go that create real JWTs must use a test-only signing secret (e.g., "xce-api-key" from the test config file). Never use the production signing secret. Document the expected test secret in a comment at the top of the test file so it is clearly identifiable during security audits.
Priority Classification
| Priority | Tests | Rationale |
|---|---|---|
| P0 — Must have | 1.1, 1.2, 1.3, 2.1, 2.2, 2.5, 3.1, 3.4, 4.1, 5.1 | Core functionality and merge correctness |
| P1 — Should have | 1.4, 1.5, 2.4, 2.6, 2.8, 3.2, 3.3, 3.5, 3.6, 4.2, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7 | Edge cases, RBAC, JWT security, and known gap documentation |
| P2 — Nice to have | 2.3, 2.7, 5.4, 5.5, 7.1, 7.2, 7.3, 8.1–8.4 | Robustness and stress |
Existing Test Coverage
The following unit tests already cover much of the ground above and should NOT be duplicated:
| Existing Test File | What It Covers | Integration Gap |
|---|---|---|
backend/handler_test.go |
Single-push merge for hosts, clusters, dispatch | Multi-push sequences, cross-feature |
backend/convert_test.go |
All 7 value types, nil val, unknown discriminant | Store round-trip after conversion |
backend/heartbeat_test.go |
SetHeartbeatInterval, runHeartbeat call/cancel | Interval change triggered by OnClusterUpdated |
controllers/host_control_test.go |
preCheck validation, concurrent no-race | Full handler flow with store interaction |
controllers/rbac_check_test.go |
host:control/host:view permissions | Full middleware chain with JWT |