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
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"next-themes": "^0.4.6",
"openai": "^5.12.0",
"openid-client": "^6.6.3",
"postgres": "^3.4.9",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"include": ["client/src/**/*", "shared/**/*", "server/**/*", "worker/src/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
Expand All @@ -14,7 +14,7 @@
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"types": ["node", "vite/client", "@cloudflare/workers-types"],
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
Expand Down
304 changes: 304 additions & 0 deletions worker/__tests__/asset-reads.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
// @canon: chittycanon://core/services/chittyassets
// Integration tests for Phase 2a asset read routes.
// Tests call real Neon (ephemeral branch) — NO MOCKS, NO FAKE DATA.
//
// Test seam: uses registerAssetRoutes() with a claims-injecting pass-through
// middleware instead of requireChittyAuth, so we don't need a real JWT
// infrastructure. The data path (Drizzle → Neon) is 100% real.
//
// Per chittycanon://gov/governance#core-types — owner is a Person (P) entity.
// Authority (A), Location (L), Thing (T), Event (E) types are not exercised
// in this asset-read test but the type system enumerates all five.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Hono } from "hono";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { registerAssetRoutes } from "../src/routes/assets";
import type { ChittyAuthClaims, Env } from "../src/env";
import * as schema from "../../shared/schema";

// ---------------------------------------------------------------------------
// Test configuration — connection string from env var.
// ---------------------------------------------------------------------------
const TEST_DB_URL = process.env.TEST_DB_URL;
if (!TEST_DB_URL) {
throw new Error(
"TEST_DB_URL must be set to an ephemeral Neon branch connection string.",
Comment on lines +25 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Skip DB integration suite when TEST_DB_URL is absent

This file throws at module-load time when TEST_DB_URL is unset, so a normal npm test run fails before any tests execute in environments that do not provision a Neon branch URL. Because this suite is included by the repo-wide Vitest pattern, the hard throw makes unrelated test runs fail by default; gate the suite with describe.skipIf(...) (or similar) instead of throwing at import time.

Useful? React with 👍 / 👎.

);
}

// ChittyOS-shaped fixture identities. Person (P) entities, canonical format.
const OWNER_CHITTY_ID = "01-A-CHT-ASST-P-5A-1-X";
const INTRUDER_CHITTY_ID = "01-A-CHT-ASST-P-5B-1-X";
let ASSET_ID_1: string;
let ASSET_ID_2: string;
let EVIDENCE_ID: string;

function buildTestApp(claimsOverride: ChittyAuthClaims) {
const stubEnv = {
ENVIRONMENT: "development" as const,
CHITTYAUTH_ISSUER: "https://auth.chitty.cc",
CHITTYAUTH_JWKS_URL: "https://auth.chitty.cc/.well-known/jwks.json",
CHITTYAUTH_AUDIENCE: "chittyassets-api",
CHITTYASSETS_DB: { connectionString: TEST_DB_URL } as unknown as Hyperdrive,
} as Env;

const app = new Hono<{
Bindings: Env;
Variables: { claims: ChittyAuthClaims };
}>();

app.use("*", async (c, next) => {
Object.defineProperty(c, "env", {
get: () => stubEnv,
configurable: true,
});
c.set("claims", claimsOverride);
await next();
});

const apiApp = new Hono<{
Bindings: Env;
Variables: { claims: ChittyAuthClaims };
}>();
registerAssetRoutes(apiApp, async (_c, next) => {
await next();
});
app.route("/api", apiApp);

app.onError((err, c) => {
// eslint-disable-next-line no-console
console.error("test_error", err.message, err.stack);
return c.json({ error: "internal_error", detail: err.message }, 500);
});

return app;
}

function ownerClaims(): ChittyAuthClaims {
const now = Math.floor(Date.now() / 1000);
return {
iss: "https://auth.chitty.cc",
sub: OWNER_CHITTY_ID,
chitty_id: OWNER_CHITTY_ID,
entity_type: "P",
trust_level: 3,
exp: now + 3600,
iat: now,
email: "owner.test@chitty.cc",
};
}

let sql: ReturnType<typeof postgres>;
let db: ReturnType<typeof drizzle>;

beforeAll(async () => {
sql = postgres(TEST_DB_URL!, { ssl: "require", max: 1 });
db = drizzle(sql, { schema });

await db
.insert(schema.users)
.values({
id: OWNER_CHITTY_ID,
chittyId: OWNER_CHITTY_ID,
email: "owner.test@chitty.cc",
firstName: "Test",
lastName: "Owner",
})
.onConflictDoNothing();

const [a1] = await db
.insert(schema.assets)
.values({
userId: OWNER_CHITTY_ID,
name: "Artisan Pocket Watch — 1921 Patek Philippe",
assetType: "jewelry",
status: "active",
currentValue: "42500.00",
trustScore: "4.2",
verificationStatus: "verified",
chittyChainStatus: "minted",
})
.returning();
ASSET_ID_1 = a1.id;

const [a2] = await db
.insert(schema.assets)
.values({
userId: OWNER_CHITTY_ID,
name: "MacBook Pro M3 Max — Serial CK3T4P8XQ1",
assetType: "electronics",
status: "active",
currentValue: "3200.00",
trustScore: "3.8",
verificationStatus: "pending",
chittyChainStatus: "draft",
})
.returning();
ASSET_ID_2 = a2.id;

const [ev] = await db
.insert(schema.evidence)
.values({
assetId: ASSET_ID_1,
userId: OWNER_CHITTY_ID,
name: "Patek Philippe Certificate of Authenticity",
evidenceType: "contract",
verificationStatus: "verified",
})
.returning();
EVIDENCE_ID = ev.id;

await db.insert(schema.timelineEvents).values({
assetId: ASSET_ID_1,
userId: OWNER_CHITTY_ID,
eventType: "acquisition",
title: "Watch acquired at Christie's auction — Lot 284",
description: "Acquired via Christie's Geneva auction. Provenance verified.",
eventDate: new Date("2024-11-15T10:30:00Z"),
});
});

afterAll(async () => {
if (ASSET_ID_1) {
await sql`DELETE FROM timeline_events WHERE asset_id = ${ASSET_ID_1}`;
await sql`DELETE FROM evidence WHERE asset_id = ${ASSET_ID_1}`;
}
if (ASSET_ID_2) {
await sql`DELETE FROM assets WHERE id = ${ASSET_ID_2}`;
}
if (ASSET_ID_1) {
await sql`DELETE FROM assets WHERE id = ${ASSET_ID_1}`;
}
await sql`DELETE FROM users WHERE id = ${OWNER_CHITTY_ID}`;
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 Prevent teardown from deleting pre-existing shared user

The suite uses a fixed OWNER_CHITTY_ID and inserts with onConflictDoNothing(), so if that ID already exists in the shared Neon branch, the test reuses someone else’s row and then unconditionally deletes it in teardown. In shared/dev environments this can remove non-test data and break other tests or manual QA; generate a per-run owner ID (or track whether this test created the user) before issuing the final delete.

Useful? React with 👍 / 👎.

await sql.end();
});

describe("GET /api/assets", () => {
it("200 — returns owner's assets", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request("/api/assets");
expect(res.status).toBe(200);
const body = (await res.json()) as any[];
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
expect(body.every((a: any) => a.userId === OWNER_CHITTY_ID)).toBe(true);
});

it("200 — filter by assetType=jewelry returns only jewelry", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request("/api/assets?type=jewelry");
expect(res.status).toBe(200);
const body = (await res.json()) as any[];
expect(body.every((a: any) => a.assetType === "jewelry")).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
});

it("200 — intruder sees empty list (ownership filter)", async () => {
const intruder: ChittyAuthClaims = {
...ownerClaims(),
sub: INTRUDER_CHITTY_ID,
chitty_id: INTRUDER_CHITTY_ID,
};
const app = buildTestApp(intruder);
const res = await app.request("/api/assets");
expect(res.status).toBe(200);
const body = (await res.json()) as any[];
expect(body.length).toBe(0);
});
});

