Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/frontend/tests/site-integrity.test.mjs
blob: 4cce2ae19940545872c5900a1e017ecaa0d69fdd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { test } from "node:test";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const websiteRoot = join(root, "..");

function read(relativePath) {
  return readFileSync(join(root, relativePath), "utf8");
}

function locations(path) {
  return [...read(path).matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
}

test("every site publishes exactly its own pages in its sitemap", () => {
  assert.deepEqual(locations("public/sitemap.xml"), [
    "https://tala.run/",
    "https://tala.run/docs/overview",
    "https://tala.run/docs/getting-started",
    "https://tala.run/docs/syntax",
  ]);
  assert.deepEqual(locations("public/hosts/talac/sitemap.xml"), [
    "https://talac.tala.run/",
    "https://talac.tala.run/docs/command-line",
    "https://talac.tala.run/docs/outputs",
  ]);
  assert.deepEqual(locations("public/hosts/tazel/sitemap.xml"), [
    "https://tazel.tala.run/",
    "https://tazel.tala.run/docs/commands",
    "https://tazel.tala.run/docs/project-file",
  ]);
  assert.deepEqual(locations("public/hosts/talafmt/sitemap.xml"), [
    "https://talafmt.tala.run/",
    "https://talafmt.tala.run/docs/command-line",
    "https://talafmt.tala.run/docs/layout",
  ]);
  assert.deepEqual(locations("public/hosts/talalint/sitemap.xml"), [
    "https://talalint.tala.run/",
    "https://talalint.tala.run/docs/command-line",
    "https://talalint.tala.run/docs/checks",
  ]);
  assert.doesNotMatch(read("public/sitemap.xml"), /forum\.tala\.run/);
  for (const [path, host] of [
    ["public/robots.txt", "tala.run"],
    ["public/hosts/talac/robots.txt", "talac.tala.run"],
    ["public/hosts/tazel/robots.txt", "tazel.tala.run"],
    ["public/hosts/talafmt/robots.txt", "talafmt.tala.run"],
    ["public/hosts/talalint/robots.txt", "talalint.tala.run"],
  ]) {
    assert.match(
      read(path),
      new RegExp(`Sitemap: https://${host.replaceAll(".", "\\.")}/sitemap\\.xml`),
    );
  }
});

test("the forum placeholder is explicitly non-indexable and not an account service", () => {
  const forum = readFileSync(join(websiteRoot, "forum/index.html"), "utf8");
  const nginx = readFileSync(join(websiteRoot, "ops/nginx/forum.conf"), "utf8");
  assert.match(forum, /name="robots" content="noindex, nofollow"/);
  assert.match(forum, /There are no accounts,/);
  assert.match(forum, /posts, or discussion service/);
  assert.match(nginx, /X-Robots-Tag "noindex, nofollow"/);
  assert.match(nginx, /script-src 'self'; style-src 'self'/);
  assert.doesNotMatch(nginx, /unsafe-inline/);
  assert.match(forum, /src="\/site\.js[?"]/);
  assert.match(forum, /href="\/site\.css[?"]/);
  const normalizedForum = forum.toLowerCase();
  assert.equal(normalizedForum.split("<script").length - 1, 1);
  assert.equal(normalizedForum.split("</script>").length - 1, 1);
  // The query string is a cache key for the edge cache. Nginx serves the
  // exact file path, so changing the value forces a fresh copy after a deploy.
  assert.match(normalizedForum, /<script src="\/site\.js\?v=[0-9-]+" defer><\/script>/);
  assert.match(normalizedForum, /<link rel="stylesheet" href="\/site\.css\?v=[0-9-]+" \/>/);
  assert.doesNotMatch(forum, /<form\b/);
});

test("the browser identity uses the supplied logo without an opaque background", () => {
  const svg = read("public/tala.svg");
  const mark = read("public/tala-mark.svg");
  assert.match(svg, /<svg\b/);
  assert.doesNotMatch(svg, /<rect\s+id="background"/);
  assert.match(mark, /<title[^>]*>Tala leopard<\/title>/);
  assert.match(mark, /viewBox="370 285 365 345"/);
  assert.match(read("index.html"), /rel="icon"[^>]*tala-mark\.svg/);
  assert.match(read("src/App.vue"), /src="\/tala-mark\.svg"/);
  const fullLeopard = svg.match(/<path id="leopard" d="([^"]+)"/);
  const croppedLeopard = mark.match(/<path d="([^"]+)"/);
  assert.ok(fullLeopard && croppedLeopard, "both brand variants must contain the leopard path");
  assert.equal(croppedLeopard[1], fullLeopard[1], "favicon must not redraw or distort the mascot");
});

