|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#13576] `If-Match: ""` — a valid RFC-7232 entity-tag with an EMPTY opaque |
| 5 | + * value — is refused `400 VALIDATION_FAILED` at ingress, instead of being |
| 6 | + * read as "no token supplied" and silently skipping the OCC guard. |
| 7 | + * |
| 8 | + * ## The defect this closes |
| 9 | + * |
| 10 | + * `normaliseVersionToken` strips RFC-7232 quotes off an `If-Match` value and |
| 11 | + * THEN checks emptiness — so `'""'` (non-empty, 2 chars) passes every |
| 12 | + * upstream truthiness check (the REST layer's `expectedVersion ? … : …`) |
| 13 | + * only to normalise to the empty string `''` one layer down, which every |
| 14 | + * caller's OWN falsiness test (`if (!expected) return`) reads as "the client |
| 15 | + * sent no version" — the OPPOSITE of what `If-Match` requests. It is the one |
| 16 | + * token shape that opts OUT of the guard rather than failing it: a garbage |
| 17 | + * token (`v2`) still normalises to a real, comparable token and fails toward |
| 18 | + * `409 CONCURRENT_UPDATE` — the safe direction for a concurrency primitive. |
| 19 | + * |
| 20 | + * ## The ruling (决裁批 #20 ①, maintainer, 2026-08-31) — option 3, verbatim |
| 21 | + * |
| 22 | + * `If-Match: ""` (header) and `expectedVersion: '""'` (body) are judged a |
| 23 | + * MALFORMED concurrency token AT INGRESS and refused (400-family); the |
| 24 | + * message must name the mechanism ("an empty token can never match any |
| 25 | + * stored version — this is a client defect, not a lost race"), because that |
| 26 | + * diagnostic distinction is the entire reason option 3 (refuse the shape) was |
| 27 | + * chosen over option 2 (fail closed to 409, which would have collapsed "you |
| 28 | + * sent something meaningless" into "you lost a race"). `""` is syntactically |
| 29 | + * LEGAL per RFC-7232 §2.3 (`*etagc` — zero or more — permits an empty opaque |
| 30 | + * tag); this refusal is an explicit platform CONTRACT choice ("an empty tag |
| 31 | + * can never match ⇒ it is necessarily a client defect"), not a syntax |
| 32 | + * verdict. Two things stay explicitly UNCHANGED: no `If-Match` at all is |
| 33 | + * still a legal unguarded write, and a garbage-but-nonempty token (`v2`) |
| 34 | + * still fails toward 409. |
| 35 | + * |
| 36 | + * ## The four-way pin set (Zone 3 of the dispatch order) — one describe each |
| 37 | + * |
| 38 | + * Each catches a DIFFERENT way a naive patch could overreach or underreach: |
| 39 | + * 1. `""` → 400. (the fix itself) |
| 40 | + * 2. no `If-Match` at all → unguarded write still succeeds. |
| 41 | + * (catches a change that rejects EVERY falsy token, not just `""`) |
| 42 | + * 3. `v2` (garbage, nonempty) → 409 still. |
| 43 | + * (catches a change that also swallows the legitimate-conflict path) |
| 44 | + * 4. a real, matching token → guarded write still succeeds. |
| 45 | + * (catches a change that rejects even a well-formed token) |
| 46 | + * |
| 47 | + * ## A2.2 — TWO ingress doors, not one (falsified my own assumption) |
| 48 | + * |
| 49 | + * `assertVersionOf` (the PATCH door, called from `updateData` after the |
| 50 | + * existence probe) and `assertVersionMatch` (the DELETE door, called from |
| 51 | + * `deleteData` BEFORE any probe — and which short-circuits BEFORE ever |
| 52 | + * calling `assertVersionOf`) each read `normaliseVersionToken`'s falsy return |
| 53 | + * independently. A fix at one site alone leaves the other exhibiting the |
| 54 | + * exact original defect — the DELETE-door describe block below regresses |
| 55 | + * independently of the PATCH-door one for exactly this reason. |
| 56 | + */ |
| 57 | + |
| 58 | +import { describe, it, expect, vi } from 'vitest'; |
| 59 | +import { |
| 60 | + assertEngineDeleteDispatch, |
| 61 | + assertEngineFindOnePredicate, |
| 62 | + assertEngineUpdateDispatch, |
| 63 | +} from '@objectstack/metadata-core'; |
| 64 | +import { ObjectStackProtocolImplementation, MalformedVersionTokenError } from './protocol.js'; |
| 65 | + |
| 66 | +const SCHEMA = { |
| 67 | + name: 'task', |
| 68 | + fields: { |
| 69 | + title: { name: 'title', type: 'text' }, |
| 70 | + updated_at: { name: 'updated_at', type: 'datetime' }, |
| 71 | + }, |
| 72 | +}; |
| 73 | + |
| 74 | +/** A fake engine holding ONE row — mirrors `protocol.occ-version-token-instant.test.ts`'s fixture. */ |
| 75 | +function makeProtocol(updatedAt: unknown) { |
| 76 | + const row: Record<string, unknown> = { id: 'rec_1', title: 'one', updated_at: updatedAt }; |
| 77 | + const findOne = vi.fn(async (_object: string, opts: any) => { |
| 78 | + assertEngineFindOnePredicate(_object, opts); |
| 79 | + return String(opts?.where?.id) === 'rec_1' ? { ...row } : null; |
| 80 | + }); |
| 81 | + const update = vi.fn(async (_object: string, data: any, opts?: any) => { |
| 82 | + const dispatch = assertEngineUpdateDispatch(data, opts); |
| 83 | + if (dispatch.kind !== 'by-id') { |
| 84 | + throw new Error(`fixture drives by-id updates only, got '${dispatch.kind}'`); |
| 85 | + } |
| 86 | + const fields = { ...(data as Record<string, unknown>) }; |
| 87 | + delete fields.id; |
| 88 | + Object.assign(row, fields); |
| 89 | + return { ...row }; |
| 90 | + }); |
| 91 | + const del = vi.fn(async (_object: string, opts?: any) => { |
| 92 | + assertEngineDeleteDispatch(opts); |
| 93 | + return String(opts?.where?.id) === 'rec_1'; |
| 94 | + }); |
| 95 | + const engine = { |
| 96 | + registry: { getObject: (n: string) => (n === 'task' ? SCHEMA : undefined) }, |
| 97 | + findOne, |
| 98 | + update, |
| 99 | + delete: del, |
| 100 | + }; |
| 101 | + return { p: new ObjectStackProtocolImplementation(engine as any) as any, findOne, update, del }; |
| 102 | +} |
| 103 | + |
| 104 | +const NOW = new Date('2026-08-30T10:19:25.947Z'); |
| 105 | +const NOW_ISO = NOW.toISOString(); |
| 106 | + |
| 107 | +// ───────────────────────────────────────────────────────────────────────────── |
| 108 | +// Pin 1 — `""` ⇒ 400, on BOTH doors, and the exact shipped message |
| 109 | +// ───────────────────────────────────────────────────────────────────────────── |
| 110 | + |
| 111 | +describe('[#13576] pin 1 — the quoted-empty entity-tag is refused 400', () => { |
| 112 | + it('PATCH: `expectedVersion: \'""\'` throws MalformedVersionTokenError (400 VALIDATION_FAILED), and writes nothing', async () => { |
| 113 | + const { p, update } = makeProtocol(NOW); |
| 114 | + await expect( |
| 115 | + p.updateData({ object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: '""' }), |
| 116 | + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400, name: 'MalformedVersionTokenError' }); |
| 117 | + expect(update).not.toHaveBeenCalled(); |
| 118 | + }); |
| 119 | + |
| 120 | + it('DELETE: the same shape throws before the probe, and deletes nothing', async () => { |
| 121 | + const { p, del, findOne } = makeProtocol(NOW); |
| 122 | + await expect( |
| 123 | + p.deleteData({ object: 'task', id: 'rec_1', expectedVersion: '""' }), |
| 124 | + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400 }); |
| 125 | + expect(del).not.toHaveBeenCalled(); |
| 126 | + expect(findOne).not.toHaveBeenCalled(); |
| 127 | + }); |
| 128 | + |
| 129 | + it('the shipped error text names the MECHANISM — "cannot match" / "client defect" — not just a generic validation failure', async () => { |
| 130 | + // Ruling clause ①: 文案不达意即白改 — the diagnostic value (this is a |
| 131 | + // meaningless token, not a lost race) IS the reason option 3 beat |
| 132 | + // option 2, so the message is asserted verbatim-in-substance, not just |
| 133 | + // the code/status envelope. |
| 134 | + const { p } = makeProtocol(NOW); |
| 135 | + try { |
| 136 | + await p.updateData({ object: 'task', id: 'rec_1', data: {}, expectedVersion: '""' }); |
| 137 | + expect.unreachable('expected a MalformedVersionTokenError throw'); |
| 138 | + } catch (e: any) { |
| 139 | + expect(e).toBeInstanceOf(MalformedVersionTokenError); |
| 140 | + expect(e.message).toMatch(/empty/i); |
| 141 | + expect(e.message).toMatch(/never match/i); |
| 142 | + expect(e.message).toMatch(/client defect/i); |
| 143 | + // Tells the caller what to do instead — both remedies the ruling names. |
| 144 | + expect(e.message).toMatch(/updated_at/); |
| 145 | + expect(e.message).toMatch(/If-Match/); |
| 146 | + } |
| 147 | + }); |
| 148 | + |
| 149 | + it('surrounding whitespace around the quoted-empty tag is still caught (REST forwards the trimmed header verbatim)', async () => { |
| 150 | + const { p } = makeProtocol(NOW); |
| 151 | + await expect( |
| 152 | + p.updateData({ object: 'task', id: 'rec_1', data: {}, expectedVersion: ' "" ' }), |
| 153 | + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', status: 400 }); |
| 154 | + }); |
| 155 | +}); |
| 156 | + |
| 157 | +// ───────────────────────────────────────────────────────────────────────────── |
| 158 | +// Pin 2 — no `If-Match` at all ⇒ unguarded write still succeeds (Zone 1.2) |
| 159 | +// ───────────────────────────────────────────────────────────────────────────── |
| 160 | + |
| 161 | +describe('[#13576] pin 2 — the legitimate no-token path is UNCHANGED', () => { |
| 162 | + it('PATCH with no `expectedVersion` field at all still writes unguarded', async () => { |
| 163 | + const { p, update } = makeProtocol(NOW); |
| 164 | + const result = await p.updateData({ object: 'task', id: 'rec_1', data: { title: 'edited' } }); |
| 165 | + expect(result.record.title).toBe('edited'); |
| 166 | + expect(update).toHaveBeenCalledOnce(); |
| 167 | + }); |
| 168 | + |
| 169 | + it('DELETE with no `expectedVersion` field at all still deletes unguarded', async () => { |
| 170 | + const { p, del } = makeProtocol(NOW); |
| 171 | + await p.deleteData({ object: 'task', id: 'rec_1' }); |
| 172 | + expect(del).toHaveBeenCalledOnce(); |
| 173 | + }); |
| 174 | + |
| 175 | + it('an unquoted empty string / whitespace-only token is NOT the malformed shape — still opts out (distinct from `\'""\'`)', async () => { |
| 176 | + // These are what the REST layer's own `expectedVersion ? {...} : {}` |
| 177 | + // truthiness gate already filters before a bare '' ever reaches this |
| 178 | + // layer for the external HTTP doors — pinned here anyway because |
| 179 | + // `updateData`/`deleteData` are also reachable directly (import-runner, |
| 180 | + // action-execution), where no such gate runs. |
| 181 | + const { p: p1, update } = makeProtocol(NOW); |
| 182 | + await p1.updateData({ object: 'task', id: 'rec_1', data: { title: 'a' }, expectedVersion: '' }); |
| 183 | + expect(update).toHaveBeenCalledOnce(); |
| 184 | + const { p: p2, update: update2 } = makeProtocol(NOW); |
| 185 | + await p2.updateData({ object: 'task', id: 'rec_1', data: { title: 'b' }, expectedVersion: ' ' }); |
| 186 | + expect(update2).toHaveBeenCalledOnce(); |
| 187 | + }); |
| 188 | +}); |
| 189 | + |
| 190 | +// ───────────────────────────────────────────────────────────────────────────── |
| 191 | +// Pin 3 — a garbage-but-nonempty token still fails toward 409 (Zone 1.2) |
| 192 | +// ───────────────────────────────────────────────────────────────────────────── |
| 193 | + |
| 194 | +describe('[#13576] pin 3 — an opaque non-matching token is UNCHANGED — still 409, never 400', () => { |
| 195 | + it('PATCH: `expectedVersion: \'v2\'` against a real stored version still throws ConcurrentUpdateError (409)', async () => { |
| 196 | + const { p, update } = makeProtocol(NOW); |
| 197 | + await expect( |
| 198 | + p.updateData({ object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: 'v2' }), |
| 199 | + ).rejects.toMatchObject({ code: 'CONCURRENT_UPDATE', status: 409 }); |
| 200 | + expect(update).not.toHaveBeenCalled(); |
| 201 | + }); |
| 202 | + |
| 203 | + it('DELETE: the same garbage token still throws ConcurrentUpdateError (409), not the new 400', async () => { |
| 204 | + const { p, del } = makeProtocol(NOW); |
| 205 | + await expect( |
| 206 | + p.deleteData({ object: 'task', id: 'rec_1', expectedVersion: 'v2' }), |
| 207 | + ).rejects.toMatchObject({ code: 'CONCURRENT_UPDATE', status: 409 }); |
| 208 | + expect(del).not.toHaveBeenCalled(); |
| 209 | + }); |
| 210 | +}); |
| 211 | + |
| 212 | +// ───────────────────────────────────────────────────────────────────────────── |
| 213 | +// Pin 4 — a real, matching token still guards the write through, successfully |
| 214 | +// ───────────────────────────────────────────────────────────────────────────── |
| 215 | + |
| 216 | +describe('[#13576] pin 4 — a real matching token still performs the guarded write', () => { |
| 217 | + it('PATCH succeeds when `expectedVersion` matches the stored `updated_at`', async () => { |
| 218 | + const { p, update } = makeProtocol(NOW); |
| 219 | + const result = await p.updateData({ |
| 220 | + object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: NOW_ISO, |
| 221 | + }); |
| 222 | + expect(result.record.title).toBe('edited'); |
| 223 | + expect(update).toHaveBeenCalledOnce(); |
| 224 | + }); |
| 225 | + |
| 226 | + it('DELETE succeeds when `expectedVersion` matches the stored `updated_at`', async () => { |
| 227 | + const { p, del } = makeProtocol(NOW); |
| 228 | + await p.deleteData({ object: 'task', id: 'rec_1', expectedVersion: NOW_ISO }); |
| 229 | + expect(del).toHaveBeenCalledOnce(); |
| 230 | + }); |
| 231 | + |
| 232 | + it('the RFC-7232-quoted form of the SAME real token still matches (quotes stripped, not the emptiness path)', async () => { |
| 233 | + const { p, update } = makeProtocol(NOW); |
| 234 | + const result = await p.updateData({ |
| 235 | + object: 'task', id: 'rec_1', data: { title: 'edited' }, expectedVersion: `"${NOW_ISO}"`, |
| 236 | + }); |
| 237 | + expect(result.record.title).toBe('edited'); |
| 238 | + expect(update).toHaveBeenCalledOnce(); |
| 239 | + }); |
| 240 | +}); |
0 commit comments