Skip to content
Merged
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
117 changes: 116 additions & 1 deletion frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2357,7 +2357,7 @@ describe('Bundle B: column header filter triggers', () => {
expect(document.querySelector('.column-filter-popover')).toBeNull();
});

test('categorical popover lists distinct values from the unfiltered rec set', async () => {
test('categorical popover lists distinct values from the rec set (no other active filters)', async () => {
await loadRecommendations();
const providerBtn = document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="provider"]');
providerBtn?.click();
Expand Down Expand Up @@ -2819,6 +2819,121 @@ describe('Bundle B: column header filter triggers', () => {
expect(values).toContain('ec2');
});
});

// Issue #164: cross-column-aware (cascading) distinct values.
// Each column popover must show only values from rows that pass all
// OTHER active filters -- values that produce non-empty results when
// combined with the existing selection.
describe('Issue #164: cross-column-aware categorical filter distinct values', () => {
const multiProviderRecs = [
{ id: 'r-aws-ec2', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 },
{ id: 'r-aws-rds', provider: 'aws', cloud_account_id: 'a1', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, savings: 80, upfront_cost: 400 },
{ id: 'r-az-vm', provider: 'azure', cloud_account_id: 'a2', service: 'vm', resource_type: 'D2s', region: 'eastus', count: 2, term: 3, savings: 200, upfront_cost: 800 },
];

beforeEach(() => {
(api.getRecommendations as jest.Mock).mockResolvedValue({
summary: {},
recommendations: multiProviderRecs,
regions: [],
});
(state.getRecommendations as jest.Mock).mockReturnValue(multiProviderRecs);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue(multiProviderRecs);
});

test('service popover shows only AWS services when provider=aws filter is active', async () => {
// Provider=aws is set; opening the service popover must list only
// services from aws rows (ec2, rds), not the azure service (vm).
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({
provider: { kind: 'set', values: ['aws'] },
});
await loadRecommendations();
const serviceBtn = document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="service"]');
serviceBtn?.click();
const values = Array.from(
document.querySelectorAll<HTMLInputElement>('.column-filter-popover .column-filter-item input[type="checkbox"]'),
).map((cb) => cb.dataset['value']).sort();
expect(values).toEqual(['ec2', 'rds']);
expect(values).not.toContain('vm');
});

test('provider popover shows all providers when opening the provider column (own filter excluded)', async () => {
// Provider=aws is active, but opening the provider popover must still
// show all 3 providers because the column's own filter is excluded from
// the cross-filter narrowing -- allowing the user to switch providers.
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({
provider: { kind: 'set', values: ['aws'] },
});
await loadRecommendations();
const providerBtn = document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="provider"]');
providerBtn?.click();
const values = Array.from(
document.querySelectorAll<HTMLInputElement>('.column-filter-popover .column-filter-item input[type="checkbox"]'),
).map((cb) => cb.dataset['value']).sort();
// All providers must be visible so the user can switch from aws to azure.
expect(values).toEqual(['aws', 'azure']);
});

test('service popover pins active-filter values even when cross-filtering would omit them (alwaysInclude)', async () => {
// Contradictory filter state: provider=azure (only has vm) + service=ec2 (no azure rows).
// Opening the service popover: cross-filtering by provider=azure yields only vm.
// But ec2 is in the service column's own active filter, so alwaysInclude must keep it
// visible so the user can deselect it without first clearing the provider filter.
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({
provider: { kind: 'set', values: ['azure'] },
service: { kind: 'set', values: ['ec2'] },
});
await loadRecommendations();
const serviceBtn = document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="service"]');
serviceBtn?.click();
const values = Array.from(
document.querySelectorAll<HTMLInputElement>('.column-filter-popover .column-filter-item input[type="checkbox"]'),
).map((cb) => cb.dataset['value']).sort();
// vm is visible because provider=azure cross-filter leaves only that azure row.
expect(values).toContain('vm');
// ec2 must also be visible even though no azure row has service=ec2:
// the alwaysInclude set pins values from the column's own active filter.
expect(values).toContain('ec2');
});

