Files
pangolin/server/internal/httpapi/nodes_disconnect_test.go
wangjia f035552ff5 fix: disconnect 吊销每设备凭证 + 客户端断开时真正调用(F4,#28)
审查 F4 两层问题:①DisconnectNode 只撤账户级 ent.DpUUID,而 connect 下发的是
每设备 devDp——吊销从未对准目标;②客户端从未调用过 disconnect 端点(只有
fetchConfig),端点是死代码,凭证一律活到 TTL(付费 24h)。

- server:disconnect 收 optional body {device_id},吊销该设备 dp_uuid(优先)+
  账户级遗留兜底;旧客户端无 body 走兜底,行为不回归。nil hub 守卫(测试友好,
  与 ListNodes 一致)。
- client:ConnectApi.disconnect(best-effort,5s 超时吞错);_disconnect 加
  revokeCredential 参数,仅在「不会紧接重连同节点」的路径置 true(用户主动断开/
  额度耗尽/登出)——看门狗断开→重连若也吊销,revoke 可能晚于新 connect 推送、
  误杀新会话。
- test:httpapi disconnect 三态(带 device_id 双吊销/无 body 仅兜底/设备已移除
  不炸);client 功能套件 182 过(golden 为已知 macOS 本地漂移,不相关)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:29:01 +08:00

114 lines
3.9 KiB
Go

package httpapi
import (
"context"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/wangjia/pangolin/server/internal/codes"
"github.com/wangjia/pangolin/server/internal/nodes"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// fakeDisconnectStore implements the NodeStore methods DisconnectNode touches;
// everything else panics via the embedded nil interface (未用到即安全)。
type fakeDisconnectStore struct {
nodes.NodeStore // embed:未实现的方法调用即 panic,测试只走下面四个
node *nodes.NodeRow
ent *nodes.Entitlement
devDp string // EnsureDeviceDpUUID 返回值;"" = 设备不存在
deleted []string
}
func (f *fakeDisconnectStore) EntitlementForUser(context.Context, int64) (*nodes.Entitlement, error) {
return f.ent, nil
}
func (f *fakeDisconnectStore) NodeByUUID(context.Context, string) (*nodes.NodeRow, error) {
return f.node, nil
}
func (f *fakeDisconnectStore) EnsureDeviceDpUUID(context.Context, int64, string) (string, int64, error) {
if f.devDp == "" {
return "", 0, nodes.ErrDeviceNotFound
}
return f.devDp, 7, nil
}
func (f *fakeDisconnectStore) DeleteCredential(_ context.Context, _ int64, dpUUID string) error {
f.deleted = append(f.deleted, dpUUID)
return nil
}
func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1.Credential, time.Time) error {
return nil
}
func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int {
t.Helper()
api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除
req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "node-1")
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, codes.CtxKeyUserID, int64(42))
rec := httptest.NewRecorder()
api.DisconnectNode(rec, req.WithContext(ctx))
return rec.Code
}
// F4 回归:带 device_id 的 disconnect 必须吊销**每设备** dp_uuid(connect 真正
// 下发的那个),账户级作为遗留兜底也一并吊销。
func TestDisconnectNode_RevokesDeviceCredential(t *testing.T) {
store := &fakeDisconnectStore{
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
devDp: "device-dp",
}
if code := doDisconnect(t, store, `{"device_id":"dev-uuid-1"}`); code != 204 {
t.Fatalf("status = %d, want 204", code)
}
want := map[string]bool{"device-dp": true, "acct-dp": true}
if len(store.deleted) != 2 || !want[store.deleted[0]] || !want[store.deleted[1]] {
t.Errorf("deleted = %v, want both device-dp and acct-dp", store.deleted)
}
// 每设备凭证在前(connect 真正下发的),账户级兜底在后。
if store.deleted[0] != "device-dp" {
t.Errorf("device credential should be revoked first, got %v", store.deleted)
}
}
// 旧客户端无 body → 仅账户级兜底(历史行为不回归)。
func TestDisconnectNode_LegacyNoBody(t *testing.T) {
store := &fakeDisconnectStore{
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
}
if code := doDisconnect(t, store, ""); code != 204 {
t.Fatalf("status = %d, want 204", code)
}
if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" {
t.Errorf("deleted = %v, want only acct-dp", store.deleted)
}
}
// 设备不存在(已被移除)→ 不炸,仍撤账户级。
func TestDisconnectNode_DeviceGone(t *testing.T) {
store := &fakeDisconnectStore{
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
devDp: "", // ErrDeviceNotFound
}
if code := doDisconnect(t, store, `{"device_id":"gone"}`); code != 204 {
t.Fatalf("status = %d, want 204", code)
}
if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" {
t.Errorf("deleted = %v, want only acct-dp", store.deleted)
}
}