paperlint 6.2.3 → 6.3.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/README.md +8 -1
- package/dist/adapters/hotcrp/index.d.ts +7 -0
- package/dist/adapters/hotcrp/index.d.ts.map +1 -0
- package/dist/adapters/hotcrp/index.js +7 -0
- package/dist/adapters/hotcrp/index.js.map +1 -0
- package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
- package/dist/adapters/hotcrp/portal.io.js +90 -0
- package/dist/adapters/hotcrp/portal.io.js.map +1 -0
- package/dist/adapters/hotcrp/response.d.ts +13 -0
- package/dist/adapters/hotcrp/response.d.ts.map +1 -0
- package/dist/adapters/hotcrp/response.js +138 -0
- package/dist/adapters/hotcrp/response.js.map +1 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +109 -34
- package/dist/cli.js.map +1 -1
- package/dist/domain/result.d.ts +4 -0
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +2 -0
- package/dist/domain/result.js.map +1 -1
- package/dist/domain/submission.d.ts +106 -0
- package/dist/domain/submission.d.ts.map +1 -0
- package/dist/domain/submission.js +39 -0
- package/dist/domain/submission.js.map +1 -0
- package/dist/domain/venue-name.d.ts +38 -0
- package/dist/domain/venue-name.d.ts.map +1 -0
- package/dist/domain/venue-name.js +71 -0
- package/dist/domain/venue-name.js.map +1 -0
- package/dist/folder-venue-rule.d.ts +51 -0
- package/dist/folder-venue-rule.d.ts.map +1 -0
- package/dist/folder-venue-rule.js +96 -0
- package/dist/folder-venue-rule.js.map +1 -0
- package/dist/init.d.ts +6 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +5 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/new-paper.d.ts +19 -5
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +23 -4
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +20 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/submission-portal.d.ts +19 -0
- package/dist/ports/submission-portal.d.ts.map +1 -0
- package/dist/ports/submission-portal.js +2 -0
- package/dist/ports/submission-portal.js.map +1 -0
- package/dist/presets.d.ts +24 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +35 -3
- package/dist/presets.js.map +1 -1
- package/dist/rules-config.js +1 -1
- package/dist/submission.d.ts +61 -0
- package/dist/submission.d.ts.map +1 -0
- package/dist/submission.js +211 -0
- package/dist/submission.js.map +1 -0
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +3 -0
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +6 -7
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +10 -14
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/configuration.md +17 -10
- package/docs/rules/paper/folder-venue-leftover.md +104 -0
- package/docs/rules/talk/captions-cover.md +1 -1
- package/docs/rules/talk/duration-floor.md +1 -1
- package/docs/rules.md +44 -42
- package/docs/skills.md +5 -4
- package/docs/submission.md +64 -0
- package/docs/talk.md +1 -1
- package/lib/paper-config.ts +6 -1
- package/lib/skill-checks.d.mts +7 -0
- package/lib/skill-trigger-cases.mjs +11 -0
- package/package.json +2 -2
- package/presets/aidc.jsonc +6 -0
- package/presets/venue-profile.schema.json +17 -1
- package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
- package/skills/submission-portal/SKILL.md +69 -0
- package/skills/submission-portal/SKILL.md.spec.ts +71 -0
- package/skills/submission-portal/submission-portal.eval.mjs +15 -0
- package/skills/submission-portal/submission-portal.test.ts +81 -0
- package/skills/submit-paper/SKILL.md +2 -1
- package/skills/submit-paper/SKILL.md.spec.ts +1 -0
- package/src/adapters/hotcrp/cassette/README.md +24 -0
- package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
- package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
- package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
- package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
- package/src/adapters/hotcrp/index.ts +11 -0
- package/src/adapters/hotcrp/portal.io.ts +130 -0
- package/src/adapters/hotcrp/portal.test.ts +503 -0
- package/src/adapters/hotcrp/response.ts +181 -0
- package/src/cli.test.ts +109 -0
- package/src/cli.ts +140 -21
- package/src/domain/result.test.ts +9 -1
- package/src/domain/result.ts +5 -0
- package/src/domain/submission.test.ts +45 -0
- package/src/domain/submission.ts +141 -0
- package/src/domain/venue-name.test.ts +115 -0
- package/src/domain/venue-name.ts +129 -0
- package/src/folder-venue-rule.test.ts +182 -0
- package/src/folder-venue-rule.ts +141 -0
- package/src/init.test.ts +22 -0
- package/src/init.ts +9 -2
- package/src/new-paper.harness.mjs +11 -9
- package/src/new-paper.test.ts +129 -8
- package/src/new-paper.ts +38 -7
- package/src/paper-settings.test.ts +12 -1
- package/src/paper-settings.ts +25 -5
- package/src/parse-args.test.ts +19 -0
- package/src/ports/submission-portal.ts +26 -0
- package/src/presets.test.ts +22 -0
- package/src/presets.ts +46 -3
- package/src/rule-cases.test.ts +19 -0
- package/src/rules-config.ts +1 -1
- package/src/submission.test.ts +496 -0
- package/src/submission.ts +311 -0
- package/src/tex-requirements.ts +8 -0
- package/src/tex-venue-rules.test.ts +1 -1
- package/src/tex-venue-rules.ts +12 -25
- package/src/types.ts +13 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HotCRP adapter against a local HTTP server on 127.0.0.1 — no real portal, no network. The
|
|
3
|
+
* server records each request whole (method, path, query, headers, body), so the tests assert what
|
|
4
|
+
* HotCRP would receive, and answers with what HotCRP documents.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
createServer,
|
|
10
|
+
type IncomingMessage,
|
|
11
|
+
type Server,
|
|
12
|
+
type ServerResponse,
|
|
13
|
+
} from "node:http";
|
|
14
|
+
import { afterAll, beforeAll, test } from "vitest";
|
|
15
|
+
import { fieldOf } from "../../domain/record.ts";
|
|
16
|
+
import { sha256Hex } from "../../domain/sha256.ts";
|
|
17
|
+
import type { SubmissionChange } from "../../domain/submission.ts";
|
|
18
|
+
import {
|
|
19
|
+
CONTENT_FIELD,
|
|
20
|
+
changeObject,
|
|
21
|
+
hotcrpPortal,
|
|
22
|
+
parseShow,
|
|
23
|
+
parseUpdate,
|
|
24
|
+
sha256OfHash,
|
|
25
|
+
} from "./index.ts";
|
|
26
|
+
|
|
27
|
+
interface Seen {
|
|
28
|
+
readonly method: string;
|
|
29
|
+
readonly url: URL;
|
|
30
|
+
readonly headers: IncomingMessage["headers"];
|
|
31
|
+
readonly body: Buffer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Reply {
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
readonly type?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const seen: Seen[] = [];
|
|
41
|
+
let reply: Reply = { status: 200, body: "{}" };
|
|
42
|
+
let base = "";
|
|
43
|
+
|
|
44
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
47
|
+
req.on("end", () => {
|
|
48
|
+
const s: Seen = {
|
|
49
|
+
method: req.method ?? "",
|
|
50
|
+
url: new URL(req.url ?? "/", "http://127.0.0.1"),
|
|
51
|
+
headers: req.headers,
|
|
52
|
+
body: Buffer.concat(chunks),
|
|
53
|
+
};
|
|
54
|
+
seen.push(s);
|
|
55
|
+
const answer = ignoredDocument(s) ?? reply;
|
|
56
|
+
res.writeHead(
|
|
57
|
+
answer.status,
|
|
58
|
+
answer.type === ""
|
|
59
|
+
? {}
|
|
60
|
+
: { "content-type": answer.type ?? "application/json" },
|
|
61
|
+
);
|
|
62
|
+
res.end(answer.body);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** The port a listening server was given. */
|
|
67
|
+
/**
|
|
68
|
+
* What HotCRP answers when `submission.content_file` names no uploaded file — as its source does
|
|
69
|
+
* (kohler/hotcrp, read 2026-10-03): `DocumentLocator::on_document_import` looks the name up with
|
|
70
|
+
* `Qrequest::file()`, which holds `$_FILES` as PHP built it, and PHP renames a dot or a space in a
|
|
71
|
+
* form field's name to `_` there. A part without a filename is a plain field, not a file. When the
|
|
72
|
+
* lookup misses, `DocumentImporter` reports "Ignored attempt to upload document without any
|
|
73
|
+
* content" and the change is not valid — measured on a real HotCRP with the field `paper.pdf`.
|
|
74
|
+
*/
|
|
75
|
+
/** A recorded HotCRP answer (`cassette/README.md`), as the text it was sent as. */
|
|
76
|
+
function cassette(
|
|
77
|
+
name: "get-paper" | "get-noauth" | "post-dot" | "post-ok",
|
|
78
|
+
): string {
|
|
79
|
+
return readFileSync(
|
|
80
|
+
new URL(`./cassette/${name}.json`, import.meta.url),
|
|
81
|
+
"utf8",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const parsed = (name: Parameters<typeof cassette>[0]): unknown =>
|
|
85
|
+
JSON.parse(cassette(name));
|
|
86
|
+
|
|
87
|
+
function ignoredDocument(s: Seen): Reply | null {
|
|
88
|
+
if (!String(s.headers["content-type"]).startsWith("multipart/form-data"))
|
|
89
|
+
return null;
|
|
90
|
+
const parts = partsOf(s);
|
|
91
|
+
const json: unknown = JSON.parse(
|
|
92
|
+
parts.get("json")?.body.toString("utf8") ?? "{}",
|
|
93
|
+
);
|
|
94
|
+
const named = fieldOf(fieldOf(json, "submission"), "content_file");
|
|
95
|
+
const files = [...parts]
|
|
96
|
+
.filter(([, p]) => /filename="/.test(p.headers))
|
|
97
|
+
.map(([name]) => name.replace(/[. ]/g, "_"));
|
|
98
|
+
if (typeof named !== "string" || files.includes(named)) return null;
|
|
99
|
+
return { status: 200, body: cassette("post-dot") };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const portOf = (s: Server): number => {
|
|
103
|
+
const a = s.address();
|
|
104
|
+
assert.ok(a !== null && typeof a === "object", "the server is not listening");
|
|
105
|
+
return a.port;
|
|
106
|
+
};
|
|
107
|
+
const closeServer = (s: Server): Promise<void> =>
|
|
108
|
+
new Promise<void>((r) => {
|
|
109
|
+
s.close(() => {
|
|
110
|
+
r();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
beforeAll(async () => {
|
|
115
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
116
|
+
base = `http://127.0.0.1:${String(portOf(server))}`;
|
|
117
|
+
});
|
|
118
|
+
afterAll(() => closeServer(server));
|
|
119
|
+
|
|
120
|
+
const TOKEN = "hct_test_token";
|
|
121
|
+
const answer = (status: number, body: unknown): void => {
|
|
122
|
+
seen.length = 0;
|
|
123
|
+
reply = { status, body: JSON.stringify(body) };
|
|
124
|
+
};
|
|
125
|
+
const portal = () => hotcrpPortal({ url: base, token: TOKEN });
|
|
126
|
+
const last = (): Seen => {
|
|
127
|
+
const s = seen.at(-1);
|
|
128
|
+
assert.ok(s, "the server saw no request");
|
|
129
|
+
return s;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const PDF = new TextEncoder().encode("%PDF-1.5 fake bytes");
|
|
133
|
+
const HEX = sha256Hex(PDF);
|
|
134
|
+
const CASSETTE_HEX =
|
|
135
|
+
"b3e602c135f2455f64aecfdc2e1e7f81dad20c5d69f0981e965b1a0131d47163";
|
|
136
|
+
|
|
137
|
+
interface Part {
|
|
138
|
+
readonly headers: string;
|
|
139
|
+
readonly body: Buffer;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The multipart body split into its parts by the boundary its content type names — the way a
|
|
144
|
+
* server reads it, small enough to read here rather than trust a library's parser.
|
|
145
|
+
*/
|
|
146
|
+
function partsOf(s: Seen): ReadonlyMap<string, Part> {
|
|
147
|
+
const boundary = /boundary=(.+)$/.exec(s.headers["content-type"] ?? "")?.[1];
|
|
148
|
+
assert.ok(boundary, "not a multipart body");
|
|
149
|
+
const text = s.body.toString("latin1");
|
|
150
|
+
const parts = text.split(`--${boundary}`).slice(1, -1);
|
|
151
|
+
return new Map(
|
|
152
|
+
parts.map((raw) => {
|
|
153
|
+
const [headers = "", ...rest] = raw.slice(2, -2).split("\r\n\r\n");
|
|
154
|
+
const name = /name="([^"]*)"/.exec(headers)?.[1] ?? "";
|
|
155
|
+
return [
|
|
156
|
+
name,
|
|
157
|
+
{ headers, body: Buffer.from(rest.join("\r\n\r\n"), "latin1") },
|
|
158
|
+
];
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("show: GET /api/paper?p=<id>&word_limit=hard with the bearer token, parsed whole", async () => {
|
|
164
|
+
seen.length = 0;
|
|
165
|
+
reply = { status: 200, body: cassette("get-paper") };
|
|
166
|
+
const r = await portal().show(7);
|
|
167
|
+
const s = last();
|
|
168
|
+
assert.equal(s.method, "GET");
|
|
169
|
+
assert.equal(s.url.pathname, "/api/paper");
|
|
170
|
+
assert.deepEqual(Object.fromEntries(s.url.searchParams), {
|
|
171
|
+
p: "7",
|
|
172
|
+
word_limit: "hard",
|
|
173
|
+
});
|
|
174
|
+
assert.equal(s.headers.authorization, `bearer ${TOKEN}`);
|
|
175
|
+
assert.deepEqual(r, {
|
|
176
|
+
ok: true,
|
|
177
|
+
value: {
|
|
178
|
+
id: 7,
|
|
179
|
+
status: "submitted",
|
|
180
|
+
title: "An Example Paper Title",
|
|
181
|
+
paperType: "Example paper type",
|
|
182
|
+
topics: ["Example topic one", "Example topic two"],
|
|
183
|
+
abstract: "An example abstract of exactly ten words for the test.",
|
|
184
|
+
pdf: {
|
|
185
|
+
sha256: CASSETTE_HEX,
|
|
186
|
+
hash: `sha2-${CASSETTE_HEX}`,
|
|
187
|
+
size: 17,
|
|
188
|
+
mimetype: "application/pdf",
|
|
189
|
+
uploadedAt: 1_790_000_000,
|
|
190
|
+
},
|
|
191
|
+
submittedAt: 1_790_000_100,
|
|
192
|
+
modifiedAt: 1_790_000_100,
|
|
193
|
+
messages: [],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("show: a submission without a PDF, fields absent, a paper type that is not a string", async () => {
|
|
199
|
+
answer(200, {
|
|
200
|
+
ok: true,
|
|
201
|
+
paper: {
|
|
202
|
+
pid: 7,
|
|
203
|
+
status: "draft",
|
|
204
|
+
paper_type: 2,
|
|
205
|
+
submission: { hash: "sha2-short" },
|
|
206
|
+
},
|
|
207
|
+
message_list: [{ message: "note", field: "title", status: 1 }, {}],
|
|
208
|
+
});
|
|
209
|
+
assert.deepEqual(await portal().show(7), {
|
|
210
|
+
ok: true,
|
|
211
|
+
value: {
|
|
212
|
+
id: 7,
|
|
213
|
+
status: "draft",
|
|
214
|
+
title: null,
|
|
215
|
+
paperType: "2",
|
|
216
|
+
topics: [],
|
|
217
|
+
abstract: null,
|
|
218
|
+
pdf: {
|
|
219
|
+
sha256: null,
|
|
220
|
+
hash: "sha2-short",
|
|
221
|
+
size: null,
|
|
222
|
+
mimetype: null,
|
|
223
|
+
uploadedAt: null,
|
|
224
|
+
},
|
|
225
|
+
submittedAt: null,
|
|
226
|
+
modifiedAt: null,
|
|
227
|
+
messages: [{ message: "note", field: "title", status: 1 }],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("show: a null paper type and a null submission read as none", async () => {
|
|
233
|
+
answer(200, {
|
|
234
|
+
ok: true,
|
|
235
|
+
paper: { pid: 7, status: "draft", paper_type: null, submission: null },
|
|
236
|
+
});
|
|
237
|
+
const r = await portal().show(7);
|
|
238
|
+
assert.deepEqual(r.ok && [r.value.paperType, r.value.pdf], [null, null]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("🔴 show: 401 surfaces HotCRP's own message, and the token is in no part of it", async () => {
|
|
242
|
+
seen.length = 0;
|
|
243
|
+
reply = { status: 401, body: cassette("get-noauth") };
|
|
244
|
+
const r = await portal().show(7);
|
|
245
|
+
assert.deepEqual(r, {
|
|
246
|
+
ok: false,
|
|
247
|
+
error: {
|
|
248
|
+
kind: "refused",
|
|
249
|
+
httpStatus: 401,
|
|
250
|
+
messages: [{ message: "Missing credentials", field: null, status: 2 }],
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
// Guards: the token never travels into what the caller prints.
|
|
254
|
+
assert.doesNotMatch(JSON.stringify(r), new RegExp(TOKEN));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const CHANGE: SubmissionChange = {
|
|
258
|
+
pdf: { name: "paper.pdf", bytes: PDF },
|
|
259
|
+
abstract: "new abstract",
|
|
260
|
+
submit: true,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
test("update: a dry run by default — dry_run=1, the json field, the PDF under its content_file name", async () => {
|
|
264
|
+
seen.length = 0;
|
|
265
|
+
reply = { status: 200, body: cassette("post-ok") };
|
|
266
|
+
const r = await portal().update(7, CHANGE, { save: false });
|
|
267
|
+
const s = last();
|
|
268
|
+
assert.equal(s.method, "POST");
|
|
269
|
+
assert.equal(s.url.pathname, "/api/paper");
|
|
270
|
+
assert.deepEqual(Object.fromEntries(s.url.searchParams), {
|
|
271
|
+
p: "7",
|
|
272
|
+
dry_run: "1",
|
|
273
|
+
});
|
|
274
|
+
assert.equal(s.headers.authorization, `bearer ${TOKEN}`);
|
|
275
|
+
const parts = partsOf(s);
|
|
276
|
+
assert.deepEqual([...parts.keys()], ["json", CONTENT_FIELD]);
|
|
277
|
+
assert.deepEqual(JSON.parse(parts.get("json")?.body.toString("utf8") ?? ""), {
|
|
278
|
+
object: "paper",
|
|
279
|
+
pid: 7,
|
|
280
|
+
submission: { content_file: CONTENT_FIELD },
|
|
281
|
+
abstract: "new abstract",
|
|
282
|
+
status: "submitted",
|
|
283
|
+
});
|
|
284
|
+
const file = parts.get(CONTENT_FIELD);
|
|
285
|
+
assert.match(file?.headers ?? "", /filename="paper\.pdf"/);
|
|
286
|
+
assert.match(file?.headers ?? "", /Content-Type: application\/pdf/i);
|
|
287
|
+
assert.deepEqual(new Uint8Array(file?.body ?? []), PDF);
|
|
288
|
+
assert.deepEqual(r, {
|
|
289
|
+
ok: true,
|
|
290
|
+
value: {
|
|
291
|
+
httpStatus: 200,
|
|
292
|
+
valid: true,
|
|
293
|
+
changes: ["submission"],
|
|
294
|
+
messages: [],
|
|
295
|
+
dryRun: true,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("🔴 the mock is HotCRP here: a file part named with a dot is not found under that name", async () => {
|
|
301
|
+
// Guards: the rule the dry-run test relies on FIRES — the form a real HotCRP refused.
|
|
302
|
+
answer(200, { ok: true, valid: true, change_list: ["submission"] });
|
|
303
|
+
const form = new FormData();
|
|
304
|
+
form.append(
|
|
305
|
+
"json",
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
object: "paper",
|
|
308
|
+
pid: 7,
|
|
309
|
+
submission: { content_file: "paper.pdf" },
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
form.append(
|
|
313
|
+
"paper.pdf",
|
|
314
|
+
new Blob([PDF], { type: "application/pdf" }),
|
|
315
|
+
"paper.pdf",
|
|
316
|
+
);
|
|
317
|
+
const r = await fetch(`${base}/api/paper?p=7&dry_run=1`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
body: form,
|
|
320
|
+
});
|
|
321
|
+
const body: unknown = await r.json();
|
|
322
|
+
assert.equal(fieldOf(body, "valid"), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("the PDF's field name survives PHP's renaming of form fields: no dot, space or bracket", () => {
|
|
326
|
+
assert.doesNotMatch(CONTENT_FIELD, /[. [\]]/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("update with save: no dry_run parameter at all", async () => {
|
|
330
|
+
answer(200, { ok: true, valid: true, pid: 7 });
|
|
331
|
+
const r = await portal().update(7, CHANGE, { save: true });
|
|
332
|
+
assert.deepEqual(Object.fromEntries(last().url.searchParams), { p: "7" });
|
|
333
|
+
assert.deepEqual(r, {
|
|
334
|
+
ok: true,
|
|
335
|
+
value: {
|
|
336
|
+
httpStatus: 200,
|
|
337
|
+
valid: true,
|
|
338
|
+
changes: [],
|
|
339
|
+
messages: [],
|
|
340
|
+
dryRun: false,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("update: valid:false is an outcome with HotCRP's messages, at any HTTP status", async () => {
|
|
346
|
+
answer(400, {
|
|
347
|
+
ok: false,
|
|
348
|
+
valid: false,
|
|
349
|
+
change_list: ["submission"],
|
|
350
|
+
message_list: [{ field: "submission", message: "Bad PDF", status: 2 }],
|
|
351
|
+
});
|
|
352
|
+
assert.deepEqual(await portal().update(7, CHANGE, { save: false }), {
|
|
353
|
+
ok: true,
|
|
354
|
+
value: {
|
|
355
|
+
httpStatus: 400,
|
|
356
|
+
valid: false,
|
|
357
|
+
changes: ["submission"],
|
|
358
|
+
messages: [{ message: "Bad PDF", field: "submission", status: 2 }],
|
|
359
|
+
dryRun: false,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("update: 403 without a verdict is a refusal with the messages", async () => {
|
|
365
|
+
answer(403, {
|
|
366
|
+
ok: false,
|
|
367
|
+
message_list: [{ message: "Token scope does not allow this" }],
|
|
368
|
+
});
|
|
369
|
+
assert.deepEqual(await portal().update(7, CHANGE, { save: false }), {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: {
|
|
372
|
+
kind: "refused",
|
|
373
|
+
httpStatus: 403,
|
|
374
|
+
messages: [
|
|
375
|
+
{
|
|
376
|
+
message: "Token scope does not allow this",
|
|
377
|
+
field: null,
|
|
378
|
+
status: null,
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("an answer that is not JSON, or JSON that is no answer, is malformed — not a pass", async () => {
|
|
386
|
+
seen.length = 0;
|
|
387
|
+
reply = { status: 200, body: "<html>login</html>", type: "text/html" };
|
|
388
|
+
assert.deepEqual(await portal().show(7), {
|
|
389
|
+
ok: false,
|
|
390
|
+
error: {
|
|
391
|
+
kind: "malformed",
|
|
392
|
+
httpStatus: 200,
|
|
393
|
+
detail: "the answer is not JSON (text/html)",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
answer(200, { ok: true });
|
|
397
|
+
assert.deepEqual(await portal().show(7), {
|
|
398
|
+
ok: false,
|
|
399
|
+
error: {
|
|
400
|
+
kind: "malformed",
|
|
401
|
+
httpStatus: 200,
|
|
402
|
+
detail: "the answer has no submission in `paper`",
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
answer(200, { ok: true });
|
|
406
|
+
assert.deepEqual(await portal().update(7, CHANGE, { save: false }), {
|
|
407
|
+
ok: false,
|
|
408
|
+
error: {
|
|
409
|
+
kind: "malformed",
|
|
410
|
+
httpStatus: 200,
|
|
411
|
+
detail: "the answer has no `valid`",
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
seen.length = 0;
|
|
415
|
+
reply = { status: 502, body: "bad gateway", type: "" };
|
|
416
|
+
assert.deepEqual(await portal().show(7), {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: {
|
|
419
|
+
kind: "malformed",
|
|
420
|
+
httpStatus: 502,
|
|
421
|
+
detail: "the answer is not JSON (no content type)",
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
answer(500, "oops");
|
|
425
|
+
const r = await portal().show(7);
|
|
426
|
+
assert.deepEqual(r, {
|
|
427
|
+
ok: false,
|
|
428
|
+
error: { kind: "refused", httpStatus: 500, messages: [] },
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a portal that does not answer is unreachable, with the network's words", async () => {
|
|
433
|
+
const closed = createServer();
|
|
434
|
+
await new Promise<void>((r) => closed.listen(0, "127.0.0.1", r));
|
|
435
|
+
const port = portOf(closed);
|
|
436
|
+
await closeServer(closed);
|
|
437
|
+
const down = hotcrpPortal({
|
|
438
|
+
url: `http://127.0.0.1:${String(port)}`,
|
|
439
|
+
token: TOKEN,
|
|
440
|
+
});
|
|
441
|
+
for (const r of [
|
|
442
|
+
await down.show(7),
|
|
443
|
+
await down.update(7, CHANGE, { save: false }),
|
|
444
|
+
]) {
|
|
445
|
+
assert.equal(!r.ok && r.error.kind, "unreachable");
|
|
446
|
+
assert.ok(!r.ok && r.error.kind === "unreachable" && r.error.detail !== "");
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("changeObject: only what changes is sent", () => {
|
|
451
|
+
assert.deepEqual(
|
|
452
|
+
changeObject(7, { pdf: null, abstract: null, submit: false }),
|
|
453
|
+
{ object: "paper", pid: 7 },
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("sha256OfHash: sha2-<hex> is a sha256; any other hash is not claimed to be one", () => {
|
|
458
|
+
assert.equal(sha256OfHash(`sha2-${HEX}`), HEX);
|
|
459
|
+
assert.equal(sha256OfHash("da39a3ee5e6b4b0d3255bfef95601890afd80709"), null);
|
|
460
|
+
assert.equal(sha256OfHash(`sha2-${HEX.toUpperCase()}`), null);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("🔴 every recorded HotCRP answer parses through the real schemas, into what it means", () => {
|
|
464
|
+
// Guards: a schema that rejects a real answer — the class a hand-written body cannot show.
|
|
465
|
+
assert.equal(parseShow(200, parsed("get-paper")).ok, true);
|
|
466
|
+
const refused = {
|
|
467
|
+
ok: false,
|
|
468
|
+
error: {
|
|
469
|
+
kind: "refused",
|
|
470
|
+
httpStatus: 401,
|
|
471
|
+
messages: [{ message: "Missing credentials", field: null, status: 2 }],
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
assert.deepEqual(parseShow(401, parsed("get-noauth")), refused);
|
|
475
|
+
// The body's own `status_code` decides when the transport says 2xx.
|
|
476
|
+
assert.deepEqual(parseShow(200, parsed("get-noauth")), refused);
|
|
477
|
+
assert.deepEqual(parseUpdate(200, parsed("post-dot")), {
|
|
478
|
+
ok: true,
|
|
479
|
+
value: {
|
|
480
|
+
httpStatus: 200,
|
|
481
|
+
valid: false,
|
|
482
|
+
changes: [],
|
|
483
|
+
messages: [
|
|
484
|
+
{
|
|
485
|
+
message: "Ignored attempt to upload document without any content",
|
|
486
|
+
field: "submission",
|
|
487
|
+
status: 2,
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
dryRun: true,
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
assert.deepEqual(parseUpdate(200, parsed("post-ok")), {
|
|
494
|
+
ok: true,
|
|
495
|
+
value: {
|
|
496
|
+
httpStatus: 200,
|
|
497
|
+
valid: true,
|
|
498
|
+
changes: ["submission"],
|
|
499
|
+
messages: [],
|
|
500
|
+
dryRun: true,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCRP's answers, parsed at the boundary into paperlint's submission values. Pure.
|
|
3
|
+
*
|
|
4
|
+
* Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
|
|
5
|
+
* names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
|
|
6
|
+
* `GET /api/paper` answers `{ ok, paper, message_list? }`, `POST /api/paper` answers
|
|
7
|
+
* `{ ok, valid, change_list, message_list, dry_run?, pid }`. A document's `hash` is
|
|
8
|
+
* `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { err, ok, type Result } from "../../domain/result.ts";
|
|
12
|
+
import { parseSha256, type Sha256 } from "../../domain/sha256.ts";
|
|
13
|
+
import type {
|
|
14
|
+
PortalDocument,
|
|
15
|
+
PortalFailure,
|
|
16
|
+
PortalMessage,
|
|
17
|
+
SubmissionView,
|
|
18
|
+
UpdateOutcome,
|
|
19
|
+
} from "../../domain/submission.ts";
|
|
20
|
+
|
|
21
|
+
const Message = z.object({
|
|
22
|
+
message: z.string().optional(),
|
|
23
|
+
field: z.string().optional(),
|
|
24
|
+
status: z.number().optional(),
|
|
25
|
+
});
|
|
26
|
+
const Messages = z.array(Message).optional();
|
|
27
|
+
|
|
28
|
+
const Document = z.object({
|
|
29
|
+
hash: z.string().optional(),
|
|
30
|
+
size: z.number().optional(),
|
|
31
|
+
mimetype: z.string().optional(),
|
|
32
|
+
timestamp: z.number().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const Paper = z.object({
|
|
36
|
+
pid: z.number(),
|
|
37
|
+
status: z.string(),
|
|
38
|
+
title: z.string().optional(),
|
|
39
|
+
abstract: z.string().optional(),
|
|
40
|
+
paper_type: z.unknown().optional(),
|
|
41
|
+
topics: z.array(z.string()).optional(),
|
|
42
|
+
submission: Document.nullable().optional(),
|
|
43
|
+
submitted_at: z.number().optional(),
|
|
44
|
+
modified_at: z.number().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const ShowBody = z.object({
|
|
48
|
+
ok: z.literal(true),
|
|
49
|
+
paper: Paper,
|
|
50
|
+
message_list: Messages,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const UpdateBody = z.object({
|
|
54
|
+
valid: z.boolean(),
|
|
55
|
+
change_list: z.array(z.string()).optional(),
|
|
56
|
+
message_list: Messages,
|
|
57
|
+
dry_run: z.boolean().optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const Envelope = z.object({ message_list: Messages });
|
|
61
|
+
|
|
62
|
+
const SHA2 = /^sha2-([0-9a-f]{64})$/;
|
|
63
|
+
|
|
64
|
+
/** HotCRP's `sha2-<hex>` → the sha256; null for any other hash it may name. */
|
|
65
|
+
export function sha256OfHash(hash: string): Sha256 | null {
|
|
66
|
+
const hex = SHA2.exec(hash)?.[1];
|
|
67
|
+
return hex === undefined ? null : parseSha256(hex);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest. */
|
|
71
|
+
const textOf = (message: string | undefined): string =>
|
|
72
|
+
(message ?? "").replace(/^<\d+>/, "").trim();
|
|
73
|
+
|
|
74
|
+
/** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
|
|
75
|
+
const messagesOf = (
|
|
76
|
+
list: Readonly<z.infer<typeof Messages>>,
|
|
77
|
+
): readonly PortalMessage[] =>
|
|
78
|
+
(list ?? [])
|
|
79
|
+
.map((m) => ({
|
|
80
|
+
message: textOf(m.message),
|
|
81
|
+
field: m.field ?? null,
|
|
82
|
+
status: m.status ?? null,
|
|
83
|
+
}))
|
|
84
|
+
.filter((m) => m.message !== "");
|
|
85
|
+
|
|
86
|
+
const documentOf = (
|
|
87
|
+
d: Readonly<z.infer<typeof Document>> | null | undefined,
|
|
88
|
+
): PortalDocument | null =>
|
|
89
|
+
d?.hash === undefined
|
|
90
|
+
? null
|
|
91
|
+
: {
|
|
92
|
+
sha256: sha256OfHash(d.hash),
|
|
93
|
+
hash: d.hash,
|
|
94
|
+
size: d.size ?? null,
|
|
95
|
+
mimetype: d.mimetype ?? null,
|
|
96
|
+
uploadedAt: d.timestamp ?? null,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** A paper type as text: HotCRP sends the option's name, or a value of another shape. */
|
|
100
|
+
const typeText = (v: unknown): string | null =>
|
|
101
|
+
v === undefined || v === null
|
|
102
|
+
? null
|
|
103
|
+
: typeof v === "string"
|
|
104
|
+
? v
|
|
105
|
+
: JSON.stringify(v);
|
|
106
|
+
|
|
107
|
+
function viewOf(b: Readonly<z.infer<typeof ShowBody>>): SubmissionView {
|
|
108
|
+
const p = b.paper;
|
|
109
|
+
return {
|
|
110
|
+
id: p.pid,
|
|
111
|
+
status: p.status,
|
|
112
|
+
title: p.title ?? null,
|
|
113
|
+
paperType: typeText(p.paper_type),
|
|
114
|
+
topics: p.topics ?? [],
|
|
115
|
+
abstract: p.abstract ?? null,
|
|
116
|
+
pdf: documentOf(p.submission),
|
|
117
|
+
submittedAt: p.submitted_at ?? null,
|
|
118
|
+
modifiedAt: p.modified_at ?? null,
|
|
119
|
+
messages: messagesOf(b.message_list),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const is2xx = (status: number): boolean => status >= 200 && status < 300;
|
|
124
|
+
|
|
125
|
+
const StatusCode = z.object({ status_code: z.number() });
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The status HotCRP meant. Its error envelope repeats the status in the body (`status_code: 401`
|
|
129
|
+
* beside `ok: false`, recorded in `cassette/get-noauth.json`); when something between us and HotCRP
|
|
130
|
+
* turns the transport status into a 2xx, the body's code still decides.
|
|
131
|
+
*/
|
|
132
|
+
function statusOf(transport: number, body: unknown): number {
|
|
133
|
+
const c = StatusCode.safeParse(body);
|
|
134
|
+
return is2xx(transport) && c.success ? c.data.status_code : transport;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A body that is no answer of HotCRP's: the portal's messages when it sent any, else "malformed". */
|
|
138
|
+
function refusal(
|
|
139
|
+
httpStatus: number,
|
|
140
|
+
body: unknown,
|
|
141
|
+
what: string,
|
|
142
|
+
): PortalFailure {
|
|
143
|
+
const e = Envelope.safeParse(body);
|
|
144
|
+
const messages = e.success ? messagesOf(e.data.message_list) : [];
|
|
145
|
+
return messages.length > 0 || !is2xx(httpStatus)
|
|
146
|
+
? { kind: "refused", httpStatus, messages }
|
|
147
|
+
: { kind: "malformed", httpStatus, detail: what };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `GET /api/paper` → the submission, or why there is none. */
|
|
151
|
+
export function parseShow(
|
|
152
|
+
httpStatus: number,
|
|
153
|
+
body: unknown,
|
|
154
|
+
): Result<SubmissionView, PortalFailure> {
|
|
155
|
+
const status = statusOf(httpStatus, body);
|
|
156
|
+
const b = ShowBody.safeParse(body);
|
|
157
|
+
return b.success && is2xx(status)
|
|
158
|
+
? ok(viewOf(b.data))
|
|
159
|
+
: err(refusal(status, body, "the answer has no submission in `paper`"));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `POST /api/paper` → the outcome, valid or not. A body carrying `valid` is HotCRP's verdict on
|
|
164
|
+
* the change whatever the HTTP status; the caller decides the exit from both.
|
|
165
|
+
*/
|
|
166
|
+
export function parseUpdate(
|
|
167
|
+
httpStatus: number,
|
|
168
|
+
body: unknown,
|
|
169
|
+
): Result<UpdateOutcome, PortalFailure> {
|
|
170
|
+
const status = statusOf(httpStatus, body);
|
|
171
|
+
const b = UpdateBody.safeParse(body);
|
|
172
|
+
if (!b.success)
|
|
173
|
+
return err(refusal(status, body, "the answer has no `valid`"));
|
|
174
|
+
return ok({
|
|
175
|
+
httpStatus: status,
|
|
176
|
+
valid: b.data.valid,
|
|
177
|
+
changes: b.data.change_list ?? [],
|
|
178
|
+
messages: messagesOf(b.data.message_list),
|
|
179
|
+
dryRun: b.data.dry_run ?? false,
|
|
180
|
+
});
|
|
181
|
+
}
|