Skip to content

Commit 6dd99b8

Browse files
fix(rest): one anonymous-intake rule honours every declared public-form withdrawal (#21566)
Fixes #21475 Clause-②: yes (widening) — five new exports on `@objectstack/metadata-core`'s index (the shared anonymous-intake rule); graded `minor`. ## What A second declared means of withdrawing a public form from anonymous intake is now honoured by every anonymous door; pinned both sides. Which forms a `view` opens to anonymous intake is now **one rule**, defined once in `@objectstack/metadata-core` (`anonymousFormIntakeCandidates`, with `anonymousFormIntakeSlugs` / `anonymousFormIntakeSlug` / `publicFormSlug`), and read by: - both anonymous form doors in `@objectstack/rest` (`registerFormEndpoints` → `findPublicFormView`), and - the organization-scoped `view` write check in `@objectstack/metadata-protocol` (`anonymousFormIntakeOrgScopeRefusal`), whose local projection (`src/anonymous-form-intake.ts`) is deleted in favour of the shared one. So no door reads one declared means and not the other, there is no second per-door check, and the write-time judgement cannot drift from what the doors serve. The rule follows `SharingConfigSchema` as declared, including its defaults, so a raw stored body and its parse get the same answer. ## Pins (both sides) - `packages/metadata-core/src/anonymous-form-intake.test.ts` — the rule itself, every closed shape, raw-vs-parse parity, the three candidate shapes. - `packages/rest/src/public-form-withdrawal.test.ts` — new block: withdrawn by either declared means ⇒ both doors `404 FORM_NOT_FOUND`, `createData` never called; published ⇒ `200` / `201` (control); with tenancy resolving an organization and with tenancy unregistered. Existing fixtures that never declared public sharing per the schema now declare it. - `packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts` — an org-scoped walled write that withdraws through the second means now counts as a change and is refused `403 NOT_OVERRIDABLE`, nothing saved. - Dogfood, single posture (`showcase-public-form-withdrawal.dogfood.test.ts`, real showcase boot): withdrawn env-wide by the second means ⇒ both doors 404 `FORM_NOT_FOUND` and no row lands; republished ⇒ 200/201 and the row lands. The existing `allowAnonymous` pins stay green. - Dogfood, walled posture (`public-form-withdrawal-walled.dogfood.test.ts`): the same withdrawal org-scoped is refused 403; env-wide closes both doors; republish restores. ## Ablation (one-shot, not kept) Deleted the second-means check from the shared rule via `node scripts/ablation-replace.mjs` (anchor 1 → 0, blob `bf5a099a` → `ad331202`), rebuilt `@objectstack/metadata-core`, `ablation-dist-preflight --absent` confirmed the guard gone from all 12 built files. Result: rest 4 failed / 12 passed (exactly the four second-means cases), metadata-protocol 1 failed / 22 passed, dogfood 2 failed / 9 passed, metadata-core 5 failed / 8 passed; every `allowAnonymous` pin and every control stayed green. Restore: blob == HEAD, `git diff HEAD` empty; rebuilt and the preflight found the guard back in dist; `git status --porcelain` empty. ## Tests (at `0164be245`) - `@objectstack/metadata-core` vitest: 17 files / 311 passed. - `@objectstack/rest` vitest: 262 files / 5044 passed, 327 skipped. - `@objectstack/metadata-protocol` vitest: 206 passed, 3 skipped files / 3177 passed, 19 skipped. - `@objectstack/dogfood` (the four public-form files): 4 files / 17 passed. - `typecheck` for metadata-core, metadata-protocol, rest, dogfood: all `Done`. - `node scripts/pm/dispatch-gates.mjs --commands`: 114 derived commands run; 113 exit 0; `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (needs a full workspace build): NOT MEASURED, left to CI. `--ran` reconciliation: 114 of 114 accounted for. - `@objectstack/spec` `check:generated`: all 15 artifacts current after `gen:docs` (the regenerated `content/docs/references/ui/sharing.mdx` carries the corrected module header). - eslint, narrowed to the 12 changed `.ts` files (`--no-inline-config --format json`): 12 files, 0 errors, 0 warnings. Narrowing is sound: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Acceptance notes - `content/docs/ui/forms.mdx` and the `sharing.zod.ts` module header are corrected to state the rule as enforced. - A published skill's description of the public-form opt-in now under-states it. `skills/**` is a Tier H surface, so it is left out of this PR to keep this one ungoverned; it needs its own follow-up (carrier: none yet). - Upgrade note: a form whose sharing never declared public sharing as the schema defines it stops being served anonymously after this lands. The changeset says so in class-level terms and points at the public forms guide. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a387354 commit 6dd99b8

16 files changed

Lines changed: 320 additions & 95 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/rest': patch
5+
---
6+
7+
Public forms: every declared means of withdrawing a form from anonymous intake is now honoured by every anonymous form door. Which forms a `view` opens to anonymous intake is now decided by one rule, `anonymousFormIntakeCandidates` (new in `@objectstack/metadata-core`, alongside `anonymousFormIntakeSlugs`, `anonymousFormIntakeSlug` and `publicFormSlug`), read by both the anonymous form endpoints in `@objectstack/rest` and the organization-scoped `view` write check in `@objectstack/metadata-protocol`, so the two can no longer disagree. A form is served anonymously only when its `sharing` config declares public sharing as `SharingConfigSchema` defines it: `sharing.enabled: true`, `sharing.allowAnonymous: true` and a `sharing.publicLink` slug. `enabled` defaults to `false`, so a form that set only `allowAnonymous` and `publicLink` is no longer served on the anonymous endpoints (`404 FORM_NOT_FOUND`). Migration: add `enabled: true` to the form's `sharing` block (and to any stored overlay of it) to keep it public; see the public forms guide.

‎content/docs/references/ui/sharing.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ asymmetry survives as the reason this file reads the way it does:
1717

1818
- `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing`
1919
carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime
20-
really reads it: `rest-server.ts` mounts the anonymous form endpoints only
21-
when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug
22-
matches. Both example apps author it (`app-showcase` `inquiry.view.ts`,
20+
really reads it: `rest-server.ts` serves the anonymous form endpoints only
21+
when `sharing.enabled === true`, `sharing.allowAnonymous === true` and a
22+
`sharing.publicLink` slug matches (`anonymousFormIntakeCandidates` in
23+
`@objectstack/metadata-core`). Both example apps author it (`app-showcase` `inquiry.view.ts`,
2324
`app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14.
2425
- `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) —
2526
see the block below where it stood.

‎content/docs/ui/forms.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Both modes:
2020
- Honor `?prefill_<field>=<value>` URL params
2121
- Honor `submitBehavior` (thank-you / redirect / continue / next-record) — with **mode-aware defaults** when it is omitted (see [§8](#8-submitbehavior--what-happens-after-submit))
2222

23-
A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically.
23+
A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.enabled: true` and `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically. Clearing either switch withdraws the form from every anonymous endpoint.
2424

2525
## Architecture at a glance
2626

@@ -86,7 +86,7 @@ export default defineView({
8686
},
8787
],
8888
sharing: {
89-
enabled: true,
89+
enabled: true, // ← required (the schema default is false)
9090
allowAnonymous: true, // ← required
9191
publicLink: '/forms/contact-us', // ← the slug ":contact-us" wires this view to the public route
9292
},
@@ -99,7 +99,7 @@ export default defineView({
9999
> - The slug in `publicLink` (`contact-us`) becomes the `:slug` segment in the REST URL.
100100
> - Anything not in the `sections[].fields[]` whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list.
101101
> - A form whose sections declare **no** fields collects nothing, so the submit is **refused** (`400 VALIDATION_ERROR`) rather than accepting whatever the caller sent (#6920). Its `GET /forms/:slug` publishes no schema either (#6601) — declare the fields and both planes come alive together.
102-
> - Multiple form views per object are fine — only the one(s) with `sharing.allowAnonymous === true` are exposed.
102+
> - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed.
103103
104104
## 2. (Optional) Create the `guest_portal` permission set
105105

@@ -230,7 +230,7 @@ Errors:
230230
| `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) |
231231
| `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) |
232232
| `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object |
233-
| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view |
233+
| `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` |
234234
| `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code |
235235

236236
The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` if form resolution itself throws.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SharingConfigSchema } from '@objectstack/spec/ui';
5+
import {
6+
anonymousFormIntakeCandidates,
7+
anonymousFormIntakeSlug,
8+
anonymousFormIntakeSlugs,
9+
publicFormSlug,
10+
} from './anonymous-form-intake.js';
11+
12+
const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' };
13+
14+
describe('anonymousFormIntakeSlug — which sharing opens a form to anonymous intake', () => {
15+
it('both switches on and a publicLink: open, slug normalised', () => {
16+
expect(anonymousFormIntakeSlug(OPEN)).toBe('contact-us');
17+
expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'forms/contact-us' })).toBe('contact-us');
18+
expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'contact-us' })).toBe('contact-us');
19+
});
20+
21+
it.each<[string, Record<string, unknown>]>([
22+
['enabled: false', { ...OPEN, enabled: false }],
23+
['enabled absent', { allowAnonymous: true, publicLink: '/forms/contact-us' }],
24+
['allowAnonymous: false', { ...OPEN, allowAnonymous: false }],
25+
['allowAnonymous absent', { enabled: true, publicLink: '/forms/contact-us' }],
26+
['publicLink absent', { enabled: true, allowAnonymous: true }],
27+
['publicLink empty', { ...OPEN, publicLink: '' }],
28+
['a truthy non-boolean switch', { ...OPEN, enabled: 'true' }],
29+
])('%s: closed', (_label, sharing) => {
30+
expect(anonymousFormIntakeSlug(sharing)).toBeNull();
31+
});
32+
33+
it('a raw body and its parse get the same answer (the schema defaults `enabled` to false)', () => {
34+
for (const raw of [OPEN, { allowAnonymous: true, publicLink: '/forms/contact-us' }, { ...OPEN, enabled: false }]) {
35+
expect(anonymousFormIntakeSlug(SharingConfigSchema.parse(raw))).toBe(anonymousFormIntakeSlug(raw));
36+
}
37+
});
38+
39+
it('not an object: closed', () => {
40+
expect(anonymousFormIntakeSlug(undefined)).toBeNull();
41+
expect(anonymousFormIntakeSlug(null)).toBeNull();
42+
expect(anonymousFormIntakeSlug('x')).toBeNull();
43+
});
44+
});
45+
46+
describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs — the three form shapes of a view', () => {
47+
const view = (sharing: Record<string, unknown>) => ({
48+
name: 'inquiry.contact',
49+
object: 'inquiry',
50+
form: { data: { object: 'inquiry' }, sharing: { ...sharing, publicLink: '/forms/nested' } },
51+
formViews: {
52+
a: { sharing: { ...sharing, publicLink: '/forms/a' } },
53+
b: { sharing: { ...OPEN, enabled: false, publicLink: '/forms/b' } },
54+
},
55+
viewKind: 'form',
56+
config: { sharing: { ...sharing, publicLink: 'forms/flat' } },
57+
});
58+
59+
it('scans the nested form, every formViews entry and the flattened config, open ones only', () => {
60+
const c = anonymousFormIntakeCandidates(view(OPEN));
61+
expect(c.map((x) => [x.key, x.slug])).toEqual([
62+
[undefined, 'nested'],
63+
['a', 'a'],
64+
['inquiry.contact', 'flat'],
65+
]);
66+
expect(anonymousFormIntakeSlugs(view(OPEN))).toEqual(['a', 'flat', 'nested']);
67+
});
68+
69+
it('withdrawn through either switch: no candidate on any shape', () => {
70+
expect(anonymousFormIntakeSlugs(view({ ...OPEN, enabled: false }))).toEqual([]);
71+
expect(anonymousFormIntakeSlugs(view({ ...OPEN, allowAnonymous: false }))).toEqual([]);
72+
});
73+
74+
it('de-duplicates and sorts slugs; tolerates non-object input', () => {
75+
expect(anonymousFormIntakeSlugs({ formViews: { x: { sharing: OPEN }, y: { sharing: { ...OPEN, publicLink: 'contact-us' } } } }))
76+
.toEqual(['contact-us']);
77+
expect(anonymousFormIntakeSlugs(null)).toEqual([]);
78+
expect(anonymousFormIntakeSlugs({ formViews: { x: null } })).toEqual([]);
79+
expect(publicFormSlug('//forms/x')).toBe('x');
80+
});
81+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Which forms a `view` body opens to anonymous intake — the ONE rule.
5+
*
6+
* The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`,
7+
* `registerFormEndpoints` in `@objectstack/rest`) serve exactly the candidates
8+
* this module returns, and `@objectstack/metadata-protocol` judges an
9+
* organization-scoped `view` write by the slug set it projects. Both import it
10+
* from here so the doors and the write-time judgement can never disagree about
11+
* which forms are published.
12+
*
13+
* A form candidate is open to anonymous intake when its `sharing` (the spec's
14+
* `SharingConfigSchema`) declares all three of:
15+
*
16+
* - `enabled === true` — "Enable public sharing". The schema defaults it to
17+
* `false`, and a parsed body carries that default, so an absent `enabled`
18+
* reads as not shared here too: a raw body and its parsed form get the same
19+
* answer.
20+
* - `allowAnonymous === true` — "Allow access without authentication".
21+
* - a non-empty `publicLink` naming the slug.
22+
*
23+
* Clearing either switch withdraws the form from every anonymous door.
24+
*
25+
* The candidates are the three shapes a view carries a form in: the nested
26+
* `form`, every `formViews` entry, and the flattened `config` of a
27+
* `viewKind: 'form'` item.
28+
*/
29+
30+
/** A form candidate of a view that is open to anonymous intake. */
31+
export interface AnonymousFormIntakeCandidate {
32+
/** The form view object (the nested `form`, a `formViews` entry, or the flattened `config`). */
33+
form: Record<string, any>;
34+
/** The `formViews` key, or the view name for a flattened `viewKind: 'form'` item. */
35+
key?: string;
36+
/** The slug its `publicLink` names, normalised (`/forms/x`, `forms/x` and `x` are one slug). */
37+
slug: string;
38+
}
39+
40+
/** Normalise a `publicLink` to the slug the doors compare: `/forms/x`, `forms/x` and `x` are one slug. */
41+
export function publicFormSlug(publicLink: string): string {
42+
return publicLink.replace(/^\/+/, '').replace(/^forms\//, '');
43+
}
44+
45+
/** The slug a form's `sharing` opens to anonymous intake, or `null` when it opens none. */
46+
export function anonymousFormIntakeSlug(sharing: unknown): string | null {
47+
if (!sharing || typeof sharing !== 'object') return null;
48+
const s = sharing as Record<string, unknown>;
49+
if (s.enabled !== true) return null;
50+
if (s.allowAnonymous !== true) return null;
51+
if (typeof s.publicLink !== 'string' || !s.publicLink) return null;
52+
return publicFormSlug(s.publicLink);
53+
}
54+
55+
/** Every form candidate of a `view` body that is open to anonymous intake, in scan order. */
56+
export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntakeCandidate[] {
57+
if (!view || typeof view !== 'object') return [];
58+
const v = view as Record<string, any>;
59+
const forms: Array<{ form: unknown; key?: string }> = [];
60+
if (v.form && typeof v.form === 'object') forms.push({ form: v.form });
61+
if (v.formViews && typeof v.formViews === 'object') {
62+
for (const [key, fv] of Object.entries(v.formViews)) forms.push({ form: fv, key });
63+
}
64+
if (v.viewKind === 'form' && v.config && typeof v.config === 'object') {
65+
forms.push({ form: v.config, key: v.name });
66+
}
67+
const open: AnonymousFormIntakeCandidate[] = [];
68+
for (const { form, key } of forms) {
69+
if (!form || typeof form !== 'object') continue;
70+
const slug = anonymousFormIntakeSlug((form as Record<string, unknown>).sharing);
71+
if (slug === null) continue;
72+
open.push({ form: form as Record<string, any>, ...(key !== undefined ? { key } : {}), slug });
73+
}
74+
return open;
75+
}
76+
77+
/**
78+
* The sorted, de-duplicated slug set a `view` body opens to anonymous intake.
79+
* Two bodies with the same set open exactly the same anonymous doors.
80+
*/
81+
export function anonymousFormIntakeSlugs(view: unknown): string[] {
82+
return [...new Set(anonymousFormIntakeCandidates(view).map((c) => c.slug))].sort();
83+
}

‎packages/metadata-core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,9 @@ export * from './record-organization.js';
137137
// metadata-protocol, and a boot log with its own opinion about which
138138
// declarations the registry will take is the very defect this card closes.
139139
export * from './object-field-type.js';
140+
141+
// Which forms a `view` body opens to anonymous intake. The enforcing doors live
142+
// in `@objectstack/rest` and the write-time judgement of an organization-scoped
143+
// `view` write in `@objectstack/metadata-protocol`; both read this one rule, so
144+
// a form withdrawn by either declared switch is withdrawn everywhere.
145+
export * from './anonymous-form-intake.js';

‎packages/metadata-protocol/src/anonymous-form-intake.ts‎

Lines changed: 0 additions & 37 deletions
This file was deleted.

‎packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,18 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se
604604
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
605605
});
606606

607+
it('walled: an org-scoped withdrawal through `sharing.enabled` alone is refused the same way', async () => {
608+
const { protocol, rows } = makeTenancyProtocol(null);
609+
await publishEnvWide(protocol);
610+
const body = FORM_VIEW(true);
611+
body.config.sharing.enabled = false;
612+
613+
await expect(protocol.saveMetaItem({
614+
type: 'view', name: 'task.intake_form', item: body, organizationId: 'org_a',
615+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' });
616+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
617+
});
618+
607619
it('walled: an org-scoped draft of the withdrawal is refused too', async () => {
608620
const { protocol, rows } = makeTenancyProtocol(null);
609621
await publishEnvWide(protocol);

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import {
3838
// [#7560] ADR-0070's read-only-package rule, shared with the `/packages`
3939
// lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`.
4040
import { isWritablePackage as isWritablePackageShared } from './package-writability.js';
41-
import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js';
4241
import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
4342
// [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the
4443
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
@@ -90,6 +89,9 @@ import {
9089
// {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer
9190
// fallback so a hydrated row is never answered as the code layer.
9291
isTenantAuthored,
92+
// The one rule for which forms a `view` body opens to anonymous intake —
93+
// the same rule the anonymous form doors in `@objectstack/rest` serve by.
94+
anonymousFormIntakeSlugs,
9395
} from '@objectstack/metadata-core';
9496
// [#5532] One vocabulary of "which driver read errors are benign", shared with
9597
// `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in

‎packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,30 @@ describe('walled posture: withdrawing a public form from anonymous intake', () =
203203
expect([open.get, open.submit]).toEqual([200, 201]);
204204
expect(open.landed).toHaveLength(1);
205205
});
206+
207+
it('withdrawn through `sharing.enabled: false` alone: refused org-scoped; env-wide both doors 404 and nothing lands', async () => {
208+
const withEnabled = (enabled: boolean): Record<string, any> => {
209+
const body = withAnonymous(true);
210+
body.config.sharing.enabled = enabled;
211+
return body;
212+
};
213+
await setActive(orgId);
214+
const refused = await put(withEnabled(false));
215+
expect(refused.status, JSON.stringify(refused.json)).toBe(403);
216+
expect(JSON.stringify(refused.json)).toMatch(/NOT_OVERRIDABLE/);
217+
218+
await setActive(null);
219+
const off = await put(withEnabled(false));
220+
expect(off.status, JSON.stringify(off.json)).toBe(200);
221+
const closed = await probe();
222+
expect([closed.get, closed.getCode, closed.submit, closed.submitCode])
223+
.toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']);
224+
expect(closed.landed).toHaveLength(0);
225+
226+
const on = await put(withEnabled(true));
227+
expect(on.status, JSON.stringify(on.json)).toBe(200);
228+
const open = await probe();
229+
expect([open.get, open.submit]).toEqual([200, 201]);
230+
expect(open.landed).toHaveLength(1);
231+
});
206232
});

0 commit comments

Comments
 (0)