feat: 端到端测试(真 agent + 真中继 + TLS),并修三处只有它能发现的缺陷
新增 test/e2e.test.js:编译并运行 Go agent,经 TLS 接到真中继,由模拟手机发起 签名请求,再断言节点实际收到的内容(含 audit 日志不含 body/密码、危险写 body 逐字节一致)。缺 go/openssl 则跳过并说明原因。 它一次抓出三个单元测试各自都通过、合起来却坏了的问题: 1. transport.Client 把会话 token 写进 c.token,而 c.token 是 bearer——Poll/Result 因此用会话 token 当 bearer 发出,中继一律 401。现象是 agent 连上后无限重连, 日志说'凭据被拒'。现在两个凭据分成两个字段,并加测试断言握手带 bearer 不带会话、 轮询带会话。 2. main.loop 每个周期都重新握手。中继每个节点只保留一个会话,新握手会顶掉旧的并 丢弃其挂起的轮询,于是两次握手之间到达的请求被投进已不存在的会话、以'agent 离线' 超时——而 agent 全程在线。改为只在会话失效/传输失败时重新握手。 3. auth.registerDevices 把'已存在'一律跳过,随后又被'本轮未声明'的回收循环删掉, 所以节点的第二次握手会把自己注册的设备全部注销。改为本节点已持有即确认(幂等)。 另:transport 支持 relayCAFile(PEM 钉 CA,替代系统信任库,不是关闭校验), 测试用自签证书 + 钉 CA 走真实 TLS 校验路径。
This commit is contained in:
@@ -3,6 +3,7 @@ package transport
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -193,6 +194,10 @@ func TestResultPostsTheEnvelopeVerbatim(t *testing.T) {
|
||||
got, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}), "t")
|
||||
// The session the handshake would have issued. It is set directly rather than by running the
|
||||
// handshake because this test is about the result body, and the two tokens being distinct is
|
||||
// asserted on its own in TestTheBearerAndTheSessionAreDifferentCredentials.
|
||||
client.session = "session-token"
|
||||
envelope := json.RawMessage(`{"v":1,"node":"nd_test0001","p":"AAAA"}`)
|
||||
if err := client.Result(context.Background(), envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -214,12 +219,16 @@ func TestPushDevicesPostsTheFullSetUnderTheSession(t *testing.T) {
|
||||
if r.URL.Path != PathDevices {
|
||||
t.Fatalf("posted to %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-MSA-Session") == "" {
|
||||
t.Error("the device push must carry the session")
|
||||
if r.Header.Get("X-MSA-Session") != "session-token" {
|
||||
t.Errorf("the device push carried session %q", r.Header.Get("X-MSA-Session"))
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer t" {
|
||||
t.Errorf("the device push carried authorization %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&got)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}), "t")
|
||||
client.session = "session-token"
|
||||
err := client.PushDevices(context.Background(), []DeviceRegistration{
|
||||
{App: "ap_5b1d0000", Token: "tok-a"},
|
||||
})
|
||||
@@ -237,6 +246,7 @@ func TestPushDevicesPostsTheFullSetUnderTheSession(t *testing.T) {
|
||||
client2, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}), "t")
|
||||
client2.session = "session-token"
|
||||
if err := client2.PushDevices(context.Background(), []DeviceRegistration{}); err != nil {
|
||||
t.Fatalf("empty push: %v", err)
|
||||
}
|
||||
@@ -270,3 +280,109 @@ func TestRetryAfterHonoursTheRelayHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bearer token and the session token are different credentials with different jobs, and swapping
|
||||
// them is the failure this test exists to catch. The bearer identifies the node to the relay's
|
||||
// authentication layer; the session is what the relay issued once the challenge signature proved
|
||||
// possession of the node's key. Sending the session as the bearer (or vice versa) makes the relay
|
||||
// answer 401, which the loop reads as "re-authenticate" — so the symptom is an agent that connects
|
||||
// and immediately reconnects forever, with a log line that blames the token.
|
||||
func TestTheBearerAndTheSessionAreDifferentCredentials(t *testing.T) {
|
||||
var seen []struct {
|
||||
path string
|
||||
authorization string
|
||||
session string
|
||||
}
|
||||
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, struct {
|
||||
path string
|
||||
authorization string
|
||||
session string
|
||||
}{r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("X-MSA-Session")})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case PathHello:
|
||||
var body HelloRequest
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Challenge == "" {
|
||||
json.NewEncoder(w).Encode(HelloResponse{Challenge: "issued"})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(HelloResponse{Session: Session{Token: "session-abc"}})
|
||||
}
|
||||
case PathPoll:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}), "bearer-token")
|
||||
|
||||
if _, err := client.Hello(context.Background(), "build", "sha"); err != nil {
|
||||
t.Fatalf("Hello: %v", err)
|
||||
}
|
||||
if _, err := client.Poll(context.Background()); !errors.Is(err, ErrNoWork) {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
|
||||
for _, request := range seen {
|
||||
if request.authorization != "Bearer bearer-token" {
|
||||
t.Errorf("%s sent authorization %q", request.path, request.authorization)
|
||||
}
|
||||
switch request.path {
|
||||
case PathHello:
|
||||
// The handshake is authenticated by the bearer and the signature; there is no session yet.
|
||||
if request.session != "" {
|
||||
t.Errorf("the handshake carried a session %q", request.session)
|
||||
}
|
||||
case PathPoll:
|
||||
if request.session != "session-abc" {
|
||||
t.Errorf("poll carried session %q, want the one the handshake issued", request.session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A pinned CA replaces the system store rather than adding to it, and a certificate that only the
|
||||
// system store would accept must then fail. That asymmetry is the point: `relayCAFile` is for a
|
||||
// relay behind a private CA, and an operator who set it expects the relay to be verified against
|
||||
// exactly that root.
|
||||
func TestRootCAsReplaceTheSystemStore(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
_, signer, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The test server's own certificate is self-signed, so trusting it works...
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(server.Certificate())
|
||||
client, err := New(Config{
|
||||
BaseURL: server.URL, NodeID: "nd_test0001", Token: "t", Signer: signer,
|
||||
Timeout: 2 * time.Second, RootCAs: pool,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The transport is left as configured, so this exercises the pinned pool rather than the test
|
||||
// server's convenience client. The handler answers 200 with no body, so ErrNoWork is the success
|
||||
// case here: what is under test is whether the TLS handshake completed, and a verification
|
||||
// failure surfaces as a transport error instead.
|
||||
if _, err := client.Poll(context.Background()); !errors.Is(err, ErrNoWork) {
|
||||
t.Fatalf("a pinned CA did not verify the relay: %v", err)
|
||||
}
|
||||
|
||||
// ...and an empty pool verifies nothing, which is the failure mode an operator sees when they
|
||||
// point relayCAFile at the wrong file: a TLS error, never a silent fallback.
|
||||
empty, err := New(Config{
|
||||
BaseURL: server.URL, NodeID: "nd_test0001", Token: "t", Signer: signer,
|
||||
Timeout: 2 * time.Second, RootCAs: x509.NewCertPool(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := empty.Poll(context.Background()); err == nil || errors.Is(err, ErrNoWork) {
|
||||
t.Fatalf("an empty root pool still verified the relay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user