Skip to content

Commit 071e82a

Browse files
authored
Merge pull request #4083 from Spinnich/fix/v2-hash-copy-and-firmware-hashes
fix(v2): make hashes recoverable without HTTPS and show all firmware hashes
2 parents 985fefb + c8ab8dd commit 071e82a

6 files changed

Lines changed: 385 additions & 27 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { shallowMount } from "@vue/test-utils";
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { FirmwareSchema } from "@/__generated__";
4+
import type { Platform } from "@/stores/platforms";
5+
import HashChip from "@/v2/components/shared/HashChip.vue";
6+
import FirmwareTab from "./FirmwareTab.vue";
7+
8+
vi.mock("vue-i18n", () => ({
9+
useI18n: () => ({ t: (key: string, fallback?: string) => fallback ?? key }),
10+
}));
11+
12+
vi.mock("@/services/api/firmware", () => ({
13+
default: {
14+
getFirmware: vi.fn(),
15+
uploadFirmware: vi.fn(),
16+
deleteFirmware: vi.fn(),
17+
},
18+
}));
19+
20+
vi.mock("@/stores/platforms", () => ({
21+
default: () => ({ update: vi.fn() }),
22+
}));
23+
24+
vi.mock("@/v2/stores/galleryRoms", () => ({
25+
default: () => ({ currentPlatform: null, setCurrentPlatform: vi.fn() }),
26+
}));
27+
28+
vi.mock("@/v2/composables/useCan", () => ({
29+
useCan: () => ({ value: true }),
30+
}));
31+
32+
vi.mock("@/v2/composables/useSnackbar", () => ({
33+
useSnackbar: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() }),
34+
}));
35+
36+
const CRC = "aabbccdd";
37+
const MD5 = "0123456789abcdef0123456789abcdef";
38+
const SHA1 = "0123456789abcdef0123456789abcdef01234567";
39+
40+
function firmware(overrides: Partial<FirmwareSchema> = {}): FirmwareSchema {
41+
return {
42+
id: 1,
43+
file_name: "disksys.rom",
44+
file_name_no_tags: "disksys",
45+
file_name_no_ext: "disksys",
46+
file_extension: "rom",
47+
file_path: "fds",
48+
file_size_bytes: 8192,
49+
full_path: "fds/disksys.rom",
50+
is_verified: true,
51+
crc_hash: CRC,
52+
md5_hash: MD5,
53+
sha1_hash: SHA1,
54+
missing_from_fs: false,
55+
platform_id: 1,
56+
created_at: "",
57+
updated_at: "",
58+
...overrides,
59+
} as FirmwareSchema;
60+
}
61+
62+
function platform(firmwareList: FirmwareSchema[]): Platform {
63+
return {
64+
id: 1,
65+
slug: "fds",
66+
fs_slug: "fds",
67+
name: "Family Computer Disk System",
68+
display_name: "Family Computer Disk System",
69+
rom_count: 0,
70+
firmware_count: firmwareList.length,
71+
firmware: firmwareList,
72+
} as Platform;
73+
}
74+
75+
function mountTab(firmwareList: FirmwareSchema[]) {
76+
return shallowMount(FirmwareTab, {
77+
props: { platform: platform(firmwareList) },
78+
global: {
79+
// The whole tab is wrapped in a dropzone whose default slot holds
80+
// the list, and a stubbed component does not render its slots.
81+
stubs: { RDropzone: { template: "<div><slot /></div>" } },
82+
},
83+
});
84+
}
85+
86+
function chipEntries(wrapper: ReturnType<typeof mountTab>) {
87+
return wrapper
88+
.findAllComponents(HashChip)
89+
.map((c) => [c.props("label"), c.props("value")]);
90+
}
91+
92+
describe("FirmwareTab", () => {
93+
// #4082: the MD5 used to render as a plain chip with no copy affordance
94+
// and `user-select: none`, and CRC / SHA-1 were never shown at all.
95+
it("renders a copyable chip for every stored hash", () => {
96+
const wrapper = mountTab([firmware()]);
97+
98+
expect(chipEntries(wrapper)).toEqual([
99+
["SHA-1", SHA1],
100+
["MD5", MD5],
101+
["CRC", CRC],
102+
]);
103+
});
104+
105+
it("skips hashes the firmware record does not carry", () => {
106+
const wrapper = mountTab([firmware({ crc_hash: "", sha1_hash: "" })]);
107+
108+
expect(chipEntries(wrapper)).toEqual([["MD5", MD5]]);
109+
});
110+
});

