|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, expect, it } from 'vitest'; |
| 4 | +import { AgentSchema } from '../ai/agent.zod'; |
4 | 5 | import { ProtectionSchema, applyProtection } from './protection.zod'; |
5 | 6 |
|
| 7 | +/** |
| 8 | + * The `unrecognized_keys` issue this parse raised, or `undefined`. |
| 9 | + * |
| 10 | + * Read off the ISSUE rather than the formatted string: the formatter is a |
| 11 | + * second surface with its own tests, and pinning through it would make these |
| 12 | + * assertions fail for a reason that has nothing to do with this block. |
| 13 | + */ |
| 14 | +function unknownKeyIssue(result: { success: boolean; error?: { issues: readonly unknown[] } }) { |
| 15 | + if (result.success) return undefined; |
| 16 | + return (result.error!.issues as { code: string; message: string; keys?: string[] }[]) |
| 17 | + .find((i) => i.code === 'unrecognized_keys'); |
| 18 | +} |
| 19 | + |
6 | 20 | describe('ProtectionSchema', () => { |
7 | 21 | it('accepts the four lock values', () => { |
8 | 22 | for (const lock of ['none', 'no-overlay', 'no-delete', 'full'] as const) { |
@@ -104,3 +118,127 @@ describe('applyProtection', () => { |
104 | 118 | expect(item._lockReason).toBe('new'); |
105 | 119 | }); |
106 | 120 | }); |
| 121 | + |
| 122 | +/** |
| 123 | + * #16845 — the refusal TEXT, pinned at parse level. |
| 124 | + * |
| 125 | + * Before this block adopted `strictObject`, it was a bare `z.object(…).strict()` |
| 126 | + * with no error map, so its rejection was zod's own default — |
| 127 | + * `Unrecognized key: "lockk"` — carrying no surface name, no declared-key list |
| 128 | + * and no rename, on every one of the metadata types that mount it. Its own |
| 129 | + * closure was never the defect; the message was. |
| 130 | + * |
| 131 | + * These pins are written against the message the author actually reads, because |
| 132 | + * reading the source cannot distinguish "has an error map" from "has an error |
| 133 | + * map that says something useful". |
| 134 | + */ |
| 135 | +describe('ProtectionSchema — unknown-key refusal (#16845)', () => { |
| 136 | + it('names the surface, echoes the key and suggests the rename', () => { |
| 137 | + const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', lockk: 'system' } as never)); |
| 138 | + expect(issue, 'a `.strict()` shape must still raise unrecognized_keys').toBeDefined(); |
| 139 | + expect(issue!.keys).toEqual(['lockk']); |
| 140 | + // ① the surface — true at every mount, named without transcribing any. |
| 141 | + expect(issue!.message).toContain('the `protection` block of this metadata item'); |
| 142 | + // ② the offending key, echoed back. |
| 143 | + expect(issue!.message).toContain('`lockk`'); |
| 144 | + // ③ the rename. |
| 145 | + expect(issue!.message).toContain('Did you mean `lockk` → `lock`?'); |
| 146 | + // …and the declared-key list, which the template carries in `history`. |
| 147 | + expect(issue!.message).toContain('The declared keys are `lock`, `reason` and `docsUrl`.'); |
| 148 | + // The pre-fix message, pinned as ABSENT: zod's bare default is the defect. |
| 149 | + expect(issue!.message).not.toBe('Unrecognized key: "lockk"'); |
| 150 | + }); |
| 151 | + |
| 152 | + it('reaches an authorable surface — the card\'s own case, through `AgentSchema`', () => { |
| 153 | + const issue = unknownKeyIssue(AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } } as never)); |
| 154 | + expect(issue, 'the mount must carry the declaring schema\'s error map').toBeDefined(); |
| 155 | + expect((issue as unknown as { path: unknown[] }).path).toEqual(['protection']); |
| 156 | + expect(issue!.message).toContain('the `protection` block of this metadata item'); |
| 157 | + expect(issue!.message).toContain('Did you mean `lockk` → `lock`?'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('corrects the two spellings the distance fallback answered WRONG', () => { |
| 161 | + // Measured on the pre-fix build: `docs` and `link` are each 2 edits from |
| 162 | + // `lock`, inside the length-relative budget, so the fallback pointed an |
| 163 | + // author who meant the documentation URL at the lock policy. |
| 164 | + for (const written of ['docs', 'link'] as const) { |
| 165 | + const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', [written]: 'x' } as never)); |
| 166 | + expect(issue!.message).toContain(`Did you mean \`${written}\` → \`docsUrl\`?`); |
| 167 | + expect(issue!.message).not.toContain(`\`${written}\` → \`lock\``); |
| 168 | + } |
| 169 | + }); |
| 170 | + |
| 171 | + it('answers the prose slot and the `_lock*` envelope family', () => { |
| 172 | + for (const written of ['description', 'message', 'explanation', 'lockReason'] as const) { |
| 173 | + const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', [written]: 'x' } as never)); |
| 174 | + expect(issue!.message).toContain(`Did you mean \`${written}\` → \`reason\`?`); |
| 175 | + } |
| 176 | + // One prescription for the whole family, once per message. |
| 177 | + const env = unknownKeyIssue(ProtectionSchema.safeParse( |
| 178 | + { lock: 'full', reason: 'r', _lock: 'full', _lockReason: 'r', _lockSource: 'package' } as never, |
| 179 | + )); |
| 180 | + expect(env!.message).toContain('the runtime\'s PRIVATE envelope'); |
| 181 | + expect(env!.message.match(/PRIVATE envelope/g)).toHaveLength(1); |
| 182 | + // A wrong-layer boolean gets the VALUE mapping, not a bare rename. |
| 183 | + const ro = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', readOnly: true } as never)); |
| 184 | + expect(ro!.message).toContain("write `lock: 'no-overlay'`"); |
| 185 | + expect(ro!.message).not.toContain('→ `lock`?'); |
| 186 | + }); |
| 187 | + |
| 188 | + it('the `history` key list is the shape\'s key list — the one transcription here, pinned', () => { |
| 189 | + // `history` spells the declared keys out in prose because the template |
| 190 | + // has no declared-key channel of its own. That is a second copy of the |
| 191 | + // shape, so it is held equal to the shape rather than trusted. |
| 192 | + const issue = unknownKeyIssue(ProtectionSchema.safeParse({ zzz: 1 } as never)); |
| 193 | + for (const key of Object.keys(ProtectionSchema.shape)) { |
| 194 | + expect(issue!.message, `\`${key}\` is declared but the history sentence does not name it`) |
| 195 | + .toContain(`\`${key}\``); |
| 196 | + } |
| 197 | + }); |
| 198 | +}); |
| 199 | + |
| 200 | +/** |
| 201 | + * #16845 clause ② — the accept set did NOT move. |
| 202 | + * |
| 203 | + * `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a |
| 204 | + * zod error map is consulted only for an issue already being raised, so it |
| 205 | + * cannot make a rejected value accepted or an accepted value rejected. That is |
| 206 | + * an argument; this is the measurement. Every row below reads identically on the |
| 207 | + * pre-fix build. |
| 208 | + */ |
| 209 | +describe('ProtectionSchema — accept set is unchanged (#16845)', () => { |
| 210 | + it('declares exactly `lock`, `reason`, `docsUrl`', () => { |
| 211 | + expect(Object.keys(ProtectionSchema.shape).sort()).toEqual(['docsUrl', 'lock', 'reason']); |
| 212 | + }); |
| 213 | + |
| 214 | + it('accepts what it accepted, and returns the same data', () => { |
| 215 | + expect(ProtectionSchema.parse({ lock: 'full', reason: 'core', docsUrl: 'https://example.com/d' })) |
| 216 | + .toEqual({ lock: 'full', reason: 'core', docsUrl: 'https://example.com/d' }); |
| 217 | + expect(ProtectionSchema.parse({ lock: 'none', reason: 'r' })).toEqual({ lock: 'none', reason: 'r' }); |
| 218 | + }); |
| 219 | + |
| 220 | + it('rejects what it rejected, with the same issue CODES', () => { |
| 221 | + const rows: [string, unknown, string][] = [ |
| 222 | + ['unknown key', { lock: 'full', reason: 'r', lockk: 'x' }, 'unrecognized_keys'], |
| 223 | + ['missing lock', { reason: 'r' }, 'invalid_value'], |
| 224 | + ['missing reason', { lock: 'full' }, 'invalid_type'], |
| 225 | + ['bad lock value', { lock: 'nope', reason: 'r' }, 'invalid_value'], |
| 226 | + ['bad docsUrl', { lock: 'full', reason: 'r', docsUrl: 'x' }, 'invalid_format'], |
| 227 | + ['reason too long', { lock: 'full', reason: 'x'.repeat(501) }, 'too_big'], |
| 228 | + ['reason empty', { lock: 'full', reason: '' }, 'too_small'], |
| 229 | + ]; |
| 230 | + for (const [label, input, code] of rows) { |
| 231 | + const r = ProtectionSchema.safeParse(input as never); |
| 232 | + expect(r.success, label).toBe(false); |
| 233 | + expect(r.error!.issues.map((i) => i.code), label).toContain(code); |
| 234 | + } |
| 235 | + }); |
| 236 | + |
| 237 | + it('a valid block still parses through a real mount', () => { |
| 238 | + const r = AgentSchema.safeParse({ |
| 239 | + name: 'a', label: 'A', role: 'r', instructions: 'i', |
| 240 | + protection: { lock: 'full', reason: 'core' }, |
| 241 | + } as never); |
| 242 | + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); |
| 243 | + }); |
| 244 | +}); |
0 commit comments