Skip to content

Commit e531d36

Browse files
authored
fix(admin): make the declarative Tabulator layer apply its attributes (#20457)
Signed-off-by: Mike Fiedler <miketheman@gmail.com>
1 parent e9d8509 commit e531d36

7 files changed

Lines changed: 650 additions & 20 deletions

File tree

‎package.json‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@
99
"lint:fix": "eslint 'warehouse/static/js/warehouse/**' 'warehouse/admin/static/js/**' 'tests/frontend/**' 'webpack.*.js' --fix",
1010
"stylelint": "stylelint '**/*.scss' --cache --cache-location .cache/stylelint",
1111
"stylelint:fix": "stylelint '**/*.scss' --cache --cache-location .cache/stylelint --fix",
12-
"test": "NODE_OPTIONS='$NODE_OPTIONS --experimental-vm-modules' jest --coverage"
12+
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --coverage"
1313
},
1414
"jest": {
1515
"setupFilesAfterEnv": [
1616
"./tests/frontend/setup.js"
1717
],
1818
"testEnvironment": "jsdom",
19-
"testRegex": ".*_test.js"
19+
"testRegex": ".*_test.js",
20+
"moduleNameMapper": {
21+
"^tabulator-tables$": "<rootDir>/node_modules/tabulator-tables/dist/js/tabulator_esm.js"
22+
},
23+
"transformIgnorePatterns": [
24+
"/node_modules/(?!tabulator-tables/)"
25+
],
26+
"transform": {
27+
"\\.m?[jt]sx?$": "babel-jest"
28+
}
2029
},
2130
"dependencies": {
2231
"@fontsource/ewert": "^5.2.8",
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/* SPDX-License-Identifier: Apache-2.0 */
2+
3+
/* global expect, describe, it, beforeEach, jest */
4+
5+
/**
6+
* Tests for the declarative admin tables, asserting on runtime behavior rather
7+
* than on the markup that configures it.
8+
*
9+
* A `tabulator-*` attribute can be spelled correctly and still have no effect:
10+
* Tabulator parses those inside HtmlTableImport, which initializes after the
11+
* core modules and after GroupRows has decided whether to subscribe, so any
12+
* table option consulted during a module's `initialize()` never sees one.
13+
* Asserting that an attribute is present therefore proves nothing about
14+
* whether the table does what it says.
15+
*/
16+
17+
async function render(html) {
18+
document.body.innerHTML = html;
19+
let table;
20+
await jest.isolateModulesAsync(async () => {
21+
const { TabulatorFull } = await import("tabulator-tables");
22+
await import("../../warehouse/admin/static/js/tabulator");
23+
const mounted = document.querySelector(".tabulator") || document.querySelector("table");
24+
table = TabulatorFull.findTable(mounted)[0];
25+
});
26+
await new Promise((resolve) => {
27+
table.on("tableBuilt", resolve);
28+
if (table.initialized) {
29+
resolve();
30+
}
31+
});
32+
return table;
33+
}
34+
35+
const GROUPED = `
36+
<table data-tabulator
37+
tabulator-layout="fitDataFill"
38+
tabulator-groupBy="project_name"
39+
tabulator-pagination="true"
40+
tabulator-paginationSize="25"
41+
tabulator-paginationSizeSelector="25,50,100">
42+
<thead><tr>
43+
<th tabulator-visible="false">Project Name</th><th>Summary</th>
44+
</tr></thead>
45+
<tbody>
46+
<tr><td><a href="/admin/projects/evil-pkg/">evil-pkg</a></td><td>one</td></tr>
47+
<tr><td><a href="/admin/projects/evil-pkg/">evil-pkg</a></td><td>two</td></tr>
48+
<tr><td><a href="/admin/projects/gone-pkg/">gone-pkg</a></td><td>three</td></tr>
49+
</tbody>
50+
</table>`;
51+
52+
describe("declarative admin tables", () => {
53+
beforeEach(() => {
54+
document.body.innerHTML = "";
55+
});
56+
57+
it("groups rows by a hidden column", async () => {
58+
const table = await render(GROUPED);
59+
// Grouping is the only thing rendering the project name on the malware
60+
// reports page, since its column is hidden.
61+
expect(table.getGroups()).toHaveLength(2);
62+
expect(table.getColumns()[0].isVisible()).toBe(false);
63+
});
64+
65+
it("applies the layout mode named in the attribute", async () => {
66+
const table = await render(GROUPED);
67+
expect(table.modules.layout.mode).toBe("fitDataFill");
68+
});
69+
70+
it("gives list and number options their real types", async () => {
71+
const table = await render(GROUPED);
72+
expect(table.options.paginationSize).toBe(25);
73+
expect(table.options.paginationSizeSelector).toEqual([25, 50, 100]);
74+
});
75+
76+
it("sorts on the text of a cell rather than its markup", async () => {
77+
// Cell values are markup, so a naive sort compares hrefs.
78+
const table = await render(`
79+
<table data-tabulator><thead><tr><th>Name</th></tr></thead>
80+
<tbody>
81+
<tr><td><a href="/admin/sponsors/ffffffff/">Aardvark</a></td></tr>
82+
<tr><td><a href="/admin/sponsors/00000000/">Zebra</a></td></tr>
83+
</tbody></table>`);
84+
table.setSort("name", "asc");
85+
const names = table
86+
.getData("active")
87+
.map((row) => row.name.replace(/<[^>]*>/g, "").trim());
88+
// Sorting the markup would lead with Zebra, whose href sorts first.
89+
expect(names).toEqual(["Aardvark", "Zebra"]);
90+
});
91+
92+
it.each([
93+
["asc", ["Gold", "Silver"]],
94+
["desc", ["Silver", "Gold"]],
95+
])("sorts blanks last going %s", async (dir, filled) => {
96+
// A column of optional values keeps its filled-in rows together.
97+
const table = await render(`
98+
<table data-tabulator><thead><tr><th>Level</th></tr></thead>
99+
<tbody>
100+
<tr><td>Gold</td></tr><tr><td></td></tr><tr><td>Silver</td></tr>
101+
</tbody></table>`);
102+
table.setSort("level", dir);
103+
expect(table.getData("active").map((row) => row.level)).toEqual([...filled, ""]);
104+
});
105+
106+
it("filters a column on its text, and skips columns opted out", async () => {
107+
const table = await render(`
108+
<table data-tabulator>
109+
<thead><tr>
110+
<th>Name</th><th tabulator-headerFilter="false">Active?</th>
111+
</tr></thead>
112+
<tbody>
113+
<tr><td><a href="/admin/sponsors/beeeee/">Aardvark</a></td>
114+
<td><i class="fa fa-check"></i></td></tr>
115+
<tr><td><a href="/admin/sponsors/aaaaaa/">Zebra</a></td>
116+
<td><i class="fa fa-times"></i></td></tr>
117+
</tbody></table>`);
118+
119+
const [name, active] = table.getColumns();
120+
expect(name.getDefinition().headerFilter).toBe("input");
121+
// An icon column has no text to match, so a filter box there could only
122+
// ever empty the table.
123+
expect(active.getDefinition().headerFilter).toBe(false);
124+
125+
// "beeeee" appears only inside the href, so a filter reading the markup
126+
// would answer with Aardvark for a string nobody can see on the page.
127+
table.setHeaderFilterValue("name", "beeeee");
128+
expect(table.getData("active")).toEqual([]);
129+
130+
table.setHeaderFilterValue("name", "aardvark");
131+
const shown = table
132+
.getData("active")
133+
.map((row) => row.name.replace(/<[^>]*>/g, "").trim());
134+
expect(shown).toEqual(["Aardvark"]);
135+
});
136+
137+
it("toggles a column once per click of its menu entry", async () => {
138+
const table = await render(`
139+
<table data-tabulator data-tabulator-column-menu>
140+
<thead><tr><th tabulator-visible="false">IP address</th><th>Event</th></tr></thead>
141+
<tbody><tr><td>127.0.0.1</td><td>login</td></tr></tbody></table>`);
142+
const column = table.getColumns()[0];
143+
const [entry] = table.options.columnDefaults.headerMenu.call(table);
144+
145+
// Tabulator listens on the menu item, so a <label> here would forward a
146+
// second click to its own checkbox and toggle the column straight back.
147+
const item = document.createElement("div");
148+
item.appendChild(entry.label);
149+
document.body.appendChild(item);
150+
item.addEventListener("click", (event) => entry.action(event, column));
151+
entry.label.dispatchEvent(new MouseEvent("click", { bubbles: true }));
152+
153+
expect(column.isVisible()).toBe(true);
154+
});
155+
it("shows a column the responsive layout folded as still wanted", async () => {
156+
const table = await render(`
157+
<table data-tabulator data-tabulator-column-menu
158+
tabulator-responsiveLayout="collapse">
159+
<thead><tr><th>Event</th><th tabulator-responsive="2">User-Agent</th></tr></thead>
160+
<tbody><tr><td>login</td><td>curl/8.0</td></tr></tbody></table>`);
161+
const agent = table
162+
.getColumns()
163+
.find((column) => column.getDefinition().title === "User-Agent");
164+
165+
// What a narrow window does: fold the column into the collapsed block.
166+
table.modules.responsiveLayout.hideColumn(agent);
167+
expect(agent.isVisible()).toBe(false);
168+
169+
const entry = table.options.columnDefaults.headerMenu
170+
.call(table)
171+
.find((item) => item.label.textContent.trim() === "User-Agent");
172+
// Reading `isVisible()` here would report a folded column as one the
173+
// admin had hidden, and clicking it would then show it rather than hide.
174+
expect(entry.label.querySelector("input").checked).toBe(true);
175+
});
176+
177+
it("gives a collapsing table a handle to close the block with", async () => {
178+
const table = await render(`
179+
<table data-tabulator tabulator-responsiveLayout="collapse">
180+
<thead><tr><th>Event</th></tr></thead>
181+
<tbody><tr><td>login</td></tr></tbody></table>`);
182+
183+
// ResponsiveLayout wires up a toggle only when it finds this formatter,
184+
// and no attribute can ask for it, so without one the collapsed block
185+
// renders permanently open with nothing to close it.
186+
const [handle] = table.getColumns();
187+
expect(handle.getDefinition().formatter).toBe("responsiveCollapse");
188+
expect(table.modules.responsiveLayout.collapseHandleColumn).toBeTruthy();
189+
// No title, so it stays out of the column visibility menu.
190+
expect(handle.getDefinition().title).toBeUndefined();
191+
});
192+
193+
it("ignores an attribute naming only an Object prototype member", async () => {
194+
// HTML lowercases attribute names, so `constructor` and `__proto__` reach
195+
// the prototype chain of the option table and answer with something that
196+
// is not an option at all.
197+
document.body.innerHTML = `
198+
<table id="first" data-tabulator tabulator-constructor="boom">
199+
<thead><tr><th>Name</th></tr></thead>
200+
<tbody><tr><td>a</td></tr></tbody></table>
201+
<table id="second" data-tabulator>
202+
<thead><tr><th>Name</th></tr></thead>
203+
<tbody><tr><td>b</td></tr></tbody></table>`;
204+
await jest.isolateModulesAsync(async () => {
205+
await import("../../warehouse/admin/static/js/tabulator");
206+
});
207+
await new Promise((resolve) => setTimeout(resolve));
208+
209+
// Both tables built: a throw while reading the first one's attributes
210+
// would have left the second as a plain <table>.
211+
expect(document.querySelectorAll("div.tabulator")).toHaveLength(2);
212+
});
213+
214+
it("exports what a cell shows rather than the markup showing it", async () => {
215+
const table = await render(`
216+
<table id="sponsors" data-tabulator data-tabulator-download>
217+
<thead><tr><th>Name</th></tr></thead>
218+
<tbody><tr><td><a href="/admin/sponsors/00000000/">Aardvark</a></td></tr>
219+
</tbody></table>`);
220+
221+
const { accessorDownload, accessorClipboard } = table.options.columnDefaults;
222+
const cell = "<a href=\"/admin/sponsors/00000000/\">Aardvark</a>";
223+
expect(accessorDownload(cell)).toBe("Aardvark");
224+
expect(accessorClipboard(cell)).toBe("Aardvark");
225+
226+
const download = jest.spyOn(table, "download").mockImplementation(() => {});
227+
const copy = jest.spyOn(table, "copyToClipboard").mockImplementation(() => {});
228+
const [copyButton, csvButton] = document.querySelectorAll(
229+
"div.btn-group > button",
230+
);
231+
copyButton.click();
232+
csvButton.click();
233+
234+
// Filtered and sorted as they stand, across every page, which is what the
235+
// DataTables toolbar these replace exported.
236+
expect(copy).toHaveBeenCalledWith("active");
237+
// Without this the Clipboard module never binds the listener that
238+
// `copyToClipboard` fires against, and the button does nothing at all.
239+
expect(table.options.clipboard).toBe("copy");
240+
expect(download).toHaveBeenCalledWith("csv", "sponsors.csv", {}, "active");
241+
});
242+
243+
it("leaves the export buttons off a table that did not ask for them", async () => {
244+
await render(`
245+
<table data-tabulator><thead><tr><th>Name</th></tr></thead>
246+
<tbody><tr><td>a</td></tr></tbody></table>`);
247+
expect(document.querySelector("div.btn-group")).toBeNull();
248+
});
249+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
"""The escaping rule the declarative admin tables rest on.
4+
5+
Tabulator reads a server-rendered cell as innerHTML and, with the `html`
6+
formatter these tables default to, writes it back the same way. The group
7+
headings and the responsive-collapse block do that whatever the formatter is,
8+
so every cell of a `data-tabulator` table has to be Jinja-autoescaped output.
9+
10+
Nothing in the rendering path can tell the difference, so the rule is checked
11+
here: the first `|safe` added to one of these tables would be script execution
12+
in an authenticated admin session.
13+
"""
14+
15+
import pathlib
16+
import re
17+
18+
import pytest
19+
20+
import warehouse.admin
21+
22+
_TEMPLATES = pathlib.Path(warehouse.admin.__file__).parent / "templates" / "admin"
23+
24+
# A <table data-tabulator ...> and everything up to its </table>.
25+
_TABLE = re.compile(
26+
r"<table[^>]*\bdata-tabulator\b.*?</table>", re.DOTALL | re.IGNORECASE
27+
)
28+
29+
# Anything that hands Jinja markup it will not escape.
30+
_UNESCAPED = re.compile(r"\|\s*safe\b|\bMarkup\(|{%\s*autoescape\s+false")
31+
32+
33+
def _tables():
34+
for path in sorted(_TEMPLATES.rglob("*.html")):
35+
source = path.read_text()
36+
for match in _TABLE.finditer(source):
37+
yield pytest.param(
38+
match.group(0),
39+
id=f"{path.parent.name}/{path.name}:"
40+
f"{source.count(chr(10), 0, match.start()) + 1}",
41+
)
42+
43+
44+
@pytest.mark.parametrize("table", list(_tables()))
45+
def test_declarative_table_cells_stay_escaped(table):
46+
assert not _UNESCAPED.search(table)

‎warehouse/admin/static/css/admin.css‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ table.dataTable {
2424
max-width: 30em;
2525
}
2626

27+
/* Tabulator replaces the <table> with divs, so the rule above cannot reach its
28+
cells, and its own stylesheet clips each one to a single line. Wrap instead:
29+
the columns capped at `maxInitialWidth` are the payload and caveat blobs
30+
admins are on the page to read. `anywhere` breaks an unspaced token — a
31+
base64 caveat, a long URL — rather than letting it set the column width. */
32+
.tabulator-cell {
33+
white-space: normal;
34+
overflow-wrap: anywhere;
35+
text-overflow: clip;
36+
}
37+
2738
.preserve-line-breaks {
2839
white-space: pre-line;
2940
}

0 commit comments

Comments
 (0)