test('hostile payload in service value is not interpreted as HTML in the popover (XSS guard)', async () => {
// A recommendation whose `service` field contains an HTML injection string
// must not be parsed as markup in the filter popover -- the value goes through
// textContent and dataset, never innerHTML.
const hostileService = '<script>alert(1)</script>';
const recsWithHostile = [
{ id: 'r-aws-ec2', provider: 'aws', cloud_account_id: 'a1', service: hostileService, resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 },
{ id: 'r-aws-rds', provider: 'aws', cloud_account_id: 'a1', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, savings: 80, upfront_cost: 400 },
];
(api.getRecommendations as jest.Mock).mockResolvedValue({
summary: {},
recommendations: recsWithHostile,
regions: [],
});
(state.getRecommendations as jest.Mock).mockReturnValue(recsWithHostile);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue(recsWithHostile);
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({});
await loadRecommendations();
const serviceBtn = document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="service"]');
serviceBtn?.click();

// The popover must not contain a parsed <script> element.
const popover = document.querySelector('.column-filter-popover');
expect(popover).not.toBeNull();
expect(popover!.querySelector('script')).toBeNull();

// The raw payload must appear as a literal data-value attribute, not interpreted HTML.
const hostileCb = Array.from(
document.querySelectorAll<HTMLInputElement>('.column-filter-popover .column-filter-item input[type="checkbox"]'),
).find((cb) => cb.dataset['value'] === hostileService);
expect(hostileCb).not.toBeUndefined();

// The label span renders the payload as literal text, not HTML.
const labelSpan = hostileCb!.closest('label')?.querySelector('span');
expect(labelSpan?.textContent).toBe(hostileService);
expect(labelSpan?.innerHTML).not.toContain('<script>');
});
});
});

describe('Bundle B: sticky bottom action box', () => {
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1928,13 +1928,20 @@ function positionPopover(popover: HTMLElement, anchor: HTMLElement): void {
function distinctValuesForColumn(
recs: readonly LocalRecommendation[],
column: state.RecommendationsColumnId,
// Values to include regardless of what appears in `recs` (used so the
// currently-active filter selection for the column being edited remains
// visible even when cross-column filtering would otherwise hide it).
alwaysInclude?: ReadonlySet<string>,
): string[] {
// Numeric columns don't get a checkbox list, but we still call this for
// categorical columns only.
const seen = new Set<string>();
for (const r of recs) {
seen.add(categoricalCellValue(r, column));
}
if (alwaysInclude) {
for (const v of alwaysInclude) seen.add(v);
}
return Array.from(seen).sort((a, b) => {
if (a === '' && b !== '') return -1; // (empty) first
if (a !== '' && b === '') return 1;
Expand Down Expand Up @@ -1963,9 +1970,15 @@ function categoricalDisplayLabel(
// Build the popover DOM for a given column. Categorical: checkbox list with
// (All) tri-state + Clear footer. Numeric: free-text expression input with
// inline error and Clear footer.
//
// `recs` should already be pre-filtered to the cross-column narrowed set
// (all filters except this column's own applied). `alwaysInclude` pins any
// currently-active values for this column so the user can still deselect
// them even when the cross-filtered set would otherwise omit them.
function buildPopoverContent(
column: state.RecommendationsColumnId,
recs: readonly LocalRecommendation[],
alwaysInclude?: ReadonlySet<string>,
): { el: HTMLDivElement; checkboxes: Map<string, HTMLInputElement>; input: HTMLInputElement | null; errorEl: HTMLElement | null } {
const popover = document.createElement('div');
popover.className = 'column-filter-popover';
Expand Down Expand Up @@ -2034,7 +2047,7 @@ function buildPopoverContent(
}
});
} else {
const distinct = distinctValuesForColumn(recs, column);
const distinct = distinctValuesForColumn(recs, column, alwaysInclude);

// (All) tri-state checkbox at the top.
const allLabel = document.createElement('label');
Expand Down Expand Up @@ -2337,7 +2350,25 @@ function openColumnPopover(column: state.RecommendationsColumnId, anchor: HTMLEl
if (openPopover) closePopover();

const recs = state.getRecommendations() as unknown as LocalRecommendation[];
const built = buildPopoverContent(column, recs);
// Cross-column-aware distinct values (issue #164): build the checkbox list
// from rows that pass all OTHER active filters, not the full unfiltered set.
// This way, selecting Provider=AWS first narrows the Service popover to only
// AWS services -- values that produce non-empty results.
//
// Mitigation for the "broaden a single column" UX: any value that is part of
// the column's own currently-active filter is always included even if the
// cross-filtered set would omit it (e.g. opening Provider popover while
// Provider=AWS + Service=ec2 are both active still shows AWS so the user can
// deselect it without first clearing the Service filter).
const allFilters = state.getRecommendationsColumnFilters();
const filtersExceptThisColumn: state.RecommendationsColumnFilters = { ...allFilters };
delete filtersExceptThisColumn[column];
const recsForDistinct = applyColumnFilters(recs, filtersExceptThisColumn);
const ownFilter = allFilters[column];
const alwaysInclude: ReadonlySet<string> = ownFilter && ownFilter.kind === 'set'
? new Set(ownFilter.values)
: new Set();
const built = buildPopoverContent(column, recsForDistinct, alwaysInclude);
document.body.appendChild(built.el);
openPopover = {
column,
Expand Down
Loading