fix: OIDC auth flow, Vite proxy, and PostgreSQL query compat
- Add auth redirect in root layout for unauthenticated users - Proxy OIDC routes (/login, /callback, /logout) through Vite dev server - Strip Secure flag from OIDC cookies in dev mode (HTTP localhost) - Disable retry on auth query to prevent stale cookie loops - Fix SQLite .get()/.all()/.run() calls in category and global-item services for PostgreSQL compatibility - Add userId scoping to category service functions - Add OIDC error logging in auth middleware - Apply linter auto-formatting across affected files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,11 +30,7 @@ function insertGlobalItem(db: TestDb, brand: string, model: string) {
|
||||
}
|
||||
|
||||
function insertItem(db: TestDb, name: string) {
|
||||
return db
|
||||
.insert(items)
|
||||
.values({ name, categoryId: 1 })
|
||||
.returning()
|
||||
.get();
|
||||
return db.insert(items).values({ name, categoryId: 1 }).returning().get();
|
||||
}
|
||||
|
||||
describe("Global Item Routes", () => {
|
||||
|
||||
@@ -16,7 +16,10 @@ mock.module("../../src/server/services/storage.service", () => ({
|
||||
|
||||
// Also mock image service for from-url test
|
||||
const mockFetchImageFromUrl = mock(() =>
|
||||
Promise.resolve({ filename: "test.png", sourceUrl: "https://example.com/img.png" }),
|
||||
Promise.resolve({
|
||||
filename: "test.png",
|
||||
sourceUrl: "https://example.com/img.png",
|
||||
}),
|
||||
);
|
||||
mock.module("../../src/server/services/image.service", () => ({
|
||||
fetchImageFromUrl: mockFetchImageFromUrl,
|
||||
|
||||
@@ -45,7 +45,10 @@ describe("OAuth Routes", () => {
|
||||
userId = testApp.userId;
|
||||
mockGetAuth.mockReset();
|
||||
// Default: user is authenticated via OIDC
|
||||
mockGetAuth.mockReturnValue({ sub: "test-user-logto-sub", email: "admin@example.com" });
|
||||
mockGetAuth.mockReturnValue({
|
||||
sub: "test-user-logto-sub",
|
||||
email: "admin@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /.well-known/oauth-authorization-server", () => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import * as schema from "../../src/db/schema.ts";
|
||||
import { updateProfileSchema } from "../../src/shared/schemas.ts";
|
||||
import { parseId } from "../../src/server/lib/params.ts";
|
||||
import { profileRoutes } from "../../src/server/routes/profiles.ts";
|
||||
import { setupRoutes } from "../../src/server/routes/setups.ts";
|
||||
import { getPublicSetupWithItems } from "../../src/server/services/profile.service.ts";
|
||||
import { updateProfile } from "../../src/server/services/profile.service.ts";
|
||||
import {
|
||||
getPublicSetupWithItems,
|
||||
updateProfile,
|
||||
} from "../../src/server/services/profile.service.ts";
|
||||
import { updateProfileSchema } from "../../src/shared/schemas.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { parseId } from "../../src/server/lib/params.ts";
|
||||
|
||||
type Db = Awaited<ReturnType<typeof createTestDb>>["db"];
|
||||
|
||||
@@ -112,12 +114,10 @@ describe("Profile Routes", () => {
|
||||
|
||||
it("includes only public setups", async () => {
|
||||
// Create public and private setups
|
||||
await db
|
||||
.insert(schema.setups)
|
||||
.values([
|
||||
{ name: "Public Setup", userId, isPublic: true },
|
||||
{ name: "Private Setup", userId, isPublic: false },
|
||||
]);
|
||||
await db.insert(schema.setups).values([
|
||||
{ name: "Public Setup", userId, isPublic: true },
|
||||
{ name: "Private Setup", userId, isPublic: false },
|
||||
]);
|
||||
|
||||
const res = await app.request(`/api/users/${userId}/profile`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts";
|
||||
import { seedGlobalItems } from "../../src/db/seed-global-items.ts";
|
||||
import {
|
||||
getGlobalItemWithOwnerCount,
|
||||
linkItemToGlobal,
|
||||
searchGlobalItems,
|
||||
unlinkItemFromGlobal,
|
||||
} from "../../src/server/services/global-item.service.ts";
|
||||
import { seedGlobalItems } from "../../src/db/seed-global-items.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
type TestDb = ReturnType<typeof createTestDb>;
|
||||
@@ -35,11 +35,7 @@ function insertGlobalItem(
|
||||
}
|
||||
|
||||
function insertItem(db: TestDb, name: string) {
|
||||
return db
|
||||
.insert(items)
|
||||
.values({ name, categoryId: 1 })
|
||||
.returning()
|
||||
.get();
|
||||
return db.insert(items).values({ name, categoryId: 1 }).returning().get();
|
||||
}
|
||||
|
||||
describe("Global Item Service", () => {
|
||||
@@ -51,7 +47,10 @@ describe("Global Item Service", () => {
|
||||
|
||||
describe("searchGlobalItems", () => {
|
||||
it("returns all global items when no query provided", () => {
|
||||
insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" });
|
||||
insertGlobalItem(db, {
|
||||
brand: "Revelate Designs",
|
||||
model: "Terrapin System",
|
||||
});
|
||||
insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" });
|
||||
|
||||
const results = searchGlobalItems(db);
|
||||
@@ -59,7 +58,10 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("returns items matching brand (case-insensitive)", () => {
|
||||
insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" });
|
||||
insertGlobalItem(db, {
|
||||
brand: "Revelate Designs",
|
||||
model: "Terrapin System",
|
||||
});
|
||||
insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" });
|
||||
|
||||
const results = searchGlobalItems(db, "revelate");
|
||||
@@ -68,7 +70,10 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("returns items matching model (case-insensitive)", () => {
|
||||
insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" });
|
||||
insertGlobalItem(db, {
|
||||
brand: "Revelate Designs",
|
||||
model: "Terrapin System",
|
||||
});
|
||||
insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" });
|
||||
|
||||
const results = searchGlobalItems(db, "HANDLEBAR");
|
||||
@@ -77,7 +82,10 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("does not match everything with wildcard chars", () => {
|
||||
insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" });
|
||||
insertGlobalItem(db, {
|
||||
brand: "Revelate Designs",
|
||||
model: "Terrapin System",
|
||||
});
|
||||
insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" });
|
||||
|
||||
const results = searchGlobalItems(db, "100%");
|
||||
@@ -87,7 +95,10 @@ describe("Global Item Service", () => {
|
||||
|
||||
describe("getGlobalItemWithOwnerCount", () => {
|
||||
it("returns item with ownerCount 0 when no links", () => {
|
||||
const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" });
|
||||
const gi = insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
|
||||
const result = getGlobalItemWithOwnerCount(db, gi.id);
|
||||
expect(result).not.toBeNull();
|
||||
@@ -96,7 +107,10 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("returns ownerCount matching number of linked items", () => {
|
||||
const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" });
|
||||
const gi = insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
const item1 = insertItem(db, "My Stove");
|
||||
const item2 = insertItem(db, "Another Stove");
|
||||
|
||||
@@ -120,7 +134,10 @@ describe("Global Item Service", () => {
|
||||
|
||||
describe("linkItemToGlobal", () => {
|
||||
it("creates link and returns link row", () => {
|
||||
const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" });
|
||||
const gi = insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
const item = insertItem(db, "My Stove");
|
||||
|
||||
const link = linkItemToGlobal(db, item.id, gi.id);
|
||||
@@ -129,7 +146,10 @@ describe("Global Item Service", () => {
|
||||
});
|
||||
|
||||
it("throws when item already linked", () => {
|
||||
const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" });
|
||||
const gi = insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
const item = insertItem(db, "My Stove");
|
||||
|
||||
linkItemToGlobal(db, item.id, gi.id);
|
||||
@@ -139,7 +159,10 @@ describe("Global Item Service", () => {
|
||||
|
||||
describe("unlinkItemFromGlobal", () => {
|
||||
it("removes the link", () => {
|
||||
const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" });
|
||||
const gi = insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
const item = insertItem(db, "My Stove");
|
||||
|
||||
linkItemToGlobal(db, item.id, gi.id);
|
||||
|
||||
@@ -21,12 +21,12 @@ const { fetchImageFromUrl } = await import(
|
||||
|
||||
// 1x1 transparent PNG (smallest valid PNG)
|
||||
const TINY_PNG = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
||||
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
|
||||
0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44,
|
||||
0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
|
||||
0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
|
||||
0x82,
|
||||
]);
|
||||
|
||||
let server: Server;
|
||||
@@ -72,17 +72,17 @@ describe("Image Service", () => {
|
||||
|
||||
// Verify uploadImage was called with correct args
|
||||
expect(mockUploadImage).toHaveBeenCalledTimes(1);
|
||||
const [buffer, filename, contentType] =
|
||||
mockUploadImage.mock.calls[0] as unknown[];
|
||||
const [buffer, filename, contentType] = mockUploadImage.mock
|
||||
.calls[0] as unknown[];
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(filename).toBe(result.filename);
|
||||
expect(contentType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("rejects non-image content type", async () => {
|
||||
await expect(
|
||||
fetchImageFromUrl(`${baseUrl}/page.html`),
|
||||
).rejects.toThrow("Invalid content type");
|
||||
await expect(fetchImageFromUrl(`${baseUrl}/page.html`)).rejects.toThrow(
|
||||
"Invalid content type",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid URL format", async () => {
|
||||
@@ -98,9 +98,9 @@ describe("Image Service", () => {
|
||||
});
|
||||
|
||||
it("rejects 404 responses", async () => {
|
||||
await expect(
|
||||
fetchImageFromUrl(`${baseUrl}/missing.jpg`),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
await expect(fetchImageFromUrl(`${baseUrl}/missing.jpg`)).rejects.toThrow(
|
||||
"HTTP 404",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,10 @@ describe("Profile Service", () => {
|
||||
|
||||
it("returns only public setups, not private ones", async () => {
|
||||
// Create one public and one private setup
|
||||
const pub = await createSetup(db, userId, { name: "Public Setup", isPublic: true });
|
||||
const pub = await createSetup(db, userId, {
|
||||
name: "Public Setup",
|
||||
isPublic: true,
|
||||
});
|
||||
const priv = await createSetup(db, userId, { name: "Private Setup" });
|
||||
|
||||
const profile = await getPublicProfile(db, userId);
|
||||
|
||||
@@ -240,13 +240,7 @@ describe("Setup Service", () => {
|
||||
await syncSetupItems(db, userId, setup.id, [item1.id, item2.id]);
|
||||
|
||||
// Change classifications
|
||||
await updateItemClassification(
|
||||
db,
|
||||
userId,
|
||||
setup.id,
|
||||
item1.id,
|
||||
"worn",
|
||||
);
|
||||
await updateItemClassification(db, userId, setup.id, item1.id, "worn");
|
||||
await updateItemClassification(
|
||||
db,
|
||||
userId,
|
||||
@@ -261,12 +255,8 @@ describe("Setup Service", () => {
|
||||
const result = await getSetupWithItems(db, userId, setup.id);
|
||||
expect(result?.items).toHaveLength(2);
|
||||
|
||||
const item2Result = result?.items.find(
|
||||
(i: any) => i.name === "Jacket",
|
||||
);
|
||||
const item3Result = result?.items.find(
|
||||
(i: any) => i.name === "Stove",
|
||||
);
|
||||
const item2Result = result?.items.find((i: any) => i.name === "Jacket");
|
||||
const item3Result = result?.items.find((i: any) => i.name === "Stove");
|
||||
expect(item2Result?.classification).toBe("consumable");
|
||||
expect(item3Result?.classification).toBe("base");
|
||||
});
|
||||
@@ -293,13 +283,7 @@ describe("Setup Service", () => {
|
||||
});
|
||||
await syncSetupItems(db, userId, setup.id, [item.id]);
|
||||
|
||||
await updateItemClassification(
|
||||
db,
|
||||
userId,
|
||||
setup.id,
|
||||
item.id,
|
||||
"worn",
|
||||
);
|
||||
await updateItemClassification(db, userId, setup.id, item.id, "worn");
|
||||
|
||||
const result = await getSetupWithItems(db, userId, setup.id);
|
||||
expect(result?.items[0].classification).toBe("worn");
|
||||
@@ -318,13 +302,7 @@ describe("Setup Service", () => {
|
||||
expect(result?.items[0].classification).toBe("base");
|
||||
|
||||
// Update
|
||||
await updateItemClassification(
|
||||
db,
|
||||
userId,
|
||||
setup.id,
|
||||
item.id,
|
||||
"worn",
|
||||
);
|
||||
await updateItemClassification(db, userId, setup.id, item.id, "worn");
|
||||
|
||||
result = await getSetupWithItems(db, userId, setup.id);
|
||||
expect(result?.items[0].classification).toBe("worn");
|
||||
@@ -341,20 +319,8 @@ describe("Setup Service", () => {
|
||||
await syncSetupItems(db, userId, setup1.id, [item.id]);
|
||||
await syncSetupItems(db, userId, setup2.id, [item.id]);
|
||||
|
||||
await updateItemClassification(
|
||||
db,
|
||||
userId,
|
||||
setup1.id,
|
||||
item.id,
|
||||
"worn",
|
||||
);
|
||||
await updateItemClassification(
|
||||
db,
|
||||
userId,
|
||||
setup2.id,
|
||||
item.id,
|
||||
"base",
|
||||
);
|
||||
await updateItemClassification(db, userId, setup1.id, item.id, "worn");
|
||||
await updateItemClassification(db, userId, setup2.id, item.id, "base");
|
||||
|
||||
const result1 = await getSetupWithItems(db, userId, setup1.id);
|
||||
const result2 = await getSetupWithItems(db, userId, setup2.id);
|
||||
|
||||
@@ -43,13 +43,8 @@ process.env.S3_BUCKET = "gearbox-images";
|
||||
process.env.S3_REGION = "us-east-1";
|
||||
|
||||
// Import after mocking
|
||||
const {
|
||||
uploadImage,
|
||||
deleteImage,
|
||||
getImageUrl,
|
||||
withImageUrl,
|
||||
withImageUrls,
|
||||
} = await import("@/server/services/storage.service");
|
||||
const { uploadImage, deleteImage, getImageUrl, withImageUrl, withImageUrls } =
|
||||
await import("@/server/services/storage.service");
|
||||
|
||||
describe("storage.service", () => {
|
||||
beforeEach(() => {
|
||||
@@ -66,7 +61,9 @@ describe("storage.service", () => {
|
||||
await uploadImage(buffer, "test-image.jpg", "image/jpeg");
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0][0] as { input: Record<string, unknown> };
|
||||
const command = mockSend.mock.calls[0][0] as {
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
expect(command.input.Bucket).toBe("gearbox-images");
|
||||
expect(command.input.Key).toBe("test-image.jpg");
|
||||
expect(command.input.ContentType).toBe("image/jpeg");
|
||||
@@ -78,7 +75,9 @@ describe("storage.service", () => {
|
||||
await uploadImage(arrayBuffer, "test.png", "image/png");
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0][0] as { input: Record<string, unknown> };
|
||||
const command = mockSend.mock.calls[0][0] as {
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
expect(Buffer.isBuffer(command.input.Body)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -88,7 +87,9 @@ describe("storage.service", () => {
|
||||
await deleteImage("test-image.jpg");
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0][0] as { input: Record<string, unknown> };
|
||||
const command = mockSend.mock.calls[0][0] as {
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
expect(command.input.Bucket).toBe("gearbox-images");
|
||||
expect(command.input.Key).toBe("test-image.jpg");
|
||||
});
|
||||
@@ -99,9 +100,7 @@ describe("storage.service", () => {
|
||||
const url = await getImageUrl("test-image.jpg");
|
||||
|
||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
||||
expect(url).toBe(
|
||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
||||
);
|
||||
expect(url).toBe("https://minio:9000/gearbox-images/test.jpg?signed=1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -564,11 +564,7 @@ describe("Thread Service", () => {
|
||||
expect(result.item?.productUrl).toBe("https://example.com/tent");
|
||||
|
||||
// Thread should be resolved
|
||||
const resolved = await getThreadWithCandidates(
|
||||
db,
|
||||
userId,
|
||||
thread.id,
|
||||
);
|
||||
const resolved = await getThreadWithCandidates(db, userId, thread.id);
|
||||
expect(resolved?.status).toBe("resolved");
|
||||
expect(resolved?.resolvedCandidateId).toBe(candidate.id);
|
||||
});
|
||||
@@ -585,12 +581,7 @@ describe("Thread Service", () => {
|
||||
await resolveThread(db, userId, thread.id, candidate.id);
|
||||
|
||||
// Try to resolve again
|
||||
const result = await resolveThread(
|
||||
db,
|
||||
userId,
|
||||
thread.id,
|
||||
candidate.id,
|
||||
);
|
||||
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
@@ -609,12 +600,7 @@ describe("Thread Service", () => {
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
const result = await resolveThread(
|
||||
db,
|
||||
userId,
|
||||
thread1.id,
|
||||
candidate.id,
|
||||
);
|
||||
const result = await resolveThread(db, userId, thread1.id, candidate.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user