describe("GET /api/assets/stats", () => {
it("200 — returns correct aggregate shape", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request("/api/assets/stats");
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(typeof body.totalAssets).toBe("number");
expect(body.totalAssets).toBeGreaterThanOrEqual(2);
expect(typeof body.totalValue).toBe("number");
expect(typeof body.verifiedAssets).toBe("number");
expect(typeof body.averageTrustScore).toBe("number");
expect(body.assetsByType).toHaveProperty("jewelry");
expect(body.assetsByType).toHaveProperty("electronics");
});
});

describe("GET /api/assets/:id", () => {
it("200 — returns asset for owner", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request(`/api/assets/${ASSET_ID_1}`);
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body.id).toBe(ASSET_ID_1);
expect(body.userId).toBe(OWNER_CHITTY_ID);
});

it("404 — intruder cannot see owner's asset", async () => {
const intruder: ChittyAuthClaims = {
...ownerClaims(),
sub: INTRUDER_CHITTY_ID,
chitty_id: INTRUDER_CHITTY_ID,
};
const app = buildTestApp(intruder);
const res = await app.request(`/api/assets/${ASSET_ID_1}`);
expect(res.status).toBe(404);
});

it("400 — bad UUID format returns 400", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request("/api/assets/not-a-uuid");
expect(res.status).toBe(400);
});
});