test("the social image is a 1280 by 640 PNG suitable for the page metadata", () => {
  const image = readFileSync(join(root, "public/tala-social.png"));
  assert.equal(image.toString("hex", 0, 8), "89504e470d0a1a0a");
  assert.equal(image.readUInt32BE(16), 1280);
  assert.equal(image.readUInt32BE(20), 640);
  assert.match(read("index.html"), /tala-social\.png/);
});

test("the site has no retired public login or registry links", () => {
  const sources = [
    "src/App.vue",
    "src/pages/LandingPage.vue",
    "src/content/landing.ts",
    "src/pages/OverviewPage.vue",
    "src/pages/GettingStartedPage.vue",
  ];
  for (const source of sources) {
    const content = read(source);
    assert.doesNotMatch(content, /(?:xsharp-lang\.xyz|tala\.run)\/(?:login|register|repo)/);
    assert.doesNotMatch(content, /xsharp-lang\.xyz/);
    assert.doesNotMatch(content, /AlfaPC11/);
    assert.doesNotMatch(content, /docs\/ARCHITECTURE\.md/);
  }
});

test("every linked repository path used by the guides exists when compiler source is available", (context) => {
  const compilerRoot = join(websiteRoot, "..");
  if (!existsSync(join(compilerRoot, "Spec"))) {
    context.skip("standalone website checkout has no sibling compiler repository");
    return;
  }
  for (const relativePath of [
    "Spec",
    "Documents/BUILDING.md",
    "Documents/CLI.md",
    "Examples/HelloWorld/HelloWorld.tala",
  ]) {
    assert.ok(existsSync(join(compilerRoot, relativePath)), `${relativePath} is missing`);
  }
});

test("the site examples are copied from the compiler repository when available", async (context) => {
  const compilerRoot = join(websiteRoot, "..");
  if (!existsSync(join(compilerRoot, "Examples/HelloWorld/HelloWorld.tala"))) {
    context.skip("standalone website checkout has no sibling compiler repository");
    return;
  }
  const program = (name) => {
    const source = readFileSync(join(compilerRoot, `Examples/${name}/${name}.tala`), "utf8");
    return source.slice(source.indexOf("namespace ")).replaceAll("\r\n", "\n").trim();
  };
  const samples = await import("../src/content/landing.ts");
  assert.equal(samples.helloWorld, program("HelloWorld"), "the HelloWorld tab diverged");
  assert.equal(samples.primeNumbers, program("PrimeNumbers"), "the PrimeNumbers tab diverged");
  assert.equal(samples.gcd, program("GCD"), "the GCD sample diverged");
  const guide = read("src/pages/GettingStartedPage.vue").replaceAll("\r\n", "\n");
  assert.ok(guide.includes(program("HelloWorld")), "the guide diverged from the compiler example");
});

test("maintenance mode preserves the legacy redirect without opening the language site", () => {
  const maintenance = readFileSync(join(websiteRoot, "ops/nginx/tala-maintenance.conf"), "utf8");
  const live = readFileSync(join(websiteRoot, "ops/nginx/tala.conf"), "utf8");
  assert.match(
    maintenance,
    /server_name tala\.run www\.tala\.run talac\.tala\.run tazel\.tala\.run;/,
  );
  assert.match(maintenance, /location \/ \{ return 503; \}/);
  assert.match(maintenance, /server_name viget\.xsharp-lang\.xyz/);
  assert.match(maintenance, /return 308 https:\/\/viget\.progmasoft\.com\$request_uri/);
  assert.match(live, /server_name viget\.xsharp-lang\.xyz/);
  assert.match(live, /location = \/api\/v1\/site/);
  assert.match(live, /location \^~ \/api\/ \{\s*return 404;/);
});

test("only the routes of the application get the app shell; other paths answer 404", () => {
  const live = readFileSync(join(websiteRoot, "ops/nginx/tala.conf"), "utf8");
  assert.doesNotMatch(live, /try_files \$uri \$uri\/ \/index\.html;/);
  assert.equal(live.match(/try_files \$uri \$uri\/ @application;/g)?.length, 5);
  assert.equal(live.match(/error_page 404 \/index\.html;/g)?.length, 5);
  const patterns = [...live.matchAll(/if \(\$uri !~ "([^"]+)"\)/g)].map((match) => match[1]);
  assert.equal(new Set(patterns).size, 1, "every site must accept the same routes");
  const route = new RegExp(patterns[0]);
  const pages = [
    "public/sitemap.xml",
    "public/hosts/talac/sitemap.xml",
    "public/hosts/tazel/sitemap.xml",
    "public/hosts/talafmt/sitemap.xml",
    "public/hosts/talalint/sitemap.xml",
  ]
    .flatMap(locations)
    .map((url) => new URL(url).pathname);
  assert.ok(pages.length > 3);
  for (const path of pages) {
    assert.match(path, route, `${path} is in a sitemap but would answer 404`);
  }
  for (const path of [
    "/wp-login.php",
    "/wp-admin/install.php",
    "/docs",
    "/docs/",
    "/docs/a/b",
    "/.env",
    "/a",
  ]) {
    assert.doesNotMatch(path, route, `${path} would be served as a page`);
  }
});

test("the former domain lands on the same path of tala.run and tala.run has no ViGet host", () => {
  const live = readFileSync(join(websiteRoot, "ops/nginx/tala.conf"), "utf8");
  const forum = readFileSync(join(websiteRoot, "ops/nginx/forum.conf"), "utf8");
  assert.match(
    live,
    /server_name xsharp-lang\.xyz www\.xsharp-lang\.xyz docs\.xsharp-lang\.xyz spec\.xsharp-lang\.xyz;[^}]*return 308 https:\/\/tala\.run\$request_uri;/s,
  );
  assert.match(
    live,
    /server_name www\.tala\.run;[^}]*return 308 https:\/\/tala\.run\$request_uri;/s,
  );
  assert.match(
    forum,
    /server_name forum\.xsharp-lang\.xyz;[^}]*return 308 https:\/\/forum\.tala\.run\$request_uri;/s,
  );
  assert.doesNotMatch(live + forum, /viget\.tala\.run/);
  for (const host of ["talac", "tazel", "talafmt", "talalint"]) {
    assert.match(live, new RegExp(`try_files /hosts/${host}/robots\\.txt =404;`));
    assert.match(live, new RegExp(`try_files /hosts/${host}/sitemap\\.xml =404;`));
  }
});

test("workflow actions are immutable and OIDC is isolated from build jobs", () => {
  const ci = readFileSync(join(websiteRoot, ".github/workflows/ci.yml"), "utf8");
  const codeql = readFileSync(join(websiteRoot, ".github/workflows/codeql.yml"), "utf8");

  for (const workflow of [ci, codeql]) {
    const actions = workflow
      .split("\n")
      .map((line) => {
        const marker = line.indexOf("uses:");
        return marker < 0
          ? ""
          : line
              .slice(marker + "uses:".length)
              .split("#", 1)[0]
              .trim();
      })
      .filter((action) => action.length > 0);

    assert.ok(actions.length > 0, "workflow must declare its action dependencies");
    for (const action of actions) {
      const separator = action.lastIndexOf("@");
      assert.ok(separator > 0, `${action} must have an immutable commit reference`);
      const commit = action.slice(separator + 1);
      assert.equal(commit.length, 40, `${action} must use a full commit SHA`);
      assert.ok(
        [...commit].every((character) => "0123456789abcdef".includes(character)),
        `${action} has a malformed commit SHA`,
      );
    }
  }

  const job = (name, nextName) => {
    const start = ci.indexOf(`  ${name}:`);
    const end = ci.indexOf(`  ${nextName}:`, start + 1);
    assert.ok(start >= 0 && end > start, `expected CI job boundary ${name} → ${nextName}`);
    return ci.slice(start, end);
  };

  assert.ok(!job("frontend", "backend").includes("id-token: write"));
  assert.ok(!job("backend", "frontend-coverage").includes("id-token: write"));
  assert.ok(job("frontend-coverage", "backend-coverage").includes("id-token: write"));
  assert.ok(job("backend-coverage", "dependency-review").includes("id-token: write"));
});

test("the production API binds to loopback and matches its Nginx proxy port", () => {
  const application = readFileSync(
    join(websiteRoot, "backend/Progmasoft.Tala.Website.Api/Program.cs"),
    "utf8",
  );
  const service = readFileSync(join(websiteRoot, "ops/systemd/tala-site-api.service"), "utf8");
  const nginx = readFileSync(join(websiteRoot, "ops/nginx/tala.conf"), "utf8");

  assert.ok(application.includes('UseUrls($"http://127.0.0.1:{port}")'));
  assert.ok(application.includes("public const int DefaultPort = 5086;"));
  assert.ok(service.includes("Environment=PORT=5086"));
  assert.ok(service.includes("ExecStart=/usr/bin/dotnet "));
  assert.doesNotMatch(service, /java/i);
  assert.ok(nginx.includes("proxy_pass http://127.0.0.1:5086/api/v1/site;"));
});

test("the forum assets are allowed by its restrictive Nginx policy", () => {
  const config = readFileSync(join(websiteRoot, "ops/nginx/forum.conf"), "utf8");
  const forum = readFileSync(join(websiteRoot, "forum/index.html"), "utf8");
  for (const filename of ["site.css", "site.js", "tala-mark.svg"]) {
    assert.ok(existsSync(join(websiteRoot, "forum", filename)), `${filename} missing`);
    assert.ok(config.includes(`location = /${filename}`), `${filename} blocked by Nginx`);
    assert.ok(forum.includes(`/${filename}`), `${filename} not referenced by forum`);
  }
});

test("navigation, router, and sitemap agree on the public language pages", () => {
  const router = read("src/main.ts");
  const navigation = read("src/App.vue");
  const sitemap = read("public/sitemap.xml");
  for (const route of ["/docs/overview", "/docs/getting-started"]) {
    assert.ok(router.includes(`path: "${route}"`), `${route} is absent from the router`);
    assert.ok(navigation.includes(`to="${route}"`), `${route} is absent from navigation`);
    assert.ok(sitemap.includes(`tala.run${route}`), `${route} is absent from sitemap`);
  }
  assert.ok(navigation.includes(`to="/docs/syntax"`));
  assert.match(router, /path: "\/"/);
  assert.match(navigation, /<RouterLink[^>]*to="\/"/);
});

test("every public link in the page shell uses the correct project identity", () => {
  const shell = read("src/App.vue");
  const home = read("src/content/landing.ts");
  const overview = read("src/pages/OverviewPage.vue");
  const guide = read("src/pages/GettingStartedPage.vue");
  const combined = [shell, home, overview, guide].join("\n");
  const parsedUrls = (combined.match(/https:\/\/[^"'\s<>]+/g) ?? []).map((value) => new URL(value));
  assert.ok(read("src/site.ts").includes('forumUrl = "https://forum.tala.run/"'));
  assert.ok(parsedUrls.every((url) => !url.hostname.endsWith("xsharp-lang.xyz")));
  const githubUrls = parsedUrls.filter((url) => url.hostname === "github.com");
  const repositories = githubUrls.map((url) =>
    url.pathname
      .split("/")
      .filter(Boolean)
      .slice(0, 2)
      .map((segment) => segment.toLowerCase())
      .join("/"),
  );
  assert.ok(repositories.includes("progmasoft/tala"));
  assert.ok(
    githubUrls.every(
      (url) => !["alfapc11", "leitwolf11"].includes(url.pathname.split("/")[1]?.toLowerCase()),
    ),
  );
  assert.doesNotMatch(combined, /support@xsharp-lang\.xyz/);
  assert.doesNotMatch(combined, /repo\.xsharp-lanng\.xyz/);
});

test("language and theme controls remain labeled and keyboard focus is visible", () => {
  const shell = read("src/App.vue");
  const styles = read("src/styles/site.css");
  assert.match(shell, /<select v-model="locale" :aria-label="copy\.language"/);
  assert.match(shell, /<option v-for="supported in supportedLocales"/);
  assert.match(shell, /class="icon-button"[^>]*:aria-label="copy\.theme"/);
  assert.match(styles, /:focus-visible\s*\{/);
  assert.match(styles, /prefers-reduced-motion: reduce/);
  assert.match(styles, /\.language-control select,\s*\.icon-button\s*\{[^}]*border-radius: 9px/s);
});

test("the former domain is left to the server: the page carries no notice about it", () => {
  const app = read("src/App.vue");
  assert.doesNotMatch(app, /domainNotice|domain-notice|onDepartingDomain/);
  assert.doesNotMatch(read("src/styles/site.css"), /\.domain-notice/);
});

test("every tala.run host behaves as if HSTS were preloaded for its top-level domain", () => {
  for (const file of ["tala.conf", "forum.conf", "tala-maintenance.conf"]) {
    const config = readFileSync(join(websiteRoot, "ops/nginx", file), "utf8");
    const blocks = config.split(/\nserver \{/).slice(1);
    for (const block of blocks) {
      const names = block.match(/server_name ([^;]+);/)?.[1] ?? "";
      if (!/\btala\.run\b/.test(names)) continue;
      if (/listen 443/.test(block)) {
        assert.match(
          block,
          /add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;/,
          `${file}: ${names} must send the preload header`,
        );
      } else {
        // Plain HTTP answers only the ACME challenge and otherwise sends the visitor to HTTPS.
        assert.match(block, /return 308 https:\/\//, `${file}: ${names} must redirect to HTTPS`);
      }
    }
  }
});