Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
110 changes: 110 additions & 0 deletions apps/webapp/app/modules/scan/last-scan-for-viewer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// @vitest-environment node
/**
* Authorization tests for `getLastScanForViewer`.
*
* The asset overview loader used to fetch and return the parsed last scan
* unconditionally, and the component hid `<ScanDetails>` behind a client-side
* `scan:read` check. The parsed payload carries the scanner's display name and
* EMAIL, the scan's GPS COORDINATES and the device user-agent, so for BASE and
* SELF_SERVICE (both `scan: []`) that was PII sitting in the page payload,
* hidden only by React.
*
* These tests drive the real `Role2PermissionMap` through the real
* `hasPermission`, so they assert the actual matrix rather than a restatement
* of it. Only the DB read is mocked.
*
* @see {@link file://./service.server.ts}
*/
import { OrganizationRoles } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { scanFindFirst } = vi.hoisted(() => ({ scanFindFirst: vi.fn() }));

// why: the only external dependency. `hasPermission` and `parseScanData` run
// for real so the gate is tested end to end against the live matrix.
vi.mock("~/database/db.server", () => ({
db: { scan: { findFirst: scanFindFirst } },
}));

import { getLastScanForViewer } from "./service.server";

/** A scan row shaped as `getScanByQrId` returns it, carrying real PII. */
const scanRow = {
id: "scan-1",
userId: "user-9",
latitude: "52.3676",
longitude: "4.9041",
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
manuallyGenerated: false,
createdAt: new Date("2026-07-01T10:00:00Z"),
qr: { id: "qr-1", organizationId: "org-1" },
user: {
id: "user-9",
firstName: "Dana",
lastName: "Reeves",
displayName: "Dana Reeves",
email: "dana@example.com",
userOrganizations: [{ organizationId: "org-1" }],
},
};

function args(roles: OrganizationRoles[]) {
return {
qrId: "qr-1",
userId: "user-1",
organizationId: "org-1",
roles,
};
}

describe("getLastScanForViewer", () => {
beforeEach(() => {
vi.clearAllMocks();
scanFindFirst.mockResolvedValue(scanRow);
});

it.each([OrganizationRoles.BASE, OrganizationRoles.SELF_SERVICE])(
"returns null for %s and never reads the scan row",
async (role) => {
const result = await getLastScanForViewer(args([role]));

expect(result).toBeNull();
// Not merely omitted from the response — never fetched at all.
expect(scanFindFirst).not.toHaveBeenCalled();
}
);

it.each([OrganizationRoles.ADMIN, OrganizationRoles.OWNER])(
"returns the parsed scan for %s",
async (role) => {
const result = await getLastScanForViewer(args([role]));

expect(scanFindFirst).toHaveBeenCalledTimes(1);
expect(result).toEqual(
expect.objectContaining({
scannedBy: "Dana Reeves(dana@example.com)",
coordinates: "52.3676, 4.9041",
})
);
}
);

it("returns null without touching the DB when the asset has no QR code", async () => {
const result = await getLastScanForViewer({
...args([OrganizationRoles.OWNER]),
qrId: undefined,
});

expect(result).toBeNull();
expect(scanFindFirst).not.toHaveBeenCalled();
});

it("returns null when the asset has a QR but has never been scanned", async () => {
scanFindFirst.mockResolvedValue(null);

const result = await getLastScanForViewer(args([OrganizationRoles.OWNER]));

// Same shape an unauthorized viewer gets, so callers need no special case.
expect(result).toBeNull();
});
});
66 changes: 65 additions & 1 deletion apps/webapp/app/modules/scan/service.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { Prisma, Scan } from "@prisma/client";
import type { OrganizationRoles, Prisma, Scan } from "@prisma/client";
import { db } from "~/database/db.server";
import { ShelfError } from "~/utils/error";
import type { ErrorLabel } from "~/utils/error";
import { wrapUserLinkForNote } from "~/utils/markdoc-wrappers";
import {
PermissionAction,
PermissionEntity,
} from "~/utils/permissions/permission.data";
import { hasPermission } from "~/utils/permissions/permission.validator.server";
import { parseScanData } from "./utils.server";
import { createNote } from "../note/service.server";
import { getOrganizationById } from "../organization/service.server";
import { getQr } from "../qr/service.server";
Expand Down Expand Up @@ -154,6 +160,64 @@ export async function getScanByQrId({ qrId }: { qrId: string }) {
}
}

/** Arguments for resolving a viewer-scoped last scan. */
type GetLastScanForViewerArgs = {
/** The asset's QR code id, or null/undefined when it has no QR yet */
qrId: string | null | undefined;
/** The user viewing the page */
userId: string;
/** The viewer's active organization */
organizationId: string;
/** The viewer's roles in that organization (skips the validator's DB lookup) */
roles?: OrganizationRoles[];
};

/**
* Resolves the parsed last scan for an asset, but ONLY for a viewer who holds
* `scan:read`.
*
* The parsed payload carries the scanner's display name and email address, the
* scan's GPS coordinates and the device user-agent. Gating that in the
* component is not enough: the loader would still ship it in the page payload
* to roles that hold `scan: []` (BASE and SELF_SERVICE), where anyone can read
* it out of the network response. This helper is the server-side gate, so a
* caller cannot fetch the scan and forget to check.
*
* Returns `null` — the same shape as "this asset has never been scanned" — for
* an unauthorized viewer, so callers need no special case.
*
* @param args - The QR to look up plus the viewer's identity and roles
* @returns The parsed scan, or `null` when the asset has no QR, has no scans,
* or the viewer may not read scans
*/
export async function getLastScanForViewer({
qrId,
userId,
organizationId,
roles,
}: GetLastScanForViewerArgs) {
if (!qrId) {
return null;
}

const canReadScan = await hasPermission({
userId,
organizationId,
roles,
entity: PermissionEntity.scan,
action: PermissionAction.read,
});

if (!canReadScan) {
return null;
}

return parseScanData({
scan: (await getScanByQrId({ qrId })) || null,
userId,
});
}

/**
* Writes a system note onto the asset linked to a scanned QR code.
*
Expand Down
Loading
Loading