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: 11 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -70,3 +72,12 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 7

- name: Process test result
if: always()
run: |
if [[ "${{ steps.run-tests.outputs.tests-passed }}" == "true" ]]; then
echo "All tests passed!"
else
echo "Some tests failed"
fi
70 changes: 42 additions & 28 deletions app/(chat)/api/document/route.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,53 @@
import { auth } from '@/app/(auth)/auth';
import { ArtifactKind } from '@/components/artifact';
import type { ArtifactKind } from '@/components/artifact';
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
} from '@/lib/db/queries';
import { apiErrors, successResponse } from '@/lib/responses';

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

if (!id) {
return new Response('Missing id', { status: 400 });
return apiErrors.missingParameter();
}

const session = await auth();

if (!session || !session.user) {
return new Response('Unauthorized', { status: 401 });
if (!session?.user?.id) {
return apiErrors.unauthorized();
}

const documents = await getDocumentsById({ id });

const [document] = documents;

if (!document) {
return new Response('Not Found', { status: 404 });
return apiErrors.documentNotFound();
}

if (document.userId !== session.user.id) {
return new Response('Unauthorized', { status: 401 });
return apiErrors.documentForbidden();
}

return Response.json(documents, { status: 200 });
return successResponse(documents);
}

export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

if (!id) {
return new Response('Missing id', { status: 400 });
return apiErrors.missingParameter();
}

const session = await auth();

if (!session) {
return new Response('Unauthorized', { status: 401 });
return apiErrors.unauthorized();
}

const {
Expand All @@ -56,49 +57,62 @@ export async function POST(request: Request) {
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();

if (session.user?.id) {
const document = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});
if (!session?.user?.id) {
return apiErrors.unauthorized();
}

const documents = await getDocumentsById({ id: id });

if (documents.length > 0) {
const [document] = documents;

return Response.json(document, { status: 200 });
if (document.userId !== session.user.id) {
return apiErrors.documentForbidden();
}
}

return new Response('Unauthorized', { status: 401 });
const [createdDocument] = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});

return successResponse(createdDocument);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Input Validation: Missing timestamp parameter validation in document deletion endpoint. While the code checks for the presence of the timestamp parameter, it doesn't validate the format or range. This could lead to unintended document deletions with malformed timestamps.

Recommendation:

if (!timestamp) {
  return apiErrors.missingParameter();
}

try {
  const parsedTimestamp = new Date(timestamp);
  if (isNaN(parsedTimestamp.getTime())) {
    return apiErrors.invalidParameter('timestamp must be a valid date string');
  }
} catch (error) {
  return apiErrors.invalidParameter('timestamp must be a valid date string');
}

Comment on lines +74 to +82
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Error Handling

The saveDocument operation lacks error handling, which could result in unhandled promise rejections if the database operation fails, causing the API to crash instead of returning an error response.

Suggested change
const [createdDocument] = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});
return successResponse(createdDocument);
74: + try {
75: + const [createdDocument] = await saveDocument({
76: + id,
77: + content,
78: + title,
79: + kind,
80: + userId: session.user.id,
81: + });
82: +
83: + return successResponse(createdDocument);
84: + } catch (error) {
85: + console.error('Failed to save document:', error);
86: + return apiErrors.serverError();
87: + }
Standards
  • ISO-IEC-25010-Reliability-Fault-Tolerance
  • SRE-Graceful-Degradation

}

