Files
3x-ui/internal/web/service/client_hwid_test.go
T
Kuzz007 1250fbb734 feat(clients): allow removing a single HWID device (#6265)
* feat(clients): allow removing a single HWID device

Only "list" and "clear all" existed for registered HWID devices, so
freeing one slot under a client's HWID limit meant clearing every
device and waiting for the ones you kept to re-register. Adds a
per-device delete: DELETE /panel/api/clients/hwids/:email/:id, scoped
to the client's own sub_id (device ids are a global auto-increment,
not per-subID, so this also prevents deleting another client's
device), plus a delete button next to each device in the existing
HWID modal.

Addresses MHSanaei/3x-ui#6245.

* feat(clients): surface HWID limit + device log in the client info card

Mirrors the existing IP-limit row/eye-icon-modal pattern that's
already in this card. The HWID devices modal reuses the same
list/clear-all/per-device-delete UI already shipped for the edit
form's own HWID modal, so a device can be removed without opening the
edit form at all.

* i18n: add HWID single-delete strings to all 13 locales

deleteHwid/deleteHwidConfirm/hwidDeleted were only added to en-US and
ru-RU in the previous commit; backfilling the other 11 locales the
project's own translation set covers.

* fix(clients): address automated review of HWID single-delete PR

- ClientInfoModal: use the existing dateLabel() helper (Jalali-aware)
  for HWID first/last-seen instead of a raw dayjs format, matching
  every other timestamp in the same modal.
- Add okText/cancelText to the delete-device Popconfirm in both
  ClientInfoModal and ClientFormModal so all 13 locales get a
  translated confirm dialog instead of Antd's English default.
- deleteHwid controller: stop reusing the success toast key on both
  error paths, which rendered a red "Update successful" toast on a
  real (not just theoretical) failure such as a stale HWID modal.
- Trim DeleteClientHwid's doc comment to the repo's 2-line cap and
  correct it: deletion is scoped by sub_id, which can span more than
  one ClientRecord, not strictly "this client only".
- Add TestDeleteClientHwid covering cross-sub_id id rejection, unknown
  id rejection, and a real successful delete.

* chore: retrigger CI (previous run stuck installing Playwright Chromium)

* fix(clients): address the arbiter review on the HWID single-delete PR

- Extract the HWID device list into a shared frontend/src/lib/clients/
  hwid-log.ts type/normalizer, a shared useClientHwids hook, and a
  shared ClientHwidListModal component, mirroring the existing IP-log
  pattern. ClientInfoModal and ClientFormModal both render the same
  component now, so the two copies can no longer drift the way they
  already had (different date formatting, different tag styles).
- Add a Popconfirm to the HWID "Clear all" button (previously
  unconfirmed, unlike the per-device delete right next to it) — closes
  the confirm/no-confirm asymmetry the review flagged as the main risk.
- Sync docs/public/openapi.json with the two hwids paths and regenerate
  clients.mdx. Scoped to just those two paths rather than a full copy
  from frontend/public/openapi.json: the docs copy is far enough behind
  on unrelated paths (a host-group API rename) that a full sync breaks
  the Next.js build on locale pages referencing the old shape — out of
  scope for this PR.

* fix(clients): trim HWID list comment blocks to 2 lines

Repo convention caps comment blocks at 2 lines; both were 1 line over.

* chore: retrigger CI

build (arm64) and build (armv6) failed on a transient Go module proxy
network error (INTERNAL_ERROR stream reset), unrelated to this PR's
changes.
2026-08-21 14:17:05 +02:00

220 lines
6.7 KiB
Go

package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func initClientHwidTestDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
}
func seedHwidClient(t *testing.T, limit int) *model.ClientRecord {
t.Helper()
rec := &model.ClientRecord{
Email: "hwid@example.com",
SubID: "sub-hwid",
UUID: "11111111-2222-4333-8444-555555555555",
Enable: true,
LimitHwid: limit,
}
if err := database.GetDB().Create(rec).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
return rec
}
func TestClientHwidGate(t *testing.T) {
initClientHwidTestDB(t)
svc := &ClientService{}
seedHwidClient(t, 0)
res, err := svc.EnforceHwidForSubID("sub-hwid", HwidRequest{})
if err != nil {
t.Fatalf("no-limit gate: %v", err)
}
if !res.Allowed || res.Active {
t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
}
}
func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
initClientHwidTestDB(t)
svc := &ClientService{}
rec := seedHwidClient(t, 2)
res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{})
if err != nil {
t.Fatalf("missing HWID gate: %v", err)
}
if res.Allowed || !res.Active || !res.NotSupported {
t.Fatalf("missing HWID should be denied as not supported: %+v", res)
}
firstRaw := "device-one"
for _, raw := range []string{firstRaw, "device-two"} {
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
Hwid: raw,
UserAgent: "Happ/1.0",
DeviceOS: "android",
OsVersion: "15",
DeviceModel: raw + "-model",
})
if err != nil {
t.Fatalf("register %s: %v", raw, err)
}
if !res.Allowed {
t.Fatalf("register %s denied: %+v", raw, res)
}
}
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-three"})
if err != nil {
t.Fatalf("third HWID gate: %v", err)
}
if res.Allowed || !res.MaxDevicesReached || !res.LimitReached {
t.Fatalf("third unique HWID should be denied after limit: %+v", res)
}
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
Hwid: firstRaw,
UserAgent: "Karing/2.0",
DeviceOS: "ios",
OsVersion: "18",
DeviceModel: "updated-model",
})
if err != nil {
t.Fatalf("existing HWID after full limit: %v", err)
}
if !res.Allowed || !res.LimitReached {
t.Fatalf("existing registered HWID should pass after limit: %+v", res)
}
var hashes []string
if err := database.GetDB().Model(&model.ClientHwid{}).Pluck("hwid_hash", &hashes).Error; err != nil {
t.Fatalf("pluck hashes: %v", err)
}
if len(hashes) != 2 {
t.Fatalf("stored HWIDs = %d, want 2", len(hashes))
}
for _, h := range hashes {
if h == firstRaw || h == "device-two" || len(h) != 64 {
t.Fatalf("raw HWID leaked or invalid hash stored: %q", h)
}
}
list, err := svc.ListClientHwids(rec.Email)
if err != nil {
t.Fatalf("list HWIDs: %v", err)
}
if len(list) != 2 {
t.Fatalf("list count = %d, want 2", len(list))
}
foundUpdated := false
for _, row := range list {
if row.DeviceModel == "updated-model" && row.UserAgent == "Karing/2.0" && row.DeviceOS == "ios" && row.OsVersion == "18" {
foundUpdated = true
}
}
if !foundUpdated {
t.Fatalf("updated HWID metadata missing: %#v", list)
}
if err := svc.setClientLimitHwidByEmail(nil, rec.Email, 1); err != nil {
t.Fatalf("lower limit: %v", err)
}
var count int64
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
t.Fatalf("count after trim: %v", err)
}
if count != 1 {
t.Fatalf("lowered limit should trim stored HWIDs to 1, got %d", count)
}
if err := svc.ClearClientHwids(rec.Email); err != nil {
t.Fatalf("clear HWIDs: %v", err)
}
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
t.Fatalf("count after clear: %v", err)
}
if count != 0 {
t.Fatalf("clear should remove all HWIDs, got %d", count)
}
}
func TestDeleteClientHwid(t *testing.T) {
initClientHwidTestDB(t)
svc := &ClientService{}
db := database.GetDB()
rec := seedHwidClient(t, 5)
if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-own"}); err != nil {
t.Fatalf("register own device: %v", err)
}
list, err := svc.ListClientHwids(rec.Email)
if err != nil || len(list) != 1 {
t.Fatalf("list own devices: err=%v list=%+v", err, list)
}
ownID := list[0].Id
other := &model.ClientRecord{Email: "other@example.com", SubID: "sub-other", UUID: "33333333-2222-4333-8444-555555555555", Enable: true, LimitHwid: 5}
if err := db.Create(other).Error; err != nil {
t.Fatalf("seed other client: %v", err)
}
if _, err := svc.EnforceHwidForSubID(other.SubID, HwidRequest{Hwid: "device-foreign"}); err != nil {
t.Fatalf("register foreign device: %v", err)
}
otherList, err := svc.ListClientHwids(other.Email)
if err != nil || len(otherList) != 1 {
t.Fatalf("list foreign devices: err=%v list=%+v", err, otherList)
}
foreignID := otherList[0].Id
if err := svc.DeleteClientHwid(rec.Email, foreignID); err == nil {
t.Fatalf("deleting a foreign sub_id's device id should fail")
}
if list, err := svc.ListClientHwids(other.Email); err != nil || len(list) != 1 {
t.Fatalf("foreign device should survive a cross-sub_id delete attempt: err=%v list=%+v", err, list)
}
if err := svc.DeleteClientHwid(rec.Email, 999999); err == nil {
t.Fatalf("deleting an unknown id should fail")
}
if err := svc.DeleteClientHwid(rec.Email, ownID); err != nil {
t.Fatalf("delete own device: %v", err)
}
if list, err := svc.ListClientHwids(rec.Email); err != nil || len(list) != 0 {
t.Fatalf("own device should be gone: err=%v list=%+v", err, list)
}
}
func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
initClientHwidTestDB(t)
svc := &ClientService{}
db := database.GetDB()
subID := "shared-sub"
if err := db.Create(&model.ClientRecord{Email: "a@ex.com", SubID: subID, UUID: "11111111-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil {
t.Fatalf("seed anchor: %v", err)
}
if err := db.Create(&model.ClientRecord{Email: "b@ex.com", SubID: subID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 2}).Error; err != nil {
t.Fatalf("seed second: %v", err)
}
res, err := svc.EnforceHwidForSubID(subID, HwidRequest{})
if err != nil || !res.Active || res.Limit != 2 {
t.Fatalf("expected active gate limit 2 from max row, err=%v res=%+v", err, res)
}
if res.Allowed || !res.NotSupported {
t.Fatalf("missing HWID should be denied: %+v", res)
}
}