describe("GET /api/assets/:assetId/evidence", () => {
it("200 — returns evidence list for owner's asset", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request(`/api/assets/${ASSET_ID_1}/evidence`);
expect(res.status).toBe(200);
const body = (await res.json()) as any[];
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body[0].id).toBe(EVIDENCE_ID);
});

it("200 — empty array for intruder query (no existence leak)", async () => {
const intruder: ChittyAuthClaims = {
...ownerClaims(),
sub: INTRUDER_CHITTY_ID,
chitty_id: INTRUDER_CHITTY_ID,
};
const app = buildTestApp(intruder);
const res = await app.request(`/api/assets/${ASSET_ID_1}/evidence`);
expect(res.status).toBe(200);
const body = (await res.json()) as any[];
expect(body.length).toBe(0);
});

it("400 — bad assetId returns 400", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request("/api/assets/not-a-uuid/evidence");
expect(res.status).toBe(400);
});
});

describe("GET /api/assets/:assetId/timeline", () => {
it("200 — returns timeline events for owner's asset", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request(`/api/assets/${ASSET_ID_1}/timeline`);
expect(res.status).toBe(200);
const body = (await res.json()) as any[];
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body[0].assetId).toBe(ASSET_ID_1);
expect(body[0].eventType).toBe("acquisition");
});

it("400 — bad assetId returns 400", async () => {
const app = buildTestApp(ownerClaims());
const res = await app.request("/api/assets/bad-id/timeline");
expect(res.status).toBe(400);
});
});
29 changes: 29 additions & 0 deletions worker/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// @canon: chittycanon://core/services/chittyassets
// Drizzle ORM factory for Cloudflare Workers + Hyperdrive.
//
// Per-request instantiation — do NOT cache at module scope. Hyperdrive pools
// connections externally; each Worker invocation gets a proxied connection string.
//
// Adapter: drizzle-orm/postgres-js (postgres.js) — Cloudflare-recommended for Hyperdrive.

import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "@shared/schema";

export type ChittyAssetsDb = ReturnType<typeof getDb>;

/**
* Create a Drizzle client for a single Worker invocation.
* Pass `env.CHITTYASSETS_DB.connectionString` in production,
* or a direct Neon connection string in integration tests.
*/
export function getDb(connectionString: string) {
const sql = postgres(connectionString, {
// max=1: Hyperdrive pools externally; one connection per isolate invocation.
max: 1,
idle_timeout: 20,
ssl: "require",
connection: { application_name: "chittyassets-worker" },
});
return drizzle(sql, { schema });
}
4 changes: 2 additions & 2 deletions worker/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export interface Env {
CHITTYCONNECT_URL?: string;
CHITTYLEDGER_URL?: string;

// Phase 2+ bindings:
// CHITTYASSETS_DB: Hyperdrive;
// Phase 2+ bindings (active):
CHITTYASSETS_DB: Hyperdrive;
// EVIDENCE: R2Bucket;
// PROCESSED: R2Bucket;
// CHITTYCONNECT: Fetcher;
Expand Down
Loading