export async function PATCH(request: Request) {
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

const { timestamp }: { timestamp: string } = await request.json();
const timestamp = searchParams.get('timestamp');

if (!id) {
return new Response('Missing id', { status: 400 });
return apiErrors.missingParameter();
}

if (!timestamp) {
return apiErrors.missingParameter();
}

const session = await auth();

if (!session || !session.user) {
return new Response('Unauthorized', { status: 401 });
if (!session?.user?.id) {
return apiErrors.unauthorized();
}

const documents = await getDocumentsById({ id });

const [document] = documents;

if (document.userId !== session.user.id) {
return new Response('Unauthorized', { status: 401 });
return apiErrors.documentForbidden();
}

await deleteDocumentsByIdAfterTimestamp({
const deletedDocuments = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: new Date(timestamp),
});

return new Response('Deleted', { status: 200 });
return successResponse(deletedDocuments);
}
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"formatter": { "enabled": false },
"linter": { "enabled": false }
},
"organizeImports": { "enabled": false },
"organizeImports": { "enabled": true },
"overrides": [
// Playwright requires an object destructure, even if empty
// https://github.com/microsoft/playwright/issues/30007
Expand Down
18 changes: 9 additions & 9 deletions components/version-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ export const VersionFooter = ({

mutate(
`/api/document?id=${artifact.documentId}`,
await fetch(`/api/document?id=${artifact.documentId}`, {
method: 'PATCH',
body: JSON.stringify({
timestamp: getDocumentTimestampByIndex(
documents,
currentVersionIndex,
),
}),
}),
await fetch(
`/api/document?id=${artifact.documentId}&timestamp=${getDocumentTimestampByIndex(
documents,
currentVersionIndex,
)}`,
{
method: 'DELETE',
},
),
{
optimisticData: documents
? [
Expand Down
38 changes: 26 additions & 12 deletions lib/db/queries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import 'server-only';

import { genSaltSync, hashSync } from 'bcrypt-ts';
import { and, asc, desc, eq, gt, gte, inArray, lt, SQL } from 'drizzle-orm';
import {
and,
asc,
desc,
eq,
gt,
gte,
inArray,
lt,
type SQL,
} from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

Expand All @@ -15,9 +25,9 @@ import {
message,
vote,
type DBMessage,
Chat,
type Chat,
} from './schema';
import { ArtifactKind } from '@/components/artifact';
import type { ArtifactKind } from '@/components/artifact';

// Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth
Expand Down Expand Up @@ -241,14 +251,17 @@ export async function saveDocument({
userId: string;
}) {
try {
return await db.insert(document).values({
id,
title,
kind,
content,
userId,
createdAt: new Date(),
});
return await db
.insert(document)
.values({
id,
title,
kind,
content,
userId,
createdAt: new Date(),
})
.returning();
} catch (error) {
console.error('Failed to save document in database');
throw error;
Expand Down Expand Up @@ -304,7 +317,8 @@ export async function deleteDocumentsByIdAfterTimestamp({

return await db
.delete(document)
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)));
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)))
.returning();
} catch (error) {
console.error(
'Failed to delete documents by id after timestamp from database',
Expand Down
19 changes: 19 additions & 0 deletions lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const ERRORS = {
MISSING_PARAMETER: {
type: 'missing_parameter',
message: 'Missing parameter',
},
UNAUTHORIZED: {
type: 'unauthorized',
message: 'Unauthorized',
},
DOCUMENT_NOT_FOUND: {
type: 'document_not_found',
message: 'Document not found',
},
DOCUMENT_FORBIDDEN: {
type: 'document_forbidden',
message:
'Access to this document is forbidden. You may not have the required permissions.',
},
};
19 changes: 19 additions & 0 deletions lib/responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ERRORS } from './errors';

export function successResponse(data: any) {
return Response.json({ data, error: null }, { status: 200 });
}

export function errorResponse(
error: { type: string; message: string },
status: number,
) {
return Response.json({ data: null, error }, { status });
}

export const apiErrors = {
missingParameter: () => errorResponse(ERRORS.MISSING_PARAMETER, 400),
unauthorized: () => errorResponse(ERRORS.UNAUTHORIZED, 401),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error Handling: The error response system is missing a specific error type for invalid parameters. Consider adding an 'invalidParameter' error type to provide more accurate error messages for validation failures.

Recommendation:

invalidParameter: (message = 'Invalid parameter') => errorResponse({ type: 'invalid_parameter', message }, 400),

documentNotFound: () => errorResponse(ERRORS.DOCUMENT_NOT_FOUND, 404),
documentForbidden: () => errorResponse(ERRORS.DOCUMENT_FORBIDDEN, 403),
};
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test --workers=4"
"test": "pnpm tsx tests/run-tests"
},
"dependencies": {
"@ai-sdk/react": "^1.2.8",
Expand Down Expand Up @@ -92,6 +92,7 @@
"@types/pdf-parse": "^1.1.4",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/wait-on": "^5.3.4",
"drizzle-kit": "^0.25.0",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
Expand All @@ -101,7 +102,8 @@
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.19.1",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"wait-on": "^8.0.3"
},
"packageManager": "[email protected]"
}
18 changes: 9 additions & 9 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: [['line']],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
Expand Down Expand Up @@ -91,6 +91,14 @@ export default defineConfig({
storageState: 'playwright/.auth/session.json',
},
},
{
name: 'routes',
testMatch: /routes\/.*\.test.ts/,
dependencies: [],
use: {
...devices['Desktop Chrome'],
},
},

// {
// name: 'firefox',
Expand Down Expand Up @@ -122,12 +130,4 @@ export default defineConfig({
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],

/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm dev',
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
});
Loading