Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ func endpointFilterClause(col, search, rootFilter, methodFilter string) (string,
clause += " AND " + col + "is_root = 0"
}
if methodFilter != "" {
clause += " AND startsWith(" + col + "endpoint, ?)"
// upper() not upperUTF8(): ASCII is the whole of RFC 9110's method token
// range, and the embedded backends' UPPER() is ASCII-only, so this keeps
// all three answering identically.
clause += " AND startsWith(upper(" + col + "endpoint), ?)"
args = append(args, shared.MethodPrefix(methodFilter))
}
return clause, args
Expand Down
49 changes: 43 additions & 6 deletions backend/app/repositories/telemetry/endpoint_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,56 @@ func TestEndpointRepository_FindGroupedByEndpoint_MethodFilter(t *testing.T) {
t.Fatalf("InsertAsync failed: %v", err)
}

// "GETAWAY /api/cars" must not match: the filter compares a whole
// space-terminated token, not a prefix of the endpoint string.
// "get /api/lowercase" must match. getHTTPEndpoint concatenates
// http.request.method verbatim, so lowercase rows genuinely exist, and the
// dropdown only ever offers the 7 canonical uppercase methods -- a
// case-sensitive comparison made those rows unreachable from the UI (#321).
stats, total, err := EndpointRepository.FindGroupedByEndpoint(ctx, projectId, now.Add(-time.Hour), now.Add(time.Hour), 1, 10, "count", "desc", "", "", "get")
if err != nil {
t.Fatalf("FindGroupedByEndpoint with method filter failed: %v", err)
}

if total != 1 {
t.Errorf("expected 1 matching endpoint, got %d", total)
if total != 2 {
t.Errorf("expected 2 matching endpoints, got %d", total)
}
if len(stats) != 1 {
t.Fatalf("expected 1 grouped stat, got %d", len(stats))
matched := make(map[string]bool, len(stats))
for _, s := range stats {
matched[s.Endpoint] = true
}
if stats[0].Endpoint != "GET /api/users" {
t.Errorf("expected 'GET /api/users', got %q", stats[0].Endpoint)
if len(stats) != 2 || !matched["GET /api/users"] || !matched["get /api/lowercase"] {
t.Fatalf("expected GET /api/users and get /api/lowercase, got %v", matched)
}
}

// The filter is applied by upper-casing the column, not the caller's argument,
// so it holds however the request cased its method.
func TestEndpointRepository_FindGroupedByEndpoint_MethodFilterCaseInsensitive(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
projectId := uuid.New()
now := truncateMs(time.Now().UTC())

endpoints := []models.Endpoint{
makeEndpoint(projectId, "GET /api/users", 100*time.Millisecond, 200, now),
makeEndpoint(projectId, "get /api/lowercase", 100*time.Millisecond, 200, now.Add(time.Minute)),
makeEndpoint(projectId, "Get /api/mixed", 100*time.Millisecond, 200, now.Add(2*time.Minute)),
makeEndpoint(projectId, "POST /api/users", 200*time.Millisecond, 201, now.Add(3*time.Minute)),
}

if err := EndpointRepository.InsertAsync(ctx, endpoints); err != nil {
t.Fatalf("InsertAsync failed: %v", err)
}

for _, method := range []string{"GET", "get", "GeT"} {
_, total, err := EndpointRepository.FindGroupedByEndpoint(ctx, projectId, now.Add(-time.Hour), now.Add(time.Hour), 1, 10, "count", "desc", "", "", method)
if err != nil {
t.Fatalf("methodFilter %q failed: %v", method, err)
}
if total != 3 {
t.Errorf("methodFilter %q: expected 3 matching endpoints, got %d", method, total)
}
}
}

Expand Down
12 changes: 11 additions & 1 deletion backend/app/repositories/telemetry/shared/sqlfilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,26 @@ func RootFilterClause(qualifiedCol, rootFilter string) string {
}
}

// MethodPrefix is the uppercase "GET " form every backend compares against.
// The stored method is whatever the instrumented service reported --
// getHTTPEndpoint concatenates http.request.method verbatim -- so the column
// side is upper-cased at query time rather than the value being trusted.
func MethodPrefix(method string) string {
return strings.ToUpper(method) + " "
}

// MethodFilterClause matches the method case-insensitively. SUBSTR rather than
// LIKE because methodFilter reaches SQL as a value and LIKE would read % and _
// in it as wildcards; UPPER around the column rather than a second bound
// parameter so the comparison cannot depend on how the caller cased its input.
// UPPER is ASCII-only in SQLite, which is exactly the range RFC 9110 allows a
// method token.
func MethodFilterClause(qualifiedCol, method string) (clause string, param string) {
if method == "" {
return "", ""
}
prefix := MethodPrefix(method)
return fmt.Sprintf(" AND SUBSTR(%s, 1, %d) = :method", qualifiedCol, len(prefix)), prefix
return fmt.Sprintf(" AND UPPER(SUBSTR(%s, 1, %d)) = :method", qualifiedCol, len(prefix)), prefix
}

// SortedKeys returns map keys in stable order so generated SQL and its
Expand Down