frontend/src/v2/components/Gallery/FirmwareTab.vue

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { Platform } from "@/stores/platforms";
3232
import storePlatforms from "@/stores/platforms";
3333
import { formatBytes } from "@/utils";
3434
import DeleteFirmwareDialog from "@/v2/components/Gallery/DeleteFirmwareDialog.vue";
35+
import HashChip from "@/v2/components/shared/HashChip.vue";
3536
import { useCan } from "@/v2/composables/useCan";
3637
import { useSnackbar } from "@/v2/composables/useSnackbar";
3738
import storeGalleryRoms from "@/v2/stores/galleryRoms";
@@ -385,15 +386,24 @@ async function performDelete(
385386
<RChip size="x-small" variant="translucent">
386387
{{ formatBytes(f.file_size_bytes) }}
387388
</RChip>
388-
<RChip
389-
size="x-small"
390-
variant="translucent"
391-
color="info"
392-
class="r-v2-fw__row-hash"
393-
:title="`MD5: ${f.md5_hash}`"
394-
>
395-
{{ f.md5_hash }}
396-
</RChip>
389+
<HashChip
390+
v-if="f.sha1_hash"
391+
label="SHA-1"
392+
:value="f.sha1_hash"
393+
compact
394+
/>
395+
<HashChip
396+
v-if="f.md5_hash"
397+
label="MD5"
398+
:value="f.md5_hash"
399+
compact
400+
/>
401+
<HashChip
402+
v-if="f.crc_hash"
403+
label="CRC"
404+
:value="f.crc_hash"
405+
compact
406+
/>
397407
<RChip
398408
v-if="f.is_verified"
399409
size="x-small"
@@ -615,14 +625,6 @@ async function performDelete(
615625
flex-wrap: wrap;
616626
gap: 4px;
617627
}
618-
.r-v2-fw__row-hash {
619-
max-width: 220px;
620-
overflow: hidden;
621-
text-overflow: ellipsis;
622-
white-space: nowrap;
623-
font-family: var(--r-font-family-mono, monospace);
624-
}
625-
626628
.r-v2-fw__row-actions {
627629
display: inline-flex;
628630
align-items: center;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { shallowMount } from "@vue/test-utils";
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { DetailedRom } from "@/stores/roms";
4+
import MetadataTab from "./MetadataTab.vue";
5+
6+
vi.mock("vue-i18n", () => ({
7+
useI18n: () => ({ t: (key: string) => key }),
8+
}));
9+
10+
const CRC = "aabbccdd";
11+
const MD5 = "0123456789abcdef0123456789abcdef";
12+
const SHA1 = "0123456789abcdef0123456789abcdef01234567";
13+
const CHD_SHA1 = "89abcdef0123456789abcdef0123456789abcdef";
14+
const RA = "fedcba9876543210fedcba9876543210";
15+
16+
function rom(overrides: Partial<DetailedRom> = {}): DetailedRom {
17+
return {
18+
id: 1,
19+
fs_name: "game.chd",
20+
fs_size_bytes: 1024,
21+
crc_hash: CRC,
22+
md5_hash: MD5,
23+
sha1_hash: SHA1,
24+
ra_hash: RA,
25+
has_simple_single_file: true,
26+
files: [{ chd_sha1_hash: "" }],
27+
...overrides,
28+
} as DetailedRom;
29+
}
30+
31+
function hashLabels(r: DetailedRom) {
32+
const wrapper = shallowMount(MetadataTab, { props: { rom: r } });
33+
return wrapper
34+
.findAllComponents({ name: "HashChip" })
35+
.map((c) => c.props("label"));
36+
}
37+
38+
describe("MetadataTab hash rows", () => {
39+
// The files list and the firmware list both read SHA-1, CHD SHA-1, MD5,
40+
// CRC, RA. This tab used to lead with CRC, so the two tabs disagreed.
41+
it("orders hashes the same way every other surface does", () => {
42+
expect(hashLabels(rom())).toEqual(["SHA-1", "MD5", "CRC", "RA"]);
43+
});
44+
45+
// Not reachable in the browser: the mock library holds no CHD.
46+
it("slots CHD SHA-1 directly after SHA-1 when the ROM is a CHD", () => {
47+
const chd = rom({
48+
files: [{ chd_sha1_hash: CHD_SHA1 }],
49+
} as Partial<DetailedRom>);
50+
51+
expect(hashLabels(chd)).toEqual(["SHA-1", "CHD SHA-1", "MD5", "CRC", "RA"]);
52+
});
53+
});

frontend/src/v2/components/GameDetails/MetadataTab.vue

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script setup lang="ts">
22
// MetadataTab — four sections, top to bottom:
33
// 1. File info — name + size only.
4-
// 2. Hashes — CRC, MD5, SHA1, all mono. RTag with eyebrow label.
4+
// 2. Hashes — SHA-1, MD5, CRC, RA, all mono. RTag with eyebrow label.
5+
// Same order as the files list so the two tabs read alike.
56
// 3. Verification — RTag per database; tone="success" for match,
67
// neutral for miss. Same source of truth (Hasheous match flags) as
78
// the "Verified" badge in the header, via `VERIFICATION_DATABASES`.
@@ -47,12 +48,12 @@ const hashRows = computed<{ label: string; value: string | null }[]>(() => {
4748
? (r.files[0]?.chd_sha1_hash ?? null)
4849
: null;
4950
const rows: { label: string; value: string | null }[] = [
50-
{ label: "CRC", value: r.crc_hash },
51+
{ label: "SHA-1", value: r.sha1_hash },
5152
{ label: "MD5", value: r.md5_hash },
52-
{ label: "SHA1", value: r.sha1_hash },
53+
{ label: "CRC", value: r.crc_hash },
5354
{ label: "RA", value: r.ra_hash },
5455
];
55-
if (chdSha1) rows.splice(3, 0, { label: "CHD SHA-1", value: chdSha1 });
56+
if (chdSha1) rows.splice(1, 0, { label: "CHD SHA-1", value: chdSha1 });
5657
return rows;
5758
});
5859
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { flushPromises, mount } from "@vue/test-utils";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import HashChip from "./HashChip.vue";
4+
5+
const { copy, clipboard } = vi.hoisted(() => ({
6+
copy: vi.fn(),
7+
clipboard: { isSupported: true },
8+
}));
9+
10+
vi.mock("vue-i18n", () => ({
11+
useI18n: () => ({
12+
t: (key: string, params?: Record<string, unknown>) =>
13+
params ? `${key}:${JSON.stringify(params)}` : key,
14+
}),
15+
}));
16+
17+
vi.mock("@/v2/composables/useClipboard", () => ({
18+
useClipboard: () => ({ isSupported: clipboard.isSupported, copy }),
19+
}));
20+
21+
const SHA1 = "0123456789abcdef0123456789abcdef01234567";
22+
const ABBREVIATED = "012345…234567";
23+
24+
function mountChip(
25+
props: Partial<{ label: string; value: string | null }> = {},
26+
) {
27+
return mount(HashChip, { props: { label: "SHA-1", value: SHA1, ...props } });
28+
}
29+
30+
async function click(wrapper: ReturnType<typeof mountChip>) {
31+
await wrapper.find("button").trigger("click");
32+
await flushPromises();
33+
}
34+
35+
beforeEach(() => {
36+
copy.mockReset();
37+
copy.mockResolvedValue(true);
38+
clipboard.isSupported = true;
39+
document.body.innerHTML = "";
40+
});
41+
42+
describe("HashChip", () => {
43+
it("abbreviates a long value", () => {
44+
const wrapper = mountChip();
45+
46+
expect(wrapper.text()).toContain(ABBREVIATED);
47+
expect(wrapper.text()).not.toContain(SHA1);
48+
});
49+
50+
it("leaves a short value intact", () => {
51+
const wrapper = mountChip({ label: "CRC", value: "aabbccdd" });
52+
53+
expect(wrapper.text()).toContain("aabbccdd");
54+
});
55+
56+
it("keeps the full value in the title so hover still reads it", () => {
57+
const wrapper = mountChip();
58+
59+
expect(wrapper.find("button").attributes("title")).toContain(SHA1);
60+
});
61+
62+
it("copies the full value, not the abbreviation", async () => {
63+
const wrapper = mountChip();
64+
65+
await click(wrapper);
66+
67+
expect(copy).toHaveBeenCalledWith(SHA1, expect.anything());
68+
});
69+
70+
it("stays abbreviated when the copy succeeds", async () => {
71+
const wrapper = mountChip();
72+
73+
await click(wrapper);
74+
75+
expect(wrapper.text()).toContain(ABBREVIATED);
76+
expect(wrapper.text()).not.toContain(SHA1);
77+
});
78+
79+
// The core of #4082: over plain HTTP the clipboard API does not exist,
80+
// so a copy can never succeed. Revealing the value is the only way the
81+
// user can get at it.
82+
it("reveals the full value instead of copying when the clipboard is unavailable", async () => {
83+
clipboard.isSupported = false;
84+
const wrapper = mountChip();
85+
86+
await click(wrapper);
87+
88+
expect(copy).not.toHaveBeenCalled();
89+
expect(wrapper.text()).toContain(SHA1);
90+
});
91+
92+
it("marks a revealed chip so its value can be selected", async () => {
93+
clipboard.isSupported = false;
94+
const wrapper = mountChip();
95+
96+
await click(wrapper);
97+
98+
expect(wrapper.find("button").classes()).toContain(
99+
"r-v2-hash-chip--revealed",
100+
);
101+
});
102+
103+
it("collapses the value again on a second click", async () => {
104+
clipboard.isSupported = false;
105+
const wrapper = mountChip();
106+
107+
await click(wrapper);
108+
await click(wrapper);
109+
110+
expect(wrapper.text()).toContain(ABBREVIATED);
111+
expect(wrapper.text()).not.toContain(SHA1);
112+
});
113+
114+
it("still collapses when the page selection is outside the chip", async () => {
115+
clipboard.isSupported = false;
116+
const wrapper = mountChip();
117+
const outside = document.createElement("p");
118+
outside.textContent = "selected elsewhere";
119+
document.body.appendChild(outside);
120+
121+
await click(wrapper);
122+
window.getSelection()?.selectAllChildren(outside);
123+
await click(wrapper);
124+
125+
expect(wrapper.text()).toContain(ABBREVIATED);
126+
expect(wrapper.text()).not.toContain(SHA1);
127+
});
128+
129+
it("reveals the full value when an attempted copy fails", async () => {
130+
copy.mockResolvedValue(false);
131+
const wrapper = mountChip();
132+
133+
await click(wrapper);
134+
135+
expect(copy).toHaveBeenCalled();
136+
expect(wrapper.text()).toContain(SHA1);
137+
});
138+
});

0 commit comments

Comments
 (0)