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
301 changes: 301 additions & 0 deletions packages/frontend/__tests__/api/submit.test.ts

Large diffs are not rendered by default.

200 changes: 200 additions & 0 deletions packages/frontend/__tests__/api/submitAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const mockState = vi.hoisted(() => {
const authenticatePersonalToken = vi.fn();
const validateSubmission = vi.fn();
const generateSubmissionHash = vi.fn(() => "submission-hash");
const hashToken = vi.fn((token: string) => `hashed_${token}`);
const revalidateTag = vi.fn();

const db = {
Expand All @@ -14,12 +15,14 @@ const mockState = vi.hoisted(() => {
authenticatePersonalToken,
validateSubmission,
generateSubmissionHash,
hashToken,
revalidateTag,
db,
reset() {
authenticatePersonalToken.mockReset();
validateSubmission.mockReset();
generateSubmissionHash.mockClear();
hashToken.mockClear();
revalidateTag.mockClear();
db.transaction.mockReset();
},
Expand All @@ -36,10 +39,16 @@ vi.mock("@/lib/auth/personalTokens", () => ({

vi.mock("@/lib/db", () => ({
db: mockState.db,
apiTokens: {
id: "apiTokens.id",
},
submissions: {
id: "submissions.id",
userId: "submissions.userId",
},
submissionReviews: {
id: "submissionReviews.id",
},
dailyBreakdown: {
id: "dailyBreakdown.id",
submissionId: "dailyBreakdown.submissionId",
Expand All @@ -51,6 +60,10 @@ vi.mock("@/lib/validation/submission", () => ({
generateSubmissionHash: mockState.generateSubmissionHash,
}));

vi.mock("@/lib/auth/utils", () => ({
hashToken: mockState.hashToken,
}));

vi.mock("@/lib/db/helpers", () => ({
mergeClientBreakdowns: vi.fn(),
recalculateDayTotals: vi.fn(),
Expand All @@ -73,6 +86,22 @@ beforeEach(() => {
});

describe("POST /api/submit auth path", () => {
it("rejects missing bearer auth before any downstream work", async () => {
const response = await POST(
new Request("http://localhost:3000/api/submit", {
method: "POST",
body: JSON.stringify({}),
})
);

expect(response.status).toBe(401);
expect(mockState.authenticatePersonalToken).not.toHaveBeenCalled();
expect(mockState.validateSubmission).not.toHaveBeenCalled();
expect(mockState.db.transaction).not.toHaveBeenCalled();
expect(mockState.revalidateTag).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "Missing or invalid Authorization header" });
});

it("rejects invalid API tokens through the shared auth service", async () => {
mockState.authenticatePersonalToken.mockResolvedValue({ status: "invalid" });

Expand All @@ -89,6 +118,7 @@ describe("POST /api/submit auth path", () => {
expect(response.status).toBe(401);
expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_invalid", {
touchLastUsedAt: false,
upgradeLegacyTokenHash: false,
});
expect(await response.json()).toEqual({ error: "Invalid API token" });
});
Expand All @@ -109,11 +139,46 @@ describe("POST /api/submit auth path", () => {
expect(response.status).toBe(401);
expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_expired", {
touchLastUsedAt: false,
upgradeLegacyTokenHash: false,
});
expect(await response.json()).toEqual({ error: "API token has expired" });
expect(mockState.db.transaction).not.toHaveBeenCalled();
});

it("rejects malformed JSON before validation or persistence", async () => {
mockState.authenticatePersonalToken.mockResolvedValue({
status: "valid",
tokenId: "token-1",
userId: "user-1",
username: "alice",
displayName: "Alice",
avatarUrl: null,
isAdmin: false,
expiresAt: null,
});

const response = await POST(
new Request("http://localhost:3000/api/submit", {
method: "POST",
headers: {
Authorization: "Bearer tt_valid",
"Content-Type": "application/json",
},
body: "{",
})
);

expect(response.status).toBe(400);
expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", {
touchLastUsedAt: false,
upgradeLegacyTokenHash: false,
});
expect(mockState.validateSubmission).not.toHaveBeenCalled();
expect(mockState.db.transaction).not.toHaveBeenCalled();
expect(mockState.revalidateTag).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "Invalid JSON body" });
});

it("accepts a valid token and continues into submission validation", async () => {
mockState.authenticatePersonalToken.mockResolvedValue({
status: "valid",
Expand Down Expand Up @@ -145,12 +210,147 @@ describe("POST /api/submit auth path", () => {
expect(response.status).toBe(400);
expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", {
touchLastUsedAt: false,
upgradeLegacyTokenHash: false,
});
expect(mockState.validateSubmission).toHaveBeenCalledTimes(1);
expect(mockState.db.transaction).not.toHaveBeenCalled();
expect(await response.json()).toEqual({
error: "Validation failed",
details: ["bad payload"],
trustState: "rejected",
});
});

it("rejects empty validated submissions before entering the transaction", async () => {
mockState.authenticatePersonalToken.mockResolvedValue({
status: "valid",
tokenId: "token-1",
userId: "user-1",
username: "alice",
displayName: "Alice",
avatarUrl: null,
isAdmin: false,
expiresAt: null,
});
mockState.validateSubmission.mockReturnValue({
valid: true,
errors: [],
warnings: [],
data: {
meta: {
generatedAt: new Date().toISOString(),
version: "1.0.0",
dateRange: { start: "2024-12-01", end: "2024-12-01" },
},
summary: {
totalTokens: 0,
totalCost: 0,
totalDays: 0,
activeDays: 0,
averagePerDay: 0,
maxCostInSingleDay: 0,
clients: [],
models: [],
},
years: [],
contributions: [],
},
});

const response = await POST(
new Request("http://localhost:3000/api/submit", {
method: "POST",
headers: {
Authorization: "Bearer tt_valid",
"Content-Type": "application/json",
},
body: JSON.stringify({ meta: {}, contributions: [] }),
})
);

expect(response.status).toBe(400);
expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", {
touchLastUsedAt: false,
upgradeLegacyTokenHash: false,
});
expect(mockState.db.transaction).not.toHaveBeenCalled();
expect(mockState.revalidateTag).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "No contribution data to submit" });
});

it("keeps mixed invalid batches out of the transaction path", async () => {
mockState.authenticatePersonalToken.mockResolvedValue({
status: "valid",
tokenId: "token-1",
userId: "user-1",
username: "alice",
displayName: "Alice",
avatarUrl: null,
isAdmin: false,
expiresAt: null,
});
mockState.validateSubmission.mockReturnValue({
valid: false,
data: null,
errors: ["Future date found in contributions: 2099-01-01"],
warnings: [],
});

const response = await POST(
new Request("http://localhost:3000/api/submit", {
method: "POST",
headers: {
Authorization: "Bearer tt_valid",
"Content-Type": "application/json",
},
body: JSON.stringify({
meta: {
generatedAt: new Date().toISOString(),
version: "1.0.0",
dateRange: { start: "2024-12-01", end: "2099-01-01" },
},
summary: {
totalTokens: 200,
totalCost: 2,
totalDays: 2,
activeDays: 2,
averagePerDay: 1,
maxCostInSingleDay: 1,
clients: ["claude"],
models: ["claude-sonnet-4"],
},
years: [],
contributions: [
{
date: "2024-12-01",
totals: { tokens: 100, cost: 1, messages: 1 },
intensity: 1,
tokenBreakdown: { input: 50, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
clients: [],
},
{
date: "2099-01-01",
totals: { tokens: 100, cost: 1, messages: 1 },
intensity: 1,
tokenBreakdown: { input: 50, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
clients: [],
},
],
}),
})
);

expect(response.status).toBe(400);
expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", {
touchLastUsedAt: false,
upgradeLegacyTokenHash: false,
});
expect(mockState.db.transaction).not.toHaveBeenCalled();
expect(mockState.revalidateTag).not.toHaveBeenCalled();
expect(await response.json()).toEqual({
error: "Validation failed",
details: ["Future date found in contributions: 2099-01-01"],
trustState: "rejected",
});
});
});
Loading