Skip to content
Closed
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
124 changes: 124 additions & 0 deletions app/api/scheduled-actions/delete/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { DELETE } from "../route";
import { validateHeaders } from "@/lib/chat/validateHeaders";
import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess";
import { deleteScheduledActionById } from "@/lib/supabase/scheduled_actions/deleteScheduledActionById";
import { selectScheduledActionById } from "@/lib/supabase/scheduled_actions/selectScheduledActionById";

vi.mock("@/lib/chat/validateHeaders", () => ({
validateHeaders: vi.fn(),
}));

vi.mock("@/lib/supabase/account_artist_ids/checkAccountArtistAccess", () => ({
checkAccountArtistAccess: vi.fn(),
}));

vi.mock("@/lib/supabase/scheduled_actions/selectScheduledActionById", () => ({
selectScheduledActionById: vi.fn(),
}));

vi.mock("@/lib/supabase/scheduled_actions/deleteScheduledActionById", () => ({
deleteScheduledActionById: vi.fn(),
}));

function makeRequest(body: unknown): NextRequest {
return new Request("http://localhost/api/scheduled-actions/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}) as NextRequest;
}

describe("DELETE /api/scheduled-actions/delete", () => {
const mockValidateHeaders = vi.mocked(validateHeaders);
const mockCheckAccountArtistAccess = vi.mocked(checkAccountArtistAccess);
const mockSelectScheduledActionById = vi.mocked(selectScheduledActionById);
const mockDeleteScheduledActionById = vi.mocked(deleteScheduledActionById);

beforeEach(() => {
vi.clearAllMocks();
});

it("returns 401 when caller is unauthenticated", async () => {
mockValidateHeaders.mockResolvedValueOnce({});

const response = await DELETE(makeRequest({ id: "1e632dc8-94aa-4f85-9f85-241213d0d2f9" }));
const data = await response.json();

expect(response.status).toBe(401);
expect(data).toEqual({ error: "Unauthorized" });
expect(mockSelectScheduledActionById).not.toHaveBeenCalled();
});

it("returns 400 when id is missing or invalid", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId: "account-123" });

const response = await DELETE(makeRequest({ id: "not-a-uuid" }));
const data = await response.json();

expect(response.status).toBe(400);
expect(data.error).toContain("UUID");
expect(mockSelectScheduledActionById).not.toHaveBeenCalled();
});

it("returns 404 when task does not exist", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId: "account-123" });
mockSelectScheduledActionById.mockResolvedValueOnce({
data: null,
error: null,
} as Awaited<ReturnType<typeof selectScheduledActionById>>);

const response = await DELETE(makeRequest({ id: "1e632dc8-94aa-4f85-9f85-241213d0d2f9" }));
const data = await response.json();

expect(response.status).toBe(404);
expect(data).toEqual({ error: "Task not found" });
});

it("returns 403 when caller is not owner and lacks artist access", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId: "account-123" });
mockCheckAccountArtistAccess.mockResolvedValueOnce(false);
mockSelectScheduledActionById.mockResolvedValueOnce({
data: {
id: "1e632dc8-94aa-4f85-9f85-241213d0d2f9",
account_id: "other-account",
artist_account_id: "artist-456",
},
error: null,
} as Awaited<ReturnType<typeof selectScheduledActionById>>);

const response = await DELETE(makeRequest({ id: "1e632dc8-94aa-4f85-9f85-241213d0d2f9" }));
const data = await response.json();

expect(response.status).toBe(403);
expect(data).toEqual({ error: "Forbidden" });
expect(mockDeleteScheduledActionById).not.toHaveBeenCalled();
});

it("returns 200 and deletes when caller owns the task", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId: "account-123" });
mockSelectScheduledActionById.mockResolvedValueOnce({
data: {
id: "1e632dc8-94aa-4f85-9f85-241213d0d2f9",
account_id: "account-123",
artist_account_id: "artist-456",
},
error: null,
} as Awaited<ReturnType<typeof selectScheduledActionById>>);
mockDeleteScheduledActionById.mockResolvedValueOnce({
error: null,
} as Awaited<ReturnType<typeof deleteScheduledActionById>>);

const response = await DELETE(makeRequest({ id: "1e632dc8-94aa-4f85-9f85-241213d0d2f9" }));
const data = await response.json();

expect(response.status).toBe(200);
expect(data).toEqual({ success: true });
expect(mockCheckAccountArtistAccess).not.toHaveBeenCalled();
expect(mockDeleteScheduledActionById).toHaveBeenCalledTimes(1);
expect(mockDeleteScheduledActionById).toHaveBeenCalledWith(
"1e632dc8-94aa-4f85-9f85-241213d0d2f9"
);
});
});
59 changes: 50 additions & 9 deletions app/api/scheduled-actions/delete/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import supabase from "@/lib/supabase/serverClient";
import { z } from "zod";
import { validateHeaders } from "@/lib/chat/validateHeaders";
import { checkAccountArtistAccess } from "@/lib/supabase/account_artist_ids/checkAccountArtistAccess";
import { deleteScheduledActionById } from "@/lib/supabase/scheduled_actions/deleteScheduledActionById";
import { selectScheduledActionById } from "@/lib/supabase/scheduled_actions/selectScheduledActionById";

const deleteScheduledActionBodySchema = z.object({
id: z.string().uuid("id must be a valid UUID"),
});

