@crombolo/remote-conformance 0.0.0-stage → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/bin/crombolo-remote-conformance.ts +37 -0
- package/package.json +32 -4
- package/src/index.ts +265 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `crombolo-remote-conformance <url> --token <token> [--environment <id>] [--push]`
|
|
4
|
+
*
|
|
5
|
+
* Runs the remote protocol's conformance suite against a server and prints each
|
|
6
|
+
* check. Exits non-zero when any fails. `--push` replaces the environment with a
|
|
7
|
+
* copy of itself, so point it at one that may be replaced.
|
|
8
|
+
*/
|
|
9
|
+
import { runConformance } from '../src/index.ts'
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2)
|
|
12
|
+
const value = (flag: string) => {
|
|
13
|
+
const at = args.indexOf(flag)
|
|
14
|
+
return at >= 0 ? args[at + 1] : undefined
|
|
15
|
+
}
|
|
16
|
+
const url = args.find((arg, i) => !arg.startsWith('--') && !args[i - 1]?.startsWith('--'))
|
|
17
|
+
const token = value('--token') ?? process.env.CROMBOLO_REMOTE_TOKEN
|
|
18
|
+
if (!url || !token) {
|
|
19
|
+
console.error(
|
|
20
|
+
'usage: crombolo-remote-conformance <url> --token <token> [--environment <id>] [--push]\n' +
|
|
21
|
+
' (or CROMBOLO_REMOTE_TOKEN for the token)',
|
|
22
|
+
)
|
|
23
|
+
process.exit(2)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const report = await runConformance({
|
|
27
|
+
url,
|
|
28
|
+
token,
|
|
29
|
+
environment: value('--environment'),
|
|
30
|
+
push: args.includes('--push'),
|
|
31
|
+
})
|
|
32
|
+
for (const check of report.checks) {
|
|
33
|
+
const mark = check.skipped ? 'skip' : check.ok ? 'pass' : 'FAIL'
|
|
34
|
+
console.log(` ${mark} ${check.name}${check.detail ? `\n ${check.detail}` : ''}`)
|
|
35
|
+
}
|
|
36
|
+
console.log(report.passed ? '\nConforms.' : '\nDoes not conform.')
|
|
37
|
+
process.exit(report.passed ? 0 : 1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/remote-conformance",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"description": "Checks a server against the Crombolo remote protocol: discovery, sign-in, environments, pulls, pushes and media by reference.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/remote-conformance"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"crombolo-remote-conformance": "./bin/crombolo-remote-conformance.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"bin",
|
|
29
|
+
"NOTICE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conformance suite for the Crombolo remote protocol, version 1 (story
|
|
3
|
+
* 107): what any implementation is held to, run against its own endpoint. It
|
|
4
|
+
* needs nothing but `fetch`, so a host that is not this CMS runs it unchanged.
|
|
5
|
+
* The specification is `docs/remote-protocol.md`.
|
|
6
|
+
*
|
|
7
|
+
* Read-only unless `push` is given: a push replaces the environment it goes to.
|
|
8
|
+
* The checks that a push is refused for a wrong hash are always run, since a
|
|
9
|
+
* conforming remote changes nothing for them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ConformanceOptions {
|
|
13
|
+
/** The site's origin, e.g. `https://example.com`. */
|
|
14
|
+
url: string
|
|
15
|
+
/** A bearer token from the device grant, for someone allowed to pull. */
|
|
16
|
+
token: string
|
|
17
|
+
/** The environment to test; the first one discovery names, when not given. */
|
|
18
|
+
environment?: string
|
|
19
|
+
/** At least 12 characters; the pulled file is sealed with it. */
|
|
20
|
+
passphrase?: string
|
|
21
|
+
/** Push the pulled file back, replacing the environment with itself. */
|
|
22
|
+
push?: boolean
|
|
23
|
+
/** How long to wait for a job, in milliseconds. */
|
|
24
|
+
timeoutMs?: number
|
|
25
|
+
fetch?: typeof fetch
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ConformanceCheck {
|
|
29
|
+
name: string
|
|
30
|
+
ok: boolean
|
|
31
|
+
skipped?: boolean
|
|
32
|
+
detail?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConformanceReport {
|
|
36
|
+
passed: boolean
|
|
37
|
+
checks: ConformanceCheck[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Failed extends Error {}
|
|
41
|
+
|
|
42
|
+
const assert = (condition: unknown, message: string): void => {
|
|
43
|
+
if (!condition) throw new Failed(message)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Json = Record<string, unknown>
|
|
47
|
+
|
|
48
|
+
export async function runConformance(options: ConformanceOptions): Promise<ConformanceReport> {
|
|
49
|
+
const http = options.fetch ?? fetch
|
|
50
|
+
const origin = options.url.replace(/\/+$/, '')
|
|
51
|
+
const passphrase = options.passphrase ?? 'conformance suite passphrase'
|
|
52
|
+
const timeout = options.timeoutMs ?? 5 * 60 * 1000
|
|
53
|
+
const checks: ConformanceCheck[] = []
|
|
54
|
+
const state: {
|
|
55
|
+
discovery?: Json
|
|
56
|
+
api?: string
|
|
57
|
+
environment?: string
|
|
58
|
+
may?: Json
|
|
59
|
+
pulled?: { bytes: Uint8Array; sha256: string }
|
|
60
|
+
} = {}
|
|
61
|
+
|
|
62
|
+
const check = async (name: string, run: () => Promise<undefined | 'skip'>) => {
|
|
63
|
+
try {
|
|
64
|
+
const outcome = await run()
|
|
65
|
+
checks.push(outcome === 'skip' ? { name, ok: true, skipped: true } : { name, ok: true })
|
|
66
|
+
} catch (error) {
|
|
67
|
+
checks.push({
|
|
68
|
+
name,
|
|
69
|
+
ok: false,
|
|
70
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const authorised = (init: RequestInit = {}): RequestInit => {
|
|
76
|
+
const headers = new Headers(init.headers)
|
|
77
|
+
headers.set('authorization', `Bearer ${options.token}`)
|
|
78
|
+
return { ...init, headers }
|
|
79
|
+
}
|
|
80
|
+
const api = (path: string, init?: RequestInit) => http(`${state.api}/${path}`, authorised(init))
|
|
81
|
+
const isProblem = (response: Response) =>
|
|
82
|
+
(response.headers.get('content-type') ?? '').includes('application/problem+json')
|
|
83
|
+
const sha256 = (bytes: Uint8Array) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex')
|
|
84
|
+
|
|
85
|
+
const settle = async (id: string): Promise<Json> => {
|
|
86
|
+
const deadline = Date.now() + timeout
|
|
87
|
+
for (;;) {
|
|
88
|
+
const response = await api(`jobs/${id}`)
|
|
89
|
+
assert(response.status === 200, `polling a job answered ${response.status}`)
|
|
90
|
+
const job = (await response.json()) as Json
|
|
91
|
+
if (job.state !== 'queued' && job.state !== 'running') return job
|
|
92
|
+
assert(Date.now() < deadline, `the job was still ${job.state} after ${timeout} ms`)
|
|
93
|
+
await Bun.sleep(Math.max(250, Number(job.pollAfter ?? 1) * 1000))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await check('discovery names the protocol, versions, sign-in and environments', async () => {
|
|
98
|
+
const response = await http(`${origin}/.well-known/crombolo-remote`)
|
|
99
|
+
assert(response.status === 200, `answered ${response.status}`)
|
|
100
|
+
const body = (await response.json()) as Json
|
|
101
|
+
assert(body.protocol === 'crombolo-remote', 'protocol is not "crombolo-remote"')
|
|
102
|
+
assert(
|
|
103
|
+
Array.isArray(body.versions) && body.versions.includes('1'),
|
|
104
|
+
'versions does not include "1"',
|
|
105
|
+
)
|
|
106
|
+
assert(typeof body.api === 'string', 'api is missing')
|
|
107
|
+
const auth = body.auth as Json | undefined
|
|
108
|
+
for (const field of ['deviceAuthorization', 'token', 'clientId'])
|
|
109
|
+
assert(typeof auth?.[field] === 'string', `auth.${field} is missing`)
|
|
110
|
+
const environments = body.environments as Json[] | undefined
|
|
111
|
+
assert(Array.isArray(environments) && environments.length > 0, 'no environments')
|
|
112
|
+
for (const env of environments ?? [])
|
|
113
|
+
assert(
|
|
114
|
+
typeof env.id === 'string' &&
|
|
115
|
+
typeof env.name === 'string' &&
|
|
116
|
+
typeof env.protected === 'boolean',
|
|
117
|
+
'an environment lacks id, name or protected',
|
|
118
|
+
)
|
|
119
|
+
state.discovery = body
|
|
120
|
+
state.api = String(body.api).replace(/\/+$/, '')
|
|
121
|
+
state.environment = options.environment ?? String((environments?.[0] as Json | undefined)?.id)
|
|
122
|
+
})
|
|
123
|
+
if (!state.api) return { passed: false, checks }
|
|
124
|
+
|
|
125
|
+
await check('the device authorization endpoint hands out codes', async () => {
|
|
126
|
+
const auth = state.discovery?.auth as Json
|
|
127
|
+
const response = await http(String(auth.deviceAuthorization), {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
130
|
+
body: new URLSearchParams({ client_id: String(auth.clientId) }).toString(),
|
|
131
|
+
})
|
|
132
|
+
assert(response.status === 200, `answered ${response.status}`)
|
|
133
|
+
const body = (await response.json()) as Json
|
|
134
|
+
for (const field of ['device_code', 'user_code', 'verification_uri'])
|
|
135
|
+
assert(typeof body[field] === 'string', `${field} is missing`)
|
|
136
|
+
for (const field of ['expires_in', 'interval'])
|
|
137
|
+
assert(typeof body[field] === 'number', `${field} is missing`)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await check('the API refuses a request without a token, as a problem', async () => {
|
|
141
|
+
const response = await http(`${state.api}/environments`)
|
|
142
|
+
assert(response.status === 401, `answered ${response.status}`)
|
|
143
|
+
assert(isProblem(response), 'not application/problem+json')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await check('environments lists what the token may do', async () => {
|
|
147
|
+
const response = await api('environments')
|
|
148
|
+
assert(response.status === 200, `answered ${response.status}`)
|
|
149
|
+
const list = (await response.json()) as Json[]
|
|
150
|
+
const env = list.find((e) => e.id === state.environment)
|
|
151
|
+
assert(env, `"${state.environment}" is not listed`)
|
|
152
|
+
const may = env?.may as Json | undefined
|
|
153
|
+
for (const field of ['pull', 'pullFull', 'push'])
|
|
154
|
+
assert(typeof may?.[field] === 'boolean', `may.${field} is missing`)
|
|
155
|
+
state.may = may
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
await check('an unknown job is not found', async () => {
|
|
159
|
+
const response = await api(`jobs/${crypto.randomUUID()}`)
|
|
160
|
+
assert(response.status === 404, `answered ${response.status}`)
|
|
161
|
+
assert(isProblem(response), 'not application/problem+json')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await check('a pull without a passphrase is refused', async () => {
|
|
165
|
+
if (!state.may?.pull) return 'skip'
|
|
166
|
+
const response = await api(`environments/${state.environment}/pulls`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'content-type': 'application/json' },
|
|
169
|
+
body: '{}',
|
|
170
|
+
})
|
|
171
|
+
assert(response.status === 400, `answered ${response.status}`)
|
|
172
|
+
assert(isProblem(response), 'not application/problem+json')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
await check('a pull runs as a job and ends in a download whose hash it states', async () => {
|
|
176
|
+
if (!state.may?.pull) return 'skip'
|
|
177
|
+
const started = await api(`environments/${state.environment}/pulls`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ passphrase }),
|
|
181
|
+
})
|
|
182
|
+
assert(started.status === 202, `starting answered ${started.status}`)
|
|
183
|
+
const job = await settle(String(((await started.json()) as Json).id))
|
|
184
|
+
assert(job.state === 'done', `the job ended ${job.state}: ${JSON.stringify(job.problem ?? {})}`)
|
|
185
|
+
const download = job.download as Json | undefined
|
|
186
|
+
assert(typeof download?.url === 'string', 'download.url is missing')
|
|
187
|
+
const file = await http(String(download?.url), authorised())
|
|
188
|
+
assert(file.status === 200, `the download answered ${file.status}`)
|
|
189
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
190
|
+
assert(bytes[0] === 0x1f && bytes[1] === 0x8b, 'the download is not gzip')
|
|
191
|
+
assert(sha256(bytes) === download?.sha256, 'the download does not match download.sha256')
|
|
192
|
+
state.pulled = { bytes, sha256: sha256(bytes) }
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
await check('media links answer null for a file the remote does not have', async () => {
|
|
196
|
+
const capabilities = (state.discovery?.capabilities as string[] | undefined) ?? []
|
|
197
|
+
if (!capabilities.includes('media-fetch') || !state.may?.pull) return 'skip'
|
|
198
|
+
const response = await api(`environments/${state.environment}/media/links`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'content-type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ keys: ['conformance/not-there.png'] }),
|
|
202
|
+
})
|
|
203
|
+
assert(response.status === 200, `answered ${response.status}`)
|
|
204
|
+
const body = (await response.json()) as { links?: Record<string, unknown> }
|
|
205
|
+
assert(body.links?.['conformance/not-there.png'] === null, 'the missing file was not null')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
await check('a push check answers what would be recycled and removed', async () => {
|
|
209
|
+
const capabilities = (state.discovery?.capabilities as string[] | undefined) ?? []
|
|
210
|
+
if (!capabilities.includes('push-check') || !state.may?.push) return 'skip'
|
|
211
|
+
const response = await api(`environments/${state.environment}/pushes/check`, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'content-type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({ nodes: [], files: [] }),
|
|
215
|
+
})
|
|
216
|
+
assert(response.status === 200, `answered ${response.status}`)
|
|
217
|
+
const body = (await response.json()) as Json
|
|
218
|
+
assert(typeof body.recycles === 'object' && body.recycles !== null, 'recycles is missing')
|
|
219
|
+
assert(Array.isArray(body.removesFiles), 'removesFiles is missing')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await check('a push whose file does not match its hash is refused', async () => {
|
|
223
|
+
if (!state.may?.push || !state.pulled) return 'skip'
|
|
224
|
+
const response = await api(`environments/${state.environment}/pushes`, {
|
|
225
|
+
method: 'PUT',
|
|
226
|
+
headers: {
|
|
227
|
+
'content-type': 'application/gzip',
|
|
228
|
+
'crombolo-sha256': '0'.repeat(64),
|
|
229
|
+
'crombolo-passphrase': passphrase,
|
|
230
|
+
},
|
|
231
|
+
body: state.pulled.bytes as BodyInit,
|
|
232
|
+
})
|
|
233
|
+
assert(response.status === 400, `answered ${response.status}`)
|
|
234
|
+
assert(isProblem(response), 'not application/problem+json')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
await check(
|
|
238
|
+
'a push of the pulled file replaces the environment, or waits for approval',
|
|
239
|
+
async () => {
|
|
240
|
+
if (!options.push || !state.may?.push || !state.pulled) return 'skip'
|
|
241
|
+
const response = await api(`environments/${state.environment}/pushes`, {
|
|
242
|
+
method: 'PUT',
|
|
243
|
+
headers: {
|
|
244
|
+
'content-type': 'application/gzip',
|
|
245
|
+
'crombolo-sha256': state.pulled.sha256,
|
|
246
|
+
'crombolo-passphrase': passphrase,
|
|
247
|
+
},
|
|
248
|
+
body: state.pulled.bytes as BodyInit,
|
|
249
|
+
})
|
|
250
|
+
assert(response.status === 202, `answered ${response.status}`)
|
|
251
|
+
const started = (await response.json()) as Json
|
|
252
|
+
if (started.state === 'approval-required') {
|
|
253
|
+
assert(typeof started.approvalUrl === 'string', 'approvalUrl is missing')
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const job = await settle(String(started.id))
|
|
257
|
+
assert(
|
|
258
|
+
job.state === 'done',
|
|
259
|
+
`the job ended ${job.state}: ${JSON.stringify(job.problem ?? {})}`,
|
|
260
|
+
)
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return { passed: checks.every((c) => c.ok), checks }
|
|
265
|
+
}
|
package/README.md
DELETED