Show certificates actual total count in table (#32972)

fixes: #32103 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.


## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

<img width="1682" height="688" alt="image"
src="https://github.com/user-attachments/assets/d4f59612-782e-4747-9090-b2895edc76ba"
/>
This commit is contained in:
Magnus Jensen
2025-09-17 14:05:32 +03:00
committed by GitHub
parent baedfd083a
commit 20c30406a8
10 changed files with 99 additions and 17 deletions
@@ -0,0 +1 @@
* Return count in list host certificates API response, and use it in the certificate table
+1
View File
@@ -43,6 +43,7 @@ const DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK: IGetHostCertificatesResponse = {
has_next_results: false,
has_previous_results: false,
},
count: 1,
};
export const createMockGetHostCertificatesResponse = (
@@ -88,9 +88,7 @@ const CertificatesTable = ({
disableMultiRowSelect
onSelectSingleRow={onClickTableRow}
renderTableHelpText={() => helpText}
renderCount={() => (
<TableCount name="certificates" count={data.certificates.length} />
)}
renderCount={() => <TableCount name="certificates" count={data.count} />}
pageSize={pageSize}
pageIndex={page}
defaultSortHeader={sortHeader}
@@ -43,6 +43,7 @@ export interface IGetDeviceCertificatesResponse {
has_next_results: boolean;
has_previous_results: boolean;
};
count: number;
}
export interface IGetDeviceCertsRequestParams extends IListOptions {
+1
View File
@@ -216,6 +216,7 @@ export interface IGetHostCertificatesResponse {
has_next_results: boolean;
has_previous_results: boolean;
};
count: number;
}
export type ILoadHostDetailsExtension = "macadmins";
+1
View File
@@ -79,6 +79,7 @@ export const defaultDeviceCertificatesHandler = http.get(
has_next_results: false,
has_previous_results: false,
},
count: 1,
});
}
);
+22 -11
View File
@@ -254,7 +254,6 @@ func loadHostCertIDsForSHA1DB(ctx context.Context, tx sqlx.QueryerContext, hostI
var certs []*fleet.HostCertificateRecord
stmt, args, err := sqlx.In(stmt, binarySHA1s, hostID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building load host cert ids query")
}
@@ -271,7 +270,16 @@ func loadHostCertIDsForSHA1DB(ctx context.Context, tx sqlx.QueryerContext, hostI
}
func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
stmt := `
const fromWhereClause = `
FROM
host_certificates hc
INNER JOIN host_certificate_sources hcs ON hc.id = hcs.host_certificate_id
WHERE
hc.host_id = ?
AND hc.deleted_at IS NULL
`
stmt := fmt.Sprintf(`
SELECT
hc.id,
hc.sha1_sum,
@@ -297,15 +305,14 @@ SELECT
hc.issuer_common_name,
hcs.source,
hcs.username
FROM
host_certificates hc
INNER JOIN host_certificate_sources hcs ON hc.id = hcs.host_certificate_id
WHERE
hc.host_id = ?
AND hc.deleted_at IS NULL`
%s`, fromWhereClause)
args := []interface{}{hostID}
stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, args, &opts)
countStmt := fmt.Sprintf(`
SELECT COUNT(*) %s
`, fromWhereClause)
baseArgs := []interface{}{hostID}
stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, baseArgs, &opts)
var certs []*fleet.HostCertificateRecord
if err := sqlx.SelectContext(ctx, tx, &certs, stmtPaged, args...); err != nil {
@@ -314,7 +321,11 @@ WHERE
var metaData *fleet.PaginationMetadata
if opts.IncludeMetadata {
metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0}
var count uint
if err := sqlx.GetContext(ctx, tx, &count, countStmt, baseArgs...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "counting host certificates")
}
metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0, TotalResults: count}
if len(certs) > int(opts.PerPage) { //nolint:gosec // dismiss G115
metaData.HasNextResults = true
certs = certs[:len(certs)-1]
@@ -32,6 +32,7 @@ func TestHostCertificates(t *testing.T) {
{"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation},
{"Create certificates with long country code", testHostCertificateWithInvalidCountryCode},
{"Truncate long certificate fields", testTruncateLongCertificateFields},
{"Count matches main query", testListHostCertificatesCountMatches},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -79,7 +80,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload))
// verify that we saved the records correctly
certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
certs, meta, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, certs, 2)
require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName)
@@ -88,6 +89,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName)
require.Equal(t, fleet.SystemHostCertificate, certs[1].Source)
require.EqualValues(t, 2, meta.TotalResults)
// order by not_valid_after descending
certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "not_valid_after", OrderDirection: fleet.OrderAscending})
@@ -728,3 +730,67 @@ func testTruncateLongCertificateFields(t *testing.T, ds *Datastore) {
assert.Equal(t, fleet.UserHostCertificate, savedCert.Source, "Source should not be changed")
assert.Equal(t, host.ID, savedCert.HostID, "HostID should not be changed")
}
func testListHostCertificatesCountMatches(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create host
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("count-mismatch-host-osquery-id"),
NodeKey: ptr.String("count-mismatch-host-node-key"),
UUID: "count-mismatch-host-uuid",
Hostname: "count-mismatch-host",
})
require.NoError(t, err)
// create a cert template and record
certTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "count.example.com",
Organization: []string{"Org"},
OrganizationalUnit: []string{"Eng"},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.example.com",
Organization: []string{"Issuer"},
},
SerialNumber: big.NewInt(424242),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
certRec := generateTestHostCertificateRecord(t, host.ID, &certTemplate)
// Update using ds.UpdateHostCertificates with two sources: system and user
certSys := *certRec
certSys.Source = fleet.SystemHostCertificate
certSys.Username = ""
certUser := *certRec
certUser.Source = fleet.UserHostCertificate
certUser.Username = "alice"
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}))
// Now list with metadata
certs, meta, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.NotNil(t, meta)
// We expect two returned rows (one per source)
require.Len(t, certs, 2)
require.Equal(t, uint(len(certs)), meta.TotalResults, "expected total results to match returned rows")
}
+2 -1
View File
@@ -883,6 +883,7 @@ func (r *listDeviceCertificatesRequest) deviceAuthToken() string {
type listDeviceCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
}
@@ -903,7 +904,7 @@ func listDeviceCertificatesEndpoint(ctx context.Context, request interface{}, sv
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listDeviceCertificatesResponse{Certificates: res, Meta: meta}, nil
return listDeviceCertificatesResponse{Certificates: res, Meta: meta, Count: meta.TotalResults}, nil
}
/////////////////////////////////////////////////////////////////////////////////
+2 -1
View File
@@ -2972,6 +2972,7 @@ func (r *listHostCertificatesRequest) ValidateRequest() error {
type listHostCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
}
@@ -2986,7 +2987,7 @@ func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil
return listHostCertificatesResponse{Certificates: res, Meta: meta, Count: meta.TotalResults}, nil
}
func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {