paperlint 6.2.3 → 7.0.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/CONTRIBUTING.md +4 -2
- package/README.md +10 -3
- 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/adapters/latex/conditionals.d.ts +21 -0
- package/dist/adapters/latex/conditionals.d.ts.map +1 -0
- package/dist/adapters/latex/conditionals.js +75 -0
- package/dist/adapters/latex/conditionals.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +10 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -0
- package/dist/adapters/latex/headings.js +129 -0
- package/dist/adapters/latex/headings.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +3 -2
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +126 -37
- package/dist/cli.js.map +1 -1
- package/dist/domain/heading-case.d.ts +100 -0
- package/dist/domain/heading-case.d.ts.map +1 -0
- package/dist/domain/heading-case.js +365 -0
- package/dist/domain/heading-case.js.map +1 -0
- 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/tex-document.d.ts +32 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +11 -0
- package/dist/domain/tex-document.js.map +1 -1
- 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/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +16 -3
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/facts-file.d.ts +3 -3
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +3 -3
- package/dist/facts-file.js.map +1 -1
- 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/heading-case.d.ts +48 -0
- package/dist/heading-case.d.ts.map +1 -0
- package/dist/heading-case.js +191 -0
- package/dist/heading-case.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/latex.d.ts +7 -1
- package/dist/ports/latex.d.ts.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/preset-card-rule.d.ts +100 -0
- package/dist/preset-card-rule.d.ts.map +1 -0
- package/dist/preset-card-rule.js +221 -0
- package/dist/preset-card-rule.js.map +1 -0
- package/dist/presets.d.ts +43 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +130 -63
- 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 +36 -3
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +30 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +7 -8
- 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 +30 -11
- package/docs/optional-rules.md +4 -3
- package/docs/rules/format/page-limit.md +1 -1
- 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/talk/profile.md +1 -1
- package/docs/rules/tex/heading-case.md +424 -0
- package/docs/rules.md +64 -53
- package/docs/skills.md +5 -4
- package/docs/submission.md +64 -0
- package/docs/talk.md +1 -1
- package/eslint-rules/latex-language.test.mjs +12 -0
- package/eslint-rules/latex-language.ts +17 -4
- package/eslint-rules/paper-typography.test.mjs +12 -0
- package/eslint.config.mjs +59 -1
- package/fixtures/build-e2e/acmart/paper.tex +1 -1
- package/fixtures/build-e2e/fallback/expect.json +3 -3
- package/lib/paper-config.ts +6 -1
- package/lib/skill-checks.d.mts +7 -0
- package/lib/skill-checks.mjs +31 -0
- package/lib/skill-trigger-cases.mjs +11 -0
- package/package.json +8 -2
- package/presets/acm-sigconf.jsonc +5 -0
- package/presets/acm-sigconf.md +43 -0
- package/presets/agenticdev.jsonc +27 -6
- package/presets/agenticdev.md +151 -0
- package/presets/aidc.jsonc +12 -0
- package/presets/aidc.md +75 -0
- package/presets/aisec.jsonc +5 -1
- package/presets/aisec.md +117 -0
- package/presets/ieee-conference.jsonc +5 -0
- package/presets/ieee-conference.md +38 -0
- package/presets/realm.jsonc +7 -2
- package/presets/realm.md +166 -0
- package/presets/tex/agenticdev.tex +1 -1
- package/presets/tex-base.jsonc +6 -0
- package/presets/tex-base.md +30 -0
- package/presets/venue-profile.schema.json +57 -2
- package/skills/find-venue/SKILL.md +6 -5
- package/skills/find-venue/SKILL.md.spec.ts +5 -4
- package/skills/paper-pipeline/SKILL.md +5 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
- package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
- package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
- package/skills/study-accepted-papers/SKILL.md +3 -3
- package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
- 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 +27 -22
- package/skills/submit-paper/SKILL.md.spec.ts +26 -21
- package/skills/submit-paper/references/publishers/acm.md +1 -1
- 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/adapters/latex/conditionals.ts +130 -0
- package/src/adapters/latex/headings.ts +163 -0
- package/src/adapters/latex/index.test.ts +65 -0
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/parse.ts +3 -2
- package/src/build.ts +2 -2
- package/src/cli.test.ts +155 -3
- package/src/cli.ts +161 -25
- package/src/domain/heading-case.test.ts +660 -0
- package/src/domain/heading-case.ts +538 -0
- 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/tex-document.ts +36 -0
- package/src/domain/venue-name.test.ts +121 -0
- package/src/domain/venue-name.ts +129 -0
- package/src/facts-file.test.ts +4 -4
- package/src/facts-file.ts +6 -6
- package/src/folder-venue-rule.test.ts +182 -0
- package/src/folder-venue-rule.ts +141 -0
- package/src/heading-case.test.ts +500 -0
- package/src/heading-case.ts +275 -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 +135 -10
- package/src/new-paper.ts +38 -7
- package/src/paper-settings-commands.test.ts +25 -10
- 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/latex.ts +7 -0
- package/src/ports/submission-portal.ts +26 -0
- package/src/preset-card-rule.test.ts +266 -0
- package/src/preset-card-rule.ts +384 -0
- package/src/presets.test.ts +212 -37
- package/src/presets.ts +183 -71
- package/src/rule-cases.test.ts +147 -42
- package/src/rules-config.ts +1 -1
- package/src/submission.test.ts +497 -0
- package/src/submission.ts +311 -0
- package/src/talk-rules.test.ts +6 -3
- package/src/tex-requirements.harness.ts +25 -11
- package/src/tex-requirements.test.ts +127 -1
- package/src/tex-requirements.ts +78 -8
- package/src/tex-venue-rules.test.ts +36 -22
- package/src/tex-venue-rules.ts +14 -26
- package/src/types.ts +13 -0
- package/src/venue-rules.test.ts +7 -6
- package/templates/paper/paper.tex +1 -1
- package/skills/submit-paper/references/venues/agenticdev.md +0 -171
- package/skills/submit-paper/references/venues/aisec.md +0 -105
- package/skills/submit-paper/references/venues/realm.md +0 -155
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCRP's REST API (`<site>/api/paper`) as the `SubmissionPortal` port, over Node's `fetch`.
|
|
3
|
+
*
|
|
4
|
+
* Read: `GET /api/paper?p=<id>&word_limit=hard` (scope `submeta:read`; `word_limit=hard` returns the
|
|
5
|
+
* whole abstract instead of one cut at the soft limit).
|
|
6
|
+
*
|
|
7
|
+
* Change: `POST /api/paper?p=<id>` as multipart/form-data — a `json` field holding a submission
|
|
8
|
+
* object, and the PDF as a file part whose field name is the object's `submission.content_file`
|
|
9
|
+
* (the documented form: `curl -F "json=<data.json" -F paper.pdf=@paper.pdf SITEURL/api/paper`).
|
|
10
|
+
* Without `save` the request carries `dry_run=1`: HotCRP checks the change and keeps nothing.
|
|
11
|
+
*
|
|
12
|
+
* 🔴 THE TOKEN goes in the `Authorization` header and nowhere else: not into a URL, a message, or an
|
|
13
|
+
* error. A failure is described by HTTP status, HotCRP's own messages, or the network error's text.
|
|
14
|
+
*/
|
|
15
|
+
import { messageOf } from "../../domain/text.ts";
|
|
16
|
+
import { err, type Result } from "../../domain/result.ts";
|
|
17
|
+
import type {
|
|
18
|
+
PortalFailure,
|
|
19
|
+
SubmissionChange,
|
|
20
|
+
} from "../../domain/submission.ts";
|
|
21
|
+
import type { SubmissionPortal } from "../../ports/submission-portal.ts";
|
|
22
|
+
import { parseShow, parseUpdate } from "./response.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The form field the PDF travels in, and the name the `json` object's `content_file` refers to it
|
|
26
|
+
* by. 🔴 NO DOT, SPACE OR BRACKET: HotCRP looks the name up in PHP's `$_FILES`, and PHP renames a
|
|
27
|
+
* dot or a space in a field name to `_`. The documented `-F paper.pdf=@paper.pdf` therefore arrives
|
|
28
|
+
* as `paper_pdf`, the lookup of `paper.pdf` misses, and HotCRP answers "Ignored attempt to upload
|
|
29
|
+
* document without any content" with `valid: false` — measured on a real HotCRP, 2026-10-03.
|
|
30
|
+
*/
|
|
31
|
+
export const CONTENT_FIELD = "paperlint_pdf";
|
|
32
|
+
|
|
33
|
+
/** Uploads take a while on a slow link; a read should not. */
|
|
34
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
35
|
+
const WRITE_TIMEOUT_MS = 300_000;
|
|
36
|
+
|
|
37
|
+
export interface HotcrpOptions {
|
|
38
|
+
/** The site's base URL, no trailing slash: `https://example.org`. */
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly token: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Answer = Result<
|
|
44
|
+
{ readonly status: number; readonly body: unknown },
|
|
45
|
+
PortalFailure
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
/** One request → HTTP status and parsed JSON body, or why there is neither. */
|
|
49
|
+
async function call(
|
|
50
|
+
url: string,
|
|
51
|
+
init: { readonly method: string; readonly body?: Readonly<FormData> },
|
|
52
|
+
token: string,
|
|
53
|
+
timeoutMs: number,
|
|
54
|
+
): Promise<Answer> {
|
|
55
|
+
try {
|
|
56
|
+
const r = await fetch(url, {
|
|
57
|
+
...init,
|
|
58
|
+
headers: { Authorization: `bearer ${token}`, Accept: "application/json" },
|
|
59
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
60
|
+
});
|
|
61
|
+
const text = await r.text();
|
|
62
|
+
try {
|
|
63
|
+
return { ok: true, value: { status: r.status, body: JSON.parse(text) } };
|
|
64
|
+
} catch {
|
|
65
|
+
return err({
|
|
66
|
+
kind: "malformed",
|
|
67
|
+
httpStatus: r.status,
|
|
68
|
+
detail: `the answer is not JSON (${r.headers.get("content-type") ?? "no content type"})`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return err({ kind: "unreachable", detail: messageOf(e) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The submission object a change sends: only the fields it changes. */
|
|
77
|
+
export function changeObject(
|
|
78
|
+
id: number,
|
|
79
|
+
c: SubmissionChange,
|
|
80
|
+
): Readonly<Record<string, unknown>> {
|
|
81
|
+
return {
|
|
82
|
+
object: "paper",
|
|
83
|
+
pid: id,
|
|
84
|
+
...(c.pdf === null ? {} : { submission: { content_file: CONTENT_FIELD } }),
|
|
85
|
+
...(c.abstract === null ? {} : { abstract: c.abstract }),
|
|
86
|
+
...(c.submit ? { status: "submitted" } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formOf(id: number, c: SubmissionChange): Readonly<FormData> {
|
|
91
|
+
const form = new FormData();
|
|
92
|
+
form.append("json", JSON.stringify(changeObject(id, c)));
|
|
93
|
+
if (c.pdf !== null)
|
|
94
|
+
form.append(
|
|
95
|
+
CONTENT_FIELD,
|
|
96
|
+
new Blob([new Uint8Array(c.pdf.bytes)], { type: "application/pdf" }),
|
|
97
|
+
c.pdf.name,
|
|
98
|
+
);
|
|
99
|
+
return form;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const endpoint = (
|
|
103
|
+
base: string,
|
|
104
|
+
params: Readonly<Record<string, string>>,
|
|
105
|
+
): string => `${base}/api/paper?${new URLSearchParams(params).toString()}`;
|
|
106
|
+
|
|
107
|
+
/** HotCRP at `o.url`, read and changed with `o.token`. */
|
|
108
|
+
export function hotcrpPortal(o: HotcrpOptions): SubmissionPortal {
|
|
109
|
+
return {
|
|
110
|
+
async show(id) {
|
|
111
|
+
const url = endpoint(o.url, { p: String(id), word_limit: "hard" });
|
|
112
|
+
const a = await call(url, { method: "GET" }, o.token, READ_TIMEOUT_MS);
|
|
113
|
+
return a.ok ? parseShow(a.value.status, a.value.body) : a;
|
|
114
|
+
},
|
|
115
|
+
async update(id, change, { save }) {
|
|
116
|
+
const url = endpoint(o.url, {
|
|
117
|
+
p: String(id),
|
|
118
|
+
...(save ? {} : { dry_run: "1" }),
|
|
119
|
+
});
|
|
120
|
+
const body = formOf(id, change);
|
|
121
|
+
const a = await call(
|
|
122
|
+
url,
|
|
123
|
+
{ method: "POST", body },
|
|
124
|
+
o.token,
|
|
125
|
+
WRITE_TIMEOUT_MS,
|
|
126
|
+
);
|
|
127
|
+
return a.ok ? parseUpdate(a.value.status, a.value.body) : a;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -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
|
+
});
|