mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-09-23 19:16:18 +00:00
57da780653
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
881 lines
25 KiB
Go
881 lines
25 KiB
Go
// Package qa contains repeatable end-to-end / API regression tests that run
|
|
// against a LIVE glkvm-cloud deployment (not unit tests). They are gated by the
|
|
// QA_TARGET env var, so a normal `go test ./...` skips them.
|
|
//
|
|
// Run (example, against the CN box):
|
|
//
|
|
// QA_TARGET=https://106.55.158.199 \
|
|
// QA_USER=admin QA_PASS='<password>' \
|
|
// QA_DEV_ADDR=106.55.158.199:5912 QA_DEV_TOKEN='<rtty token>' \
|
|
// QA_REAL_DEVID=zh71fb1 QA_REAL_MAC=9483c4b71fb1 \
|
|
// go test ./internal/qa -run TestE2E -v
|
|
//
|
|
// Assertions are invariant-based (don't depend on exact fixture counts), so the
|
|
// suite can be re-run against any environment. L2 device-protocol tests register
|
|
// their own throwaway devices (ddns prefix "qae2e") and clean them up via the API.
|
|
package qa
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ---------- config from env ----------
|
|
|
|
type config struct {
|
|
target string // user API base, e.g. https://106.55.158.199
|
|
user string
|
|
pass string
|
|
devAddr string // device port host:port (TLS), e.g. 106.55.158.199:5912
|
|
devToken string // rtty registration token
|
|
realID string // an already-online real device id (optional)
|
|
realMAC string // its colon-less MAC (optional)
|
|
}
|
|
|
|
func loadConfig(t *testing.T) config {
|
|
c := config{
|
|
target: os.Getenv("QA_TARGET"),
|
|
user: os.Getenv("QA_USER"),
|
|
pass: os.Getenv("QA_PASS"),
|
|
devAddr: os.Getenv("QA_DEV_ADDR"),
|
|
devToken: os.Getenv("QA_DEV_TOKEN"),
|
|
realID: os.Getenv("QA_REAL_DEVID"),
|
|
realMAC: os.Getenv("QA_REAL_MAC"),
|
|
}
|
|
if c.target == "" {
|
|
t.Skip("QA_TARGET not set; skipping live e2e suite")
|
|
}
|
|
if c.user == "" {
|
|
c.user = "admin"
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ---------- HTTP client ----------
|
|
|
|
type client struct {
|
|
cfg config
|
|
http *http.Client
|
|
token string
|
|
}
|
|
|
|
func newClient(cfg config) *client {
|
|
return &client{
|
|
cfg: cfg,
|
|
http: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
|
},
|
|
}
|
|
}
|
|
|
|
type apiEnvelope struct {
|
|
OK bool `json:"ok"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
func (c *client) do(method, path string, body any, auth bool) (int, apiEnvelope, error) {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, c.cfg.target+path, rdr)
|
|
if err != nil {
|
|
return 0, apiEnvelope{}, err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if auth && c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return 0, apiEnvelope{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
var env apiEnvelope
|
|
_ = json.Unmarshal(raw, &env)
|
|
return resp.StatusCode, env, nil
|
|
}
|
|
|
|
func (c *client) login(t *testing.T) {
|
|
t.Helper()
|
|
_, env, err := c.do("POST", "/api/login", map[string]string{
|
|
"username": c.cfg.user, "password": c.cfg.pass,
|
|
}, false)
|
|
if err != nil {
|
|
t.Fatalf("login request failed: %v", err)
|
|
}
|
|
if !env.OK {
|
|
t.Fatalf("login failed: code=%s msg=%s", env.Code, env.Message)
|
|
}
|
|
var d struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &d); err != nil || d.Token == "" {
|
|
t.Fatalf("login returned no token: %s", string(env.Data))
|
|
}
|
|
c.token = d.Token
|
|
}
|
|
|
|
// ---------- device-list helpers ----------
|
|
|
|
type devItem struct {
|
|
ID int64 `json:"id"`
|
|
Ddns string `json:"ddns"`
|
|
Mac string `json:"mac"`
|
|
IP string `json:"ip"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
ConnectedTime int64 `json:"connectedTime"`
|
|
DeviceGroupID *int64 `json:"deviceGroupId"`
|
|
DeviceGroupName string `json:"deviceGroupName"`
|
|
}
|
|
|
|
type devList struct {
|
|
Items []devItem `json:"items"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
func (c *client) listDevices(t *testing.T, query string) devList {
|
|
t.Helper()
|
|
path := "/api/devices"
|
|
if query != "" {
|
|
path += "?" + query
|
|
}
|
|
st, env, err := c.do("GET", path, nil, true)
|
|
if err != nil {
|
|
t.Fatalf("list devices: %v", err)
|
|
}
|
|
if !env.OK {
|
|
t.Fatalf("list devices not ok: http=%d code=%s", st, env.Code)
|
|
}
|
|
var d devList
|
|
if err := json.Unmarshal(env.Data, &d); err != nil {
|
|
t.Fatalf("decode device list: %v", err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// ---------- rtty register helper (device protocol over TLS :5912) ----------
|
|
|
|
const (
|
|
regAttrDevid = 1
|
|
regAttrDesc = 2 // MAC carried here
|
|
regAttrToken = 3
|
|
)
|
|
|
|
func tlv(typ byte, v []byte) []byte {
|
|
b := []byte{typ, 0, 0}
|
|
binary.BigEndian.PutUint16(b[1:], uint16(len(v)))
|
|
return append(b, v...)
|
|
}
|
|
func frame(typ byte, payload []byte) []byte {
|
|
b := []byte{typ, 0, 0}
|
|
binary.BigEndian.PutUint16(b[1:], uint16(len(payload)))
|
|
return append(b, payload...)
|
|
}
|
|
|
|
// registerDevice opens a TLS connection to the device port and performs an rtty
|
|
// registration. Returns the live connection (caller must Close) and the server's
|
|
// register response code (0 = accepted, non-zero = rejected).
|
|
func registerDevice(addr, devid, mac, token string) (net.Conn, byte, error) {
|
|
conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
payload := []byte{5} // proto 5 (TLV)
|
|
payload = append(payload, tlv(regAttrDevid, []byte(devid))...)
|
|
payload = append(payload, tlv(regAttrDesc, []byte(mac))...)
|
|
if token != "" {
|
|
payload = append(payload, tlv(regAttrToken, []byte(token))...)
|
|
}
|
|
if _, err := conn.Write(frame(0 /*register*/, payload)); err != nil {
|
|
conn.Close()
|
|
return nil, 0, err
|
|
}
|
|
conn.SetReadDeadline(time.Now().Add(8 * time.Second))
|
|
head := make([]byte, 3)
|
|
if _, err := io.ReadFull(conn, head); err != nil {
|
|
conn.Close()
|
|
return nil, 0, err
|
|
}
|
|
blen := binary.BigEndian.Uint16(head[1:])
|
|
body := make([]byte, blen)
|
|
io.ReadFull(conn, body)
|
|
conn.SetReadDeadline(time.Time{})
|
|
var code byte
|
|
if len(body) > 0 {
|
|
code = body[0]
|
|
}
|
|
return conn, code, nil
|
|
}
|
|
|
|
// cleanupQADevices deletes every device whose ddns starts with the qa prefix.
|
|
func (c *client) cleanupQADevices(t *testing.T) {
|
|
t.Helper()
|
|
list := c.listDevices(t, "q=qae2e&pageSize=500")
|
|
for _, it := range list.Items {
|
|
if strings.HasPrefix(it.Ddns, "qae2e") {
|
|
c.do("DELETE", fmt.Sprintf("/api/devices/%d", it.ID), nil, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- the suite ----------
|
|
|
|
func TestE2E(t *testing.T) {
|
|
cfg := loadConfig(t)
|
|
c := newClient(cfg)
|
|
|
|
// ===== A. Auth =====
|
|
t.Run("A_auth", func(t *testing.T) {
|
|
// A5 public auth-config
|
|
st, env, err := c.do("GET", "/auth-config", nil, false)
|
|
if err != nil || !env.OK {
|
|
t.Fatalf("A5 auth-config not ok: http=%d err=%v", st, err)
|
|
}
|
|
// A3 unauthenticated device list rejected
|
|
c.token = ""
|
|
_, env, _ = c.do("GET", "/api/devices?pageSize=1", nil, true)
|
|
if env.OK {
|
|
t.Errorf("A3 expected auth required without token, got ok")
|
|
}
|
|
// A2 wrong password rejected
|
|
_, env, _ = c.do("POST", "/api/login", map[string]string{"username": cfg.user, "password": "definitely-wrong-xyz"}, false)
|
|
if env.OK {
|
|
t.Errorf("A2 expected login failure with wrong password")
|
|
}
|
|
})
|
|
|
|
if cfg.pass == "" {
|
|
t.Skip("QA_PASS not set; skipping authenticated tests")
|
|
}
|
|
c.login(t)
|
|
|
|
// ===== B. Pagination (invariants) =====
|
|
t.Run("B_pagination", func(t *testing.T) {
|
|
p1 := c.listDevices(t, "page=1&pageSize=10")
|
|
if len(p1.Items) > 10 {
|
|
t.Errorf("B1 page items %d > pageSize 10", len(p1.Items))
|
|
}
|
|
if p1.Total < len(p1.Items) {
|
|
t.Errorf("B1 total %d < page items %d", p1.Total, len(p1.Items))
|
|
}
|
|
if p1.Total <= 10 {
|
|
t.Skip("fewer than 11 devices; pagination cross-page checks skipped")
|
|
}
|
|
p2 := c.listDevices(t, "page=2&pageSize=10")
|
|
if len(p2.Items) > 0 && len(p1.Items) > 0 && p2.Items[0].ID == p1.Items[0].ID {
|
|
t.Errorf("B2 page2 first item equals page1 first item")
|
|
}
|
|
// B3 last page size = remainder
|
|
size := 10
|
|
pages := (p1.Total + size - 1) / size
|
|
last := c.listDevices(t, fmt.Sprintf("page=%d&pageSize=%d", pages, size))
|
|
wantLast := p1.Total - (pages-1)*size
|
|
if last.Total == p1.Total && len(last.Items) != wantLast {
|
|
t.Errorf("B3 last page items=%d want=%d (total=%d)", len(last.Items), wantLast, p1.Total)
|
|
}
|
|
// B4 out-of-range page → empty, total unchanged
|
|
oob := c.listDevices(t, fmt.Sprintf("page=%d&pageSize=%d", pages+50, size))
|
|
if len(oob.Items) != 0 {
|
|
t.Errorf("B4 out-of-range page returned %d items", len(oob.Items))
|
|
}
|
|
if oob.Total != p1.Total {
|
|
t.Errorf("B4 total changed on oob page: %d vs %d", oob.Total, p1.Total)
|
|
}
|
|
})
|
|
|
|
// ===== C. Search (invariants) =====
|
|
t.Run("C_search", func(t *testing.T) {
|
|
// C5 no-match → 0
|
|
none := c.listDevices(t, "q=zzz_no_such_device_zzz")
|
|
if none.Total != 0 || len(none.Items) != 0 {
|
|
t.Errorf("C5 non-matching search returned total=%d", none.Total)
|
|
}
|
|
// pick a real device to search for
|
|
base := c.listDevices(t, "page=1&pageSize=1")
|
|
if len(base.Items) == 0 {
|
|
t.Skip("no devices to exercise search")
|
|
}
|
|
d := base.Items[0]
|
|
// C1 search by ddns substring → contains it
|
|
got := c.listDevices(t, "q="+d.Ddns)
|
|
if !containsDdns(got.Items, d.Ddns) {
|
|
t.Errorf("C1 search by ddns %q did not return it", d.Ddns)
|
|
}
|
|
// C2 search by MAC with colons → still matches (colon-strip logic)
|
|
if d.Mac != "" {
|
|
withColons := insertColons(d.Mac)
|
|
gotMac := c.listDevices(t, "q="+withColons)
|
|
if !containsDdns(gotMac.Items, d.Ddns) {
|
|
t.Errorf("C2 search by colon-MAC %q did not return device %q", withColons, d.Ddns)
|
|
}
|
|
}
|
|
// C4 case-insensitive
|
|
gotUpper := c.listDevices(t, "q="+strings.ToUpper(d.Ddns))
|
|
if !containsDdns(gotUpper.Items, d.Ddns) {
|
|
t.Errorf("C4 uppercase search did not match")
|
|
}
|
|
})
|
|
|
|
// ===== D. Sort (invariants) =====
|
|
t.Run("D_sort", func(t *testing.T) {
|
|
for _, field := range []string{"ddns", "mac", "ip"} {
|
|
asc := c.listDevices(t, "sortBy="+field+"&order=asc&pageSize=50")
|
|
// online-first must always hold
|
|
if !onlineFirst(asc.Items) {
|
|
t.Errorf("D7 online-first violated when sorting by %s", field)
|
|
}
|
|
// within the same online-bucket, the field is ordered
|
|
if !fieldOrderedWithinBucket(asc.Items, field, true) {
|
|
t.Errorf("D sort by %s asc not ordered within status bucket", field)
|
|
}
|
|
desc := c.listDevices(t, "sortBy="+field+"&order=desc&pageSize=50")
|
|
if !fieldOrderedWithinBucket(desc.Items, field, false) {
|
|
t.Errorf("D sort by %s desc not ordered within status bucket", field)
|
|
}
|
|
}
|
|
})
|
|
|
|
// ===== E. Status filter (new feature) =====
|
|
t.Run("E_status_filter", func(t *testing.T) {
|
|
on := c.listDevices(t, "status=online&pageSize=50")
|
|
for _, it := range on.Items {
|
|
if it.Status != "online" {
|
|
t.Errorf("E1 status=online returned a %s device (%s)", it.Status, it.Ddns)
|
|
}
|
|
}
|
|
off := c.listDevices(t, "status=offline&pageSize=50")
|
|
for _, it := range off.Items {
|
|
if it.Status != "offline" {
|
|
t.Errorf("E2 status=offline returned a %s device (%s)", it.Status, it.Ddns)
|
|
}
|
|
}
|
|
all := c.listDevices(t, "pageSize=1")
|
|
// E4 invalid status ignored → behaves like all
|
|
bad := c.listDevices(t, "status=foobar&pageSize=1")
|
|
if bad.Total != all.Total {
|
|
t.Errorf("E4 invalid status changed total: %d vs %d", bad.Total, all.Total)
|
|
}
|
|
// E6 online+offline totals reconcile with all (allowing live drift)
|
|
if on.Total+off.Total > all.Total {
|
|
t.Errorf("E6 online(%d)+offline(%d) > all(%d)", on.Total, off.Total, all.Total)
|
|
}
|
|
})
|
|
|
|
// ===== G. Device protocol (L2) =====
|
|
if cfg.devAddr != "" {
|
|
t.Run("G_device_protocol", func(t *testing.T) {
|
|
c.cleanupQADevices(t)
|
|
defer c.cleanupQADevices(t)
|
|
|
|
devid := "qae2e_ok"
|
|
mac := "02ffqae20001"
|
|
// G2 empty MAC → rejected
|
|
if conn, code, err := registerDevice(cfg.devAddr, "qae2e_nomac", "", cfg.devToken); err == nil {
|
|
conn.Close()
|
|
if code == 0 {
|
|
t.Errorf("G2 empty-MAC registration was accepted (code 0)")
|
|
}
|
|
}
|
|
// G3 bad token → rejected (only meaningful if server enforces a token)
|
|
if cfg.devToken != "" {
|
|
if conn, code, err := registerDevice(cfg.devAddr, "qae2e_badtok", "02ffqae29999", "wrong-token-xyz"); err == nil {
|
|
conn.Close()
|
|
if code == 0 {
|
|
t.Errorf("G3 bad-token registration was accepted (code 0)")
|
|
}
|
|
}
|
|
}
|
|
// G1 valid registration → device appears online with correct MAC
|
|
conn, code, err := registerDevice(cfg.devAddr, devid, mac, cfg.devToken)
|
|
if err != nil {
|
|
t.Fatalf("G1 register failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
if code != 0 {
|
|
t.Fatalf("G1 valid registration rejected, code=%d", code)
|
|
}
|
|
time.Sleep(1500 * time.Millisecond)
|
|
got := c.listDevices(t, "q="+devid)
|
|
var found *devItem
|
|
for i := range got.Items {
|
|
if got.Items[i].Ddns == devid {
|
|
found = &got.Items[i]
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("G1 registered device %q not in list", devid)
|
|
}
|
|
if found.Status != "online" {
|
|
t.Errorf("G1 device status=%q want online", found.Status)
|
|
}
|
|
if found.Mac != mac {
|
|
t.Errorf("G1 device mac=%q want %q", found.Mac, mac)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ===== L. Real device E2E (L3) =====
|
|
if cfg.realID != "" {
|
|
t.Run("L_real_device", func(t *testing.T) {
|
|
got := c.listDevices(t, "q="+cfg.realID)
|
|
var found *devItem
|
|
for i := range got.Items {
|
|
if got.Items[i].Ddns == cfg.realID {
|
|
found = &got.Items[i]
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("L1 real device %q not found in list", cfg.realID)
|
|
}
|
|
if found.Status != "online" {
|
|
t.Errorf("L1 real device %q status=%q want online (is the KVM connected?)", cfg.realID, found.Status)
|
|
}
|
|
if cfg.realMAC != "" && found.Mac != cfg.realMAC {
|
|
t.Errorf("L1 real device mac=%q want %q", found.Mac, cfg.realMAC)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------- assertion helpers ----------
|
|
|
|
func containsDdns(items []devItem, ddns string) bool {
|
|
for _, it := range items {
|
|
if it.Ddns == ddns {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func insertColons(mac string) string {
|
|
if len(mac) != 12 {
|
|
return mac
|
|
}
|
|
var p []string
|
|
for i := 0; i < 12; i += 2 {
|
|
p = append(p, mac[i:i+2])
|
|
}
|
|
return strings.Join(p, ":")
|
|
}
|
|
|
|
func onlineFirst(items []devItem) bool {
|
|
seenOffline := false
|
|
for _, it := range items {
|
|
if it.Status == "online" && seenOffline {
|
|
return false
|
|
}
|
|
if it.Status != "online" {
|
|
seenOffline = true
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func fieldVal(it devItem, field string) string {
|
|
switch field {
|
|
case "ddns":
|
|
return it.Ddns
|
|
case "mac":
|
|
return it.Mac
|
|
case "ip":
|
|
return it.IP
|
|
case "description":
|
|
return it.Description
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fieldOrderedWithinBucket checks the field is sorted within each status bucket
|
|
// (online block, then offline block), matching the server's online-first rule.
|
|
func fieldOrderedWithinBucket(items []devItem, field string, asc bool) bool {
|
|
buckets := map[string][]string{}
|
|
order := []string{}
|
|
for _, it := range items {
|
|
if _, ok := buckets[it.Status]; !ok {
|
|
order = append(order, it.Status)
|
|
}
|
|
buckets[it.Status] = append(buckets[it.Status], fieldVal(it, field))
|
|
}
|
|
for _, st := range order {
|
|
vals := buckets[st]
|
|
sorted := make([]string, len(vals))
|
|
copy(sorted, vals)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
if asc {
|
|
return sorted[i] < sorted[j]
|
|
}
|
|
return sorted[i] > sorted[j]
|
|
})
|
|
for i := range vals {
|
|
if vals[i] != sorted[i] {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// F / H / I — groups & RBAC, event logs, CRUD. Modular, self-contained tests.
|
|
// ============================================================================
|
|
|
|
type evtItem struct {
|
|
DeviceMac string `json:"deviceMac"`
|
|
EventType string `json:"eventType"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
}
|
|
|
|
// ---- generic request helpers ----
|
|
|
|
func (c *client) post(t *testing.T, path string, body any) apiEnvelope {
|
|
t.Helper()
|
|
_, env, err := c.do("POST", path, body, true)
|
|
if err != nil {
|
|
t.Fatalf("POST %s: %v", path, err)
|
|
}
|
|
return env
|
|
}
|
|
func (c *client) put(t *testing.T, path string, body any) apiEnvelope {
|
|
t.Helper()
|
|
_, env, err := c.do("PUT", path, body, true)
|
|
if err != nil {
|
|
t.Fatalf("PUT %s: %v", path, err)
|
|
}
|
|
return env
|
|
}
|
|
func (c *client) del(t *testing.T, path string) {
|
|
t.Helper()
|
|
c.do("DELETE", path, nil, true)
|
|
}
|
|
|
|
func loginClient(t *testing.T, cfg config, user, pass string) *client {
|
|
c := newClient(cfg)
|
|
c.cfg.user, c.cfg.pass = user, pass
|
|
_, env, err := c.do("POST", "/api/login", map[string]string{"username": user, "password": pass}, false)
|
|
if err != nil || !env.OK {
|
|
return nil
|
|
}
|
|
var d struct {
|
|
Token string `json:"token"`
|
|
}
|
|
json.Unmarshal(env.Data, &d)
|
|
if d.Token == "" {
|
|
return nil
|
|
}
|
|
c.token = d.Token
|
|
return c
|
|
}
|
|
|
|
// ---- fixture helpers ----
|
|
|
|
func (c *client) createDeviceGroup(t *testing.T, name string) int64 {
|
|
t.Helper()
|
|
env := c.post(t, "/api/device-groups", map[string]any{"name": name})
|
|
if !env.OK {
|
|
t.Fatalf("create device-group %q: %s", name, env.Code)
|
|
}
|
|
var d struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
json.Unmarshal(env.Data, &d)
|
|
if d.ID == 0 {
|
|
t.Fatalf("create device-group returned no id")
|
|
}
|
|
return d.ID
|
|
}
|
|
func (c *client) createUserGroup(t *testing.T, name string) int64 {
|
|
t.Helper()
|
|
env := c.post(t, "/api/user-groups", map[string]any{"name": name})
|
|
if !env.OK {
|
|
t.Fatalf("create user-group %q: %s", name, env.Code)
|
|
}
|
|
var d struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
json.Unmarshal(env.Data, &d)
|
|
return d.ID
|
|
}
|
|
func (c *client) linkUGtoDG(t *testing.T, ugID int64, dgIDs []int64) {
|
|
t.Helper()
|
|
env := c.put(t, fmt.Sprintf("/api/user-groups/%d/device-groups", ugID), map[string]any{"deviceGroupIds": dgIDs})
|
|
if !env.OK {
|
|
t.Fatalf("link ug %d -> dg: %s", ugID, env.Code)
|
|
}
|
|
}
|
|
func (c *client) createNormalUser(t *testing.T, username, pass string, ugIDs []int64) {
|
|
t.Helper()
|
|
env := c.post(t, "/api/users", map[string]any{
|
|
"role": "user", "username": username, "password": pass, "repassword": pass, "userGroupIds": ugIDs,
|
|
})
|
|
if !env.OK {
|
|
t.Fatalf("create user %q: %s", username, env.Code)
|
|
}
|
|
}
|
|
func (c *client) findUserID(t *testing.T, username string) int64 {
|
|
t.Helper()
|
|
_, env, _ := c.do("GET", "/api/users", nil, true)
|
|
var d struct {
|
|
Items []struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
} `json:"items"`
|
|
}
|
|
json.Unmarshal(env.Data, &d)
|
|
for _, u := range d.Items {
|
|
if u.Username == username {
|
|
return u.ID
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
func (c *client) assignDevicesToGroup(t *testing.T, dgID int64, devIDs []int64) {
|
|
t.Helper()
|
|
env := c.put(t, fmt.Sprintf("/api/device-groups/%d/devices", dgID), map[string]any{"deviceIds": devIDs})
|
|
if !env.OK {
|
|
t.Fatalf("assign devices to dg %d: %s", dgID, env.Code)
|
|
}
|
|
}
|
|
func (c *client) me(t *testing.T) []string {
|
|
t.Helper()
|
|
_, env, _ := c.do("GET", "/api/me", nil, true)
|
|
var d struct {
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
json.Unmarshal(env.Data, &d)
|
|
return d.Permissions
|
|
}
|
|
func (c *client) listEventLogs(t *testing.T, mac, types string) []evtItem {
|
|
t.Helper()
|
|
_, env, _ := c.do("GET", fmt.Sprintf("/api/device-event-logs?mac=%s&types=%s&pageSize=50", mac, types), nil, true)
|
|
var d struct {
|
|
Items []evtItem `json:"items"`
|
|
}
|
|
json.Unmarshal(env.Data, &d)
|
|
return d.Items
|
|
}
|
|
|
|
// registerQAOnline registers a qa device and returns the live conn + its DB id.
|
|
func (c *client) registerQAOnline(t *testing.T, devid, mac string) (net.Conn, int64) {
|
|
t.Helper()
|
|
conn, code, err := registerDevice(c.cfg.devAddr, devid, mac, c.cfg.devToken)
|
|
if err != nil {
|
|
t.Fatalf("register %s: %v", devid, err)
|
|
}
|
|
if code != 0 {
|
|
conn.Close()
|
|
t.Fatalf("register %s rejected, code=%d", devid, code)
|
|
}
|
|
time.Sleep(1500 * time.Millisecond)
|
|
got := c.listDevices(t, "q="+devid)
|
|
for _, it := range got.Items {
|
|
if it.Ddns == devid {
|
|
return conn, it.ID
|
|
}
|
|
}
|
|
conn.Close()
|
|
t.Fatalf("registered device %s not found in list", devid)
|
|
return nil, 0
|
|
}
|
|
|
|
func findItem(items []devItem, ddns string) *devItem {
|
|
for i := range items {
|
|
if items[i].Ddns == ddns {
|
|
return &items[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func contains(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func anyEvent(items []evtItem, mac, typ string) bool {
|
|
for _, e := range items {
|
|
if e.DeviceMac == mac && e.EventType == typ {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ===== F. Group filter + RBAC visibility =====
|
|
func TestE2E_GroupsRBAC(t *testing.T) {
|
|
cfg := loadConfig(t)
|
|
if cfg.pass == "" {
|
|
t.Skip("QA_PASS required")
|
|
}
|
|
c := newClient(cfg)
|
|
c.login(t)
|
|
|
|
t.Run("F2_unassigned", func(t *testing.T) {
|
|
un := c.listDevices(t, "unassigned=true&pageSize=50")
|
|
for _, it := range un.Items {
|
|
if it.DeviceGroupID != nil {
|
|
t.Errorf("F2 unassigned=true returned grouped device %s", it.Ddns)
|
|
}
|
|
}
|
|
})
|
|
|
|
if cfg.devAddr == "" {
|
|
t.Skip("QA_DEV_ADDR required for RBAC fixture")
|
|
}
|
|
t.Run("F3_visibility_and_perm_enforcement", func(t *testing.T) {
|
|
c.cleanupQADevices(t)
|
|
defer c.cleanupQADevices(t)
|
|
dgID := c.createDeviceGroup(t, "qae2e_dg")
|
|
ugID := c.createUserGroup(t, "qae2e_ug")
|
|
c.linkUGtoDG(t, ugID, []int64{dgID})
|
|
uname, upass := "qae2e_user", "Qae2ePass123!"
|
|
c.createNormalUser(t, uname, upass, []int64{ugID})
|
|
defer func() {
|
|
if uid := c.findUserID(t, uname); uid > 0 {
|
|
c.del(t, fmt.Sprintf("/api/users/%d", uid))
|
|
}
|
|
c.del(t, fmt.Sprintf("/api/user-groups/%d", ugID))
|
|
c.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
|
}()
|
|
|
|
conn, devID := c.registerQAOnline(t, "qae2e_vis", "02ffqaevis01")
|
|
defer conn.Close()
|
|
c.assignDevicesToGroup(t, dgID, []int64{devID})
|
|
|
|
nu := loginClient(t, cfg, uname, upass)
|
|
if nu == nil {
|
|
t.Fatal("normal user login failed")
|
|
}
|
|
vl := nu.listDevices(t, "pageSize=100")
|
|
// F3: the user sees their group device, and ONLY devices in their group.
|
|
if findItem(vl.Items, "qae2e_vis") == nil {
|
|
t.Errorf("F3 normal user cannot see their own group's device")
|
|
}
|
|
for _, it := range vl.Items {
|
|
if it.DeviceGroupID == nil || *it.DeviceGroupID != dgID {
|
|
t.Errorf("F3 normal user saw out-of-scope device %s (group=%v)", it.Ddns, it.DeviceGroupID)
|
|
}
|
|
}
|
|
// Permission enforcement must be consistent with declared permissions.
|
|
perms := nu.me(t)
|
|
hasWrite := contains(perms, "device.write")
|
|
_, env, _ := nu.do("DELETE", fmt.Sprintf("/api/devices/%d", devID), nil, true)
|
|
denied := !env.OK
|
|
if hasWrite && denied {
|
|
t.Errorf("RBAC inconsistent: user HAS device.write but delete was denied")
|
|
}
|
|
if !hasWrite && !denied {
|
|
t.Errorf("RBAC inconsistent: user LACKS device.write but delete succeeded")
|
|
}
|
|
t.Logf("normal user perms=%v device.write=%v deleteDenied=%v", perms, hasWrite, denied)
|
|
})
|
|
}
|
|
|
|
// ===== H. Event logs =====
|
|
func TestE2E_EventLogs(t *testing.T) {
|
|
cfg := loadConfig(t)
|
|
if cfg.pass == "" || cfg.devAddr == "" {
|
|
t.Skip("QA_PASS + QA_DEV_ADDR required")
|
|
}
|
|
c := newClient(cfg)
|
|
c.login(t)
|
|
c.cleanupQADevices(t)
|
|
defer c.cleanupQADevices(t)
|
|
|
|
devid, mac := "qae2e_evt", "02ffqaeevt01"
|
|
conn, _ := c.registerQAOnline(t, devid, mac)
|
|
|
|
t.Run("H1_online_event", func(t *testing.T) {
|
|
// poll a few seconds for the online event
|
|
for i := 0; i < 6; i++ {
|
|
if anyEvent(c.listEventLogs(t, mac, "device_online"), mac, "device_online") {
|
|
return
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
t.Errorf("H1 no device_online event recorded for %s (mac %s)", devid, mac)
|
|
})
|
|
|
|
t.Run("H2_offline_event", func(t *testing.T) {
|
|
conn.Close() // clean disconnect -> server should mark offline + log
|
|
for i := 0; i < 12; i++ {
|
|
if anyEvent(c.listEventLogs(t, mac, "device_offline"), mac, "device_offline") {
|
|
return
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
t.Errorf("H2 no device_offline event within 12s after disconnect")
|
|
})
|
|
}
|
|
|
|
// ===== I. Device CRUD (admin) =====
|
|
func TestE2E_CRUD(t *testing.T) {
|
|
cfg := loadConfig(t)
|
|
if cfg.pass == "" || cfg.devAddr == "" {
|
|
t.Skip("QA_PASS + QA_DEV_ADDR required")
|
|
}
|
|
c := newClient(cfg)
|
|
c.login(t)
|
|
c.cleanupQADevices(t)
|
|
defer c.cleanupQADevices(t)
|
|
|
|
dgID := c.createDeviceGroup(t, "qae2e_crud_dg")
|
|
defer c.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
|
|
|
conn, devID := c.registerQAOnline(t, "qae2e_crud", "02ffqaecrud1")
|
|
|
|
t.Run("I1_update_description", func(t *testing.T) {
|
|
env := c.put(t, fmt.Sprintf("/api/devices/%d", devID), map[string]string{"description": "qa-updated-desc"})
|
|
if !env.OK {
|
|
t.Fatalf("I1 update failed: %s", env.Code)
|
|
}
|
|
d := findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud")
|
|
if d == nil || d.Description != "qa-updated-desc" {
|
|
t.Errorf("I1 description not updated, got %+v", d)
|
|
}
|
|
})
|
|
|
|
t.Run("I3_move_to_group", func(t *testing.T) {
|
|
env := c.post(t, "/api/devices/move-to-device-group", map[string]any{"groupId": dgID, "deviceIds": []int64{devID}})
|
|
if !env.OK {
|
|
t.Fatalf("I3 move failed: %s", env.Code)
|
|
}
|
|
d := findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud")
|
|
if d == nil || d.DeviceGroupID == nil || *d.DeviceGroupID != dgID {
|
|
t.Errorf("I3 device not in group %d, got %+v", dgID, d)
|
|
}
|
|
})
|
|
|
|
t.Run("I2_delete", func(t *testing.T) {
|
|
conn.Close()
|
|
c.del(t, fmt.Sprintf("/api/devices/%d", devID))
|
|
if findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud") != nil {
|
|
t.Errorf("I2 device still present after delete")
|
|
}
|
|
})
|
|
}
|