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, 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) } }