diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index 90146d5..a024a2a 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -315,6 +315,594 @@ describe("ucas render command", () => { }); }); +// ---- Issue #50: Schema Validation in put Command ---- + +describe("Issue #50: Schema Validation in put", () => { + describe("Test Group 1: Valid Data (Regression Tests)", () => { + test("T1.1: Valid data matching schema should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Create schema with required name property + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Create valid payload + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: "test" })); + + const { stdout, stderr, exitCode } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // Verify node was stored + const hash = stdout.trim(); + const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore); + expect(hasExitCode).toBe(0); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T1.2: Valid data with optional properties should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with optional property + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Payload with only required properties + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: "test" })); + + const { exitCode, stdout } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T1.3: Valid data with nested objects should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with nested structure + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + name: { type: "string" }, + address: { + type: "object", + properties: { + street: { type: "string" }, + city: { type: "string" }, + }, + }, + }, + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Payload with nested structure + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync( + payloadFile, + JSON.stringify({ + name: "test", + address: { street: "123 Main", city: "NYC" }, + }), + ); + + const { exitCode, stdout } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify("hello world")); + + const { exitCode, stdout } = await runCli( + ["put", "@string", payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 2: Type Mismatches (New Validation)", () => { + test("T2.1: Wrong property type should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with name as string + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Payload with name as number + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + const { exitCode, stdout, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("Validation failed"); + expect(stderr).toContain(schemaHash.trim()); + expect(stderr).toContain(payloadFile); + + // Verify no node was stored + const { stdout: hasOutput } = await runCli( + ["has", "0000000000000"], + tmpStore, + ); + expect(hasOutput.trim()).toBe("false"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.2: Missing required property should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with required name + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Empty payload + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({})); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.3: Additional properties when disallowed should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with additionalProperties: false + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Payload with extra property + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync( + payloadFile, + JSON.stringify({ name: "test", extra: "not allowed" }), + ); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.4: Wrong root type should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema expecting array + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "array", + items: { type: "string" }, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Payload is an object + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({})); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.5: Nested type mismatch should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with nested object + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + user: { + type: "object", + properties: { + age: { type: "number" }, + }, + }, + }, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Payload with wrong nested type + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync( + payloadFile, + JSON.stringify({ user: { age: "not a number" } }), + ); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 3: Schema Errors (Edge Cases)", () => { + test("T3.1: Non-existent type-hash should fail gracefully", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: "test" })); + + const { exitCode, stderr } = await runCli( + ["put", "ZZZZZZZZZZZZZ", payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Schema not found"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T3.3: Invalid @ alias should fail before validation", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({})); + + const { exitCode, stderr } = await runCli( + ["put", "@nonexistent", payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Schema not found"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 4: Integration with Existing Features", () => { + test("T4.1: Hash command should not validate (dry-run consistency)", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Create schema + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Invalid payload + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + // Hash command should succeed even with invalid data + const { exitCode, stdout } = await runCli( + ["hash", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T4.2: Validation respects cas_ref format in schemas", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with cas_ref format + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + ref: { type: "string", format: "cas_ref" }, + }, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + // Valid cas_ref + const validFile = join(tmpStore, "valid.json"); + writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" })); + + const { exitCode: validExitCode } = await runCli( + ["put", schemaHash.trim(), validFile], + tmpStore, + ); + expect(validExitCode).toBe(0); + + // Invalid cas_ref (wrong length) + const invalidFile = join(tmpStore, "invalid.json"); + writeFileSync(invalidFile, JSON.stringify({ ref: "short" })); + + const { exitCode: invalidExitCode, stderr } = await runCli( + ["put", schemaHash.trim(), invalidFile], + tmpStore, + ); + expect(invalidExitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T4.3: Schema self-validation still works", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Valid schema + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + + const { exitCode } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + expect(exitCode).toBe(0); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 5: Error Message Quality", () => { + test("T5.1: Error message should be helpful", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + const { stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(stderr).toContain("Validation failed"); + expect(stderr).toContain(schemaHash.trim()); + expect(stderr).toContain(payloadFile); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T5.2: Error should go to stderr, not stdout", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + const { stdout: schemaHash } = await runCli( + ["schema", "put", schemaFile], + tmpStore, + ); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + const { stdout, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(stdout).toBe(""); + expect(stderr.length).toBeGreaterThan(0); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); +}); + describe("Suite 6: CLI Integration with Templates", () => { test("6.1 CLI with Template (Default Parameters)", async () => { const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 51bdf4d..1472d06 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -294,6 +294,23 @@ async function cmdPut(args: string[]): Promise { const typeHash = await resolveTypeHash(typeHashOrAlias); const payload = readJsonFile(file); const store = openStore(); + + // Check if schema exists + const schema = getSchema(store, typeHash); + if (schema === null) { + console.error(`Schema not found: ${typeHash}`); + process.exit(1); + } + + // Validate payload against schema before storing + const tempNode = { type: typeHash, payload, timestamp: Date.now() }; + if (!validate(store, tempNode)) { + console.error( + `Validation failed: payload in ${file} does not match schema ${typeHash}`, + ); + process.exit(1); + } + const hash = await store.put(typeHash, payload); console.log(hash); }