diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index 6d7b01b97..813dbd605 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -92,10 +92,12 @@ func normalizeHwidRequest(req HwidRequest) HwidRequest { } } +// The subscription body is built for disabled clients too, so an expired, +// depleted or suspended client must keep holding the limit of its subId. func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) { var limit int err := tx.Model(&model.ClientRecord{}). - Where("sub_id = ? AND enable = ?", subID, true). + Where("sub_id = ?", subID). Select("COALESCE(MAX(limit_hwid), 0)"). Scan(&limit).Error return limit, err @@ -216,7 +218,7 @@ func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) { } // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or -// last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced. +// last_seen. It shares the gate's limit query, so limit == limit enforced. func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) { subID = strings.TrimSpace(subID) if subID == "" { diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index 2d960322a..11a3e13df 100644 --- a/internal/web/service/client_hwid_test.go +++ b/internal/web/service/client_hwid_test.go @@ -288,6 +288,48 @@ func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) { } } +func TestClientHwidGateHoldsWhileClientDisabled(t *testing.T) { + initClientHwidTestDB(t) + svc := &ClientService{} + db := database.GetDB() + rec := seedHwidClient(t, 1) + + if res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed { + t.Fatalf("register first device = %+v, %v", res, err) + } + if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + + assertStillLimited := func(stage string) { + t.Helper() + res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-two"}) + if err != nil { + t.Fatalf("%s: second device: %v", stage, err) + } + if res.Allowed || !res.MaxDevicesReached || res.Limit != 1 { + t.Fatalf("%s: disabled client must keep its device limit: %+v", stage, res) + } + if res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed { + t.Fatalf("%s: registered device = %+v, %v", stage, res, err) + } + var count int64 + if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil { + t.Fatalf("%s: count HWIDs: %v", stage, err) + } + if count != 1 { + t.Fatalf("%s: stored HWIDs = %d, want 1", stage, count) + } + } + assertStillLimited("only client disabled") + + // An enabled sibling without a limit must not lift it either. + if err := db.Create(&model.ClientRecord{Email: "second@example.com", SubID: rec.SubID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil { + t.Fatalf("seed second client: %v", err) + } + assertStillLimited("unlimited sibling enabled") +} + func TestClientHwidSlotStatus(t *testing.T) { initClientHwidTestDB(t) svc := &ClientService{}