export async function DELETE(req: NextRequest) {
const { id } = await req.json();
const auth = await validateHeaders(req);
if (auth instanceof Response) {
return auth;
}
Comment on lines +13 to +16
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why are you updating the chat codebase to fix an issue with an API endpoint?


if (!id) {
return NextResponse.json({ error: "Missing task id" }, { status: 400 });
const accountId = auth.accountId;
if (!accountId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let body: unknown;
try {
const { error } = await supabase
.from("scheduled_actions")
.delete()
.eq("id", id);
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = deleteScheduledActionBodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid id" }, { status: 400 });
}

try {
const { id } = parsed.data;

const { data: scheduledAction, error: selectError } = await selectScheduledActionById(id);

if (selectError) {
throw new Error(`Failed to load task: ${selectError.message}`);
}

if (!scheduledAction) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}

const canDeleteAsOwner = scheduledAction.account_id === accountId;
const canDeleteAsArtistAccess = canDeleteAsOwner
? true
: await checkAccountArtistAccess(accountId, scheduledAction.artist_account_id);

if (!canDeleteAsOwner && !canDeleteAsArtistAccess) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { error } = await deleteScheduledActionById(id);

if (error) {
throw new Error(`Failed to delete task: ${error.message}`);
Expand All @@ -28,4 +70,3 @@ export async function DELETE(req: NextRequest) {
}

export const dynamic = "force-dynamic";

9 changes: 8 additions & 1 deletion hooks/useDeleteScheduledAction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { toast } from "react-toastify";
import { useQueryClient } from "@tanstack/react-query";
import { usePrivy } from "@privy-io/react-auth";
import { deleteTask } from "@/lib/tasks/deleteTask";

interface DeleteScheduledActionParams {
Expand All @@ -12,6 +13,7 @@ interface DeleteScheduledActionParams {
export const useDeleteScheduledAction = () => {
const [isLoading, setIsLoading] = useState(false);
const queryClient = useQueryClient();
const { getAccessToken } = usePrivy();

const deleteAction = async ({
actionId,
Expand All @@ -20,7 +22,12 @@ export const useDeleteScheduledAction = () => {
}: DeleteScheduledActionParams) => {
setIsLoading(true);
try {
await deleteTask({ id: actionId });
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error("Please sign in to delete scheduled actions");
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P2: The auth error message "Please sign in to delete scheduled actions" is swallowed by the catch block's generic toast.error("Failed to delete. Please try again."). The user never sees the specific sign-in prompt. Consider using the error's message in the toast for auth failures, e.g. toast.error(error instanceof Error ? error.message : "Failed to delete. Please try again.").

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useDeleteScheduledAction.ts, line 27:

<comment>The auth error message "Please sign in to delete scheduled actions" is swallowed by the catch block's generic `toast.error("Failed to delete. Please try again.")`. The user never sees the specific sign-in prompt. Consider using the error's message in the toast for auth failures, e.g. `toast.error(error instanceof Error ? error.message : "Failed to delete. Please try again.")`.</comment>

<file context>
@@ -20,7 +22,12 @@ export const useDeleteScheduledAction = () => {
-      await deleteTask({ id: actionId });
+      const accessToken = await getAccessToken();
+      if (!accessToken) {
+        throw new Error("Please sign in to delete scheduled actions");
+      }
+
</file context>
Fix with Cubic

}

await deleteTask({ id: actionId, accessToken });

onSuccess?.();
toast.success(successMessage);
Expand Down
5 changes: 5 additions & 0 deletions lib/supabase/scheduled_actions/deleteScheduledActionById.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import supabase from "@/lib/supabase/serverClient";

export async function deleteScheduledActionById(id: string) {
return supabase.from("scheduled_actions").delete().eq("id", id);
Comment on lines +3 to +4
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Make the delete authorization atomic.

app/api/scheduled-actions/delete/route.ts authorizes first and then calls this id-only delete, so a concurrent ownership/access change between those two steps can still remove a row the caller no longer controls. For a destructive path, push the permission check into the delete itself (for example via RLS or a single DB-side operation) instead of doing a separate pre-read.

As per coding guidelines, "Implement built-in security practices for authentication and data handling."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/scheduled_actions/deleteScheduledActionById.ts` around lines 3 -
4, The deleteScheduledActionById function performs a blind delete by id which
can race with concurrent permission changes; change its signature to accept the
caller's user id (e.g., add a userId: string parameter) and make the deletion
atomic by adding the ownership filter in the same DB call (use
supabase.from("scheduled_actions").delete().eq("id", id).eq("owner_id",
userId)); alternatively enforce via DB RLS in the same statement, but ensure the
permission check is performed server-side inside deleteScheduledActionById
rather than via a prior read.

}
15 changes: 15 additions & 0 deletions lib/supabase/scheduled_actions/selectScheduledActionById.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import supabase from "@/lib/supabase/serverClient";
import type { Tables } from "@/types/database.types";

export type ScheduledActionAuthRow = Pick<
Tables<"scheduled_actions">,
"id" | "account_id" | "artist_account_id"
>;

export async function selectScheduledActionById(id: string) {
return supabase
.from("scheduled_actions")
.select("id, account_id, artist_account_id")
.eq("id", id)
.maybeSingle<ScheduledActionAuthRow>();
}
15 changes: 12 additions & 3 deletions lib/tasks/deleteTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TASKS_API_URL } from "@/lib/consts";

export interface DeleteTaskParams {
id: string;
accessToken: string;
}

const SCHEDULE_NOT_FOUND_MSG = "Schedule not found";
Expand All @@ -18,10 +19,13 @@ function isScheduleNotFoundError(errorText: string): boolean {
/**
* Delete task record from database when scheduler deletion isn't possible
*/
async function deleteTaskFromDatabase(taskId: string): Promise<void> {
async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
await fetch("/api/scheduled-actions/delete", {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fail when fallback DB delete returns non-2xx

The fallback route now enforces auth and ownership checks, but this fetch result is ignored, so deleteTaskFromDatabase resolves even on 401/403/500. In the "Schedule not found" path, that means the caller reports a successful deletion even when the row was not deleted (for example, expired token or revoked access). Please check response.ok and throw on failure so the UI can surface the error instead of silently succeeding.

Useful? React with 👍 / 👎.

method: "DELETE",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ id: taskId }),
Comment on lines +22 to 29
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 6, 2026

Choose a reason for hiding this comment

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

P1: The fetch response in deleteTaskFromDatabase is never checked. With the newly added Authorization header, the server can now reject the request (401/403), but this failure is silently ignored — the caller returns as if deletion succeeded. Check response.ok and throw on failure so the error propagates to the user.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/tasks/deleteTask.ts, line 22:

<comment>The fetch response in `deleteTaskFromDatabase` is never checked. With the newly added `Authorization` header, the server can now reject the request (401/403), but this failure is silently ignored — the caller returns as if deletion succeeded. Check `response.ok` and throw on failure so the error propagates to the user.</comment>

<file context>
@@ -18,10 +19,13 @@ function isScheduleNotFoundError(errorText: string): boolean {
  * Delete task record from database when scheduler deletion isn't possible
  */
-async function deleteTaskFromDatabase(taskId: string): Promise<void> {
+async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
   await fetch("/api/scheduled-actions/delete", {
     method: "DELETE",
</file context>
Suggested change
async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
await fetch("/api/scheduled-actions/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ id: taskId }),
async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
const response = await fetch("/api/scheduled-actions/delete", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ id: taskId }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to delete task from database: HTTP ${response.status}: ${errorText}`);
}
}
Fix with Cubic

});
Comment on lines +22 to 30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not swallow fallback delete failures.

deleteTaskFromDatabase() ignores the response status, so any 401/403/404/500 from /api/scheduled-actions/delete is treated as success. With the new protected route, that means the caller can reach toast.success(...) even though nothing was deleted. Check response.ok here and throw on non-2xx; if this delete is meant to be idempotent, special-case 404 explicitly instead of swallowing every failure.

Suggested fix
 async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
-  await fetch("/api/scheduled-actions/delete", {
+  const response = await fetch("/api/scheduled-actions/delete", {
     method: "DELETE",
     headers: {
       "Content-Type": "application/json",
       Authorization: `Bearer ${accessToken}`,
     },
     body: JSON.stringify({ id: taskId }),
   });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(`HTTP ${response.status}: ${errorText}`);
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
await fetch("/api/scheduled-actions/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ id: taskId }),
});
async function deleteTaskFromDatabase(taskId: string, accessToken: string): Promise<void> {
const response = await fetch("/api/scheduled-actions/delete", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ id: taskId }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/tasks/deleteTask.ts` around lines 22 - 30, deleteTaskFromDatabase
currently ignores the fetch response so callers may treat failures as success;
update deleteTaskFromDatabase to inspect the fetch Response, check response.ok
and throw an Error for non-2xx statuses, but if the intent is idempotent treat a
404 as success (do not throw for 404). Use the existing function name
deleteTaskFromDatabase and the "/api/scheduled-actions/delete" response to
decide: await fetch(...), then if (!response.ok && response.status !== 404)
throw a descriptive Error (include status/text) so callers can handle/display
errors instead of showing success to the user.

}
Expand All @@ -32,10 +36,15 @@ async function deleteTaskFromDatabase(taskId: string): Promise<void> {
*/
export async function deleteTask(params: DeleteTaskParams): Promise<void> {
try {
if (!params.accessToken) {
throw new Error("Please sign in to delete scheduled actions");
}

const response = await fetch(TASKS_API_URL, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.accessToken}`,
},
body: JSON.stringify({
id: params.id,
Expand All @@ -46,7 +55,7 @@ export async function deleteTask(params: DeleteTaskParams): Promise<void> {
const errorText = await response.text();

if (isScheduleNotFoundError(errorText)) {
await deleteTaskFromDatabase(params.id);
await deleteTaskFromDatabase(params.id, params.accessToken);
return;
}

Expand Down
Loading