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
11 changes: 4 additions & 7 deletions apps/web/src/app/(app)/components/SidebarUserFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ type User = {
google_user_name: string;
google_user_email: string;
google_user_image_url: string;
is_admin: boolean;
};

type SidebarUserFooterProps = {
Expand Down Expand Up @@ -86,12 +85,10 @@ export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooter
<UserCog className="h-4 w-4" />
Connected Accounts
</DropdownMenuItem>
{user.is_admin && (
<DropdownMenuItem onClick={() => router.push('/data-exports')}>
<FileDown className="h-4 w-4" />
Request data export
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => router.push('/data-exports')}>
<FileDown className="h-4 w-4" />
Request data export
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/install')}>
<Download className="h-4 w-4" />
Install
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/app/(app)/data-exports/page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';

const mockGetUserFromAuth = jest.fn();
const mockNotFound = jest.fn(() => {
throw new Error('NEXT_NOT_FOUND');
});

(globalThis as typeof globalThis & { React: typeof React }).React = React;

jest.mock('@/lib/user/server', () => ({ getUserFromAuth: mockGetUserFromAuth }));
jest.mock('next/navigation', () => ({ notFound: mockNotFound }));
jest.mock('@/components/PageLayout', () => ({ PageLayout: () => null }));
jest.mock('./DataExportsClient', () => ({ DataExportsClient: () => null }));
jest.mock('./RequestDataDeletionCard', () => ({ RequestDataDeletionCard: () => null }));

// `.test.ts`, not `.test.tsx`: jest's testMatch is `**/src/**/*.test.ts`, so a `.tsx`
// suite is silently never collected. An earlier version of this file was a `.tsx` and
// never ran, which is how its deletion went unnoticed.
describe('DataExportsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('authenticates the visitor without requiring Kilo staff', async () => {
mockGetUserFromAuth.mockResolvedValue({ user: null });
const { default: DataExportsPage } = await import('./page');

await expect(DataExportsPage()).rejects.toThrow('NEXT_NOT_FOUND');
// `adminOnly: false` is the whole guard: signed out is still refused, but a signed-in
// non-staff user reaches the page. Asserted explicitly so a regression to
// `adminOnly: true` fails here rather than silently hiding the page from users.
expect(mockGetUserFromAuth).toHaveBeenCalledWith({ adminOnly: false });
});

it('renders for a signed-in user who is not a Kilo admin', async () => {
mockGetUserFromAuth.mockResolvedValue({ user: { id: 'user-1', is_admin: false } });
const { default: DataExportsPage } = await import('./page');

await expect(DataExportsPage()).resolves.toBeTruthy();
expect(mockNotFound).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion apps/web/src/app/(app)/data-exports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { DataExportsClient } from './DataExportsClient';
import { RequestDataDeletionCard } from './RequestDataDeletionCard';

export default async function DataExportsPage() {
const { user } = await getUserFromAuth({ adminOnly: true });
const { user } = await getUserFromAuth({ adminOnly: false });
if (!user) notFound();

return (
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routers/user-exports-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ describe('user exports router guards and serialization', () => {
expect(() => __test__.requireWebSession(false)).not.toThrow();
});

it('rejects non-admin users', async () => {
it('allows non-admin users to list their exports', async () => {
const caller = await createCallerForUser(stranger.id);
await expect(caller.userExports.list()).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.userExports.list()).resolves.toEqual({ exports: [], nextCursor: null });
});

it('normalizes database timestamp text into strict UTC ISO strings', () => {
Expand Down
26 changes: 12 additions & 14 deletions apps/web/src/routers/user-exports-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@/lib/auth/data-export-download-codes';
import { db } from '@/lib/drizzle';
import { sendDataExportDownloadCodeEmail } from '@/lib/email';
import { adminProcedure, createTRPCRouter, type TRPCContext } from '@/lib/trpc/init';
import { baseProcedure, createTRPCRouter, type TRPCContext } from '@/lib/trpc/init';
import {
ORGANIZATION_EXPORT_ROLES,
organizationExportAccess,
Expand Down Expand Up @@ -165,10 +165,9 @@ async function exportableOrganizations(userId: string): Promise<{ id: string; na
* role on it.
*
* The shared predicate, not `ensureOrganizationAccess`, which every other organization
* router uses. That helper grants `owner` to any `is_admin` caller, so on this router —
* where every procedure is `adminProcedure` — it would authorise on staff status rather
* than on membership, and nobody may export another person's or another organization's
* data.
* router uses. That helper grants `owner` to any `is_admin` caller, so it would authorise
* a Kilo staff member on staff status rather than on membership, and nobody — staff
* included — may export another person's or another organization's data.
*
* Shared with the Worker, which re-checks independently, because the two must reach the
* same verdict. They once did not, and an export generated, showed as ready, and then
Expand Down Expand Up @@ -246,8 +245,7 @@ async function createExportRequest(subject: {
FROM user_data_exports exports
WHERE ${scopeFilter}
AND exports.status <> 'failed'
-- TEMPORARY: throttle lowered to 5 minutes for pre-launch testing; restore to 24 hours before going live.
AND exports.requested_at > now() - interval '5 minutes'
AND exports.requested_at > now() - interval '24 hours'
ORDER BY exports.requested_at DESC
LIMIT 1
`);
Expand Down Expand Up @@ -421,13 +419,13 @@ function downloadCodeError(result: Exclude<ReserveDownloadCodeResult, 'ok'>): TR
}

export const userExportsRouter = createTRPCRouter({
request: adminProcedure.mutation(async ({ ctx }) => {
request: baseProcedure.mutation(async ({ ctx }) => {
requireWebSession(ctx.authViaToken);
const row = await createExportRequest({ kiloUserId: ctx.user.id, organizationId: null });
return serialize(row);
}),

requestOrganization: adminProcedure
requestOrganization: baseProcedure
.input(OrganizationExportSchema)
.mutation(async ({ ctx, input }) => {
requireWebSession(ctx.authViaToken);
Expand All @@ -443,18 +441,18 @@ export const userExportsRouter = createTRPCRouter({
}),

/** Organizations the caller may export, for rendering the organization buttons. */
exportableOrganizations: adminProcedure.query(async ({ ctx }) => {
exportableOrganizations: baseProcedure.query(async ({ ctx }) => {
requireWebSession(ctx.authViaToken);
return { organizations: await exportableOrganizations(ctx.user.id) };
}),

list: adminProcedure.input(ListInputSchema).query(async ({ ctx, input }) => {
list: baseProcedure.input(ListInputSchema).query(async ({ ctx, input }) => {
requireWebSession(ctx.authViaToken);
// Two independent reasons an export is visible.
//
// Anything the caller requested, whatever its subject. Whoever pressed the button
// always sees the result, even if the access that admitted the request is not
// access this query can reproduce — a Kilo staff elevation, for instance.
// access this query can still reproduce — an export role revoked afterwards, say.
//
// Plus every export belonging to an organization they may export, so a second
// admin is not refused by the one-active-export-per-org constraint while being
Expand Down Expand Up @@ -508,7 +506,7 @@ export const userExportsRouter = createTRPCRouter({
* held web session alone cannot reach the artifact without also reaching the
* inbox.
*/
requestDownloadCode: adminProcedure.input(ExportIdSchema).mutation(async ({ ctx, input }) => {
requestDownloadCode: baseProcedure.input(ExportIdSchema).mutation(async ({ ctx, input }) => {
requireWebSession(ctx.authViaToken);
await requireDownloadableExport(ctx, input.exportId);
const email = requireDownloadCodeRecipient(ctx.user.google_user_email);
Expand Down Expand Up @@ -539,7 +537,7 @@ export const userExportsRouter = createTRPCRouter({
}),

/** Step 2 of the download: redeem the emailed code for one signed URL. */
createDownload: adminProcedure.input(DownloadInputSchema).mutation(async ({ ctx, input }) => {
createDownload: baseProcedure.input(DownloadInputSchema).mutation(async ({ ctx, input }) => {
requireWebSession(ctx.authViaToken);
await requireDownloadableExport(ctx, input.exportId);
const email = requireDownloadCodeRecipient(ctx.user.google_user_email);
Expand Down