Merge pull request 'fix: validate payload against schema in put command' (#57) from fix/50-schema-validation into main
This commit is contained in:
@@ -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-"));
|
||||
|
||||
@@ -294,6 +294,23 @@ async function cmdPut(args: string[]): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user