chore: harden release checks and package coverage
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.13",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
captchaContext,
|
||||
captchaResultResponse,
|
||||
captchaTokenFrom,
|
||||
verifyCaptchaOrThrow,
|
||||
} from "../src/helpers.ts";
|
||||
|
||||
describe("CAPTCHA helper boundaries", () => {
|
||||
test("extracts JSON request fallbacks without consuming the caller's request", async () => {
|
||||
const request = new Request("https://example.test/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ captchaToken: 12345 }),
|
||||
});
|
||||
expect(await captchaTokenFrom(request)).toBe("12345");
|
||||
expect(await request.json()).toEqual({ captchaToken: 12345 });
|
||||
});
|
||||
|
||||
test("turns verification failures into stable errors and no-store responses", async () => {
|
||||
const provider = {
|
||||
name: "custom" as const,
|
||||
client: { responseField: "captchaToken" },
|
||||
verify: async () => ({
|
||||
success: false as const,
|
||||
code: "expired",
|
||||
message: "Try again",
|
||||
provider: "custom" as const,
|
||||
action: "login",
|
||||
}),
|
||||
};
|
||||
await expect(
|
||||
verifyCaptchaOrThrow(provider, { providerToken: "x", action: "login" }),
|
||||
).rejects.toThrow("WRN-CAPTCHA-EXPIRED: Try again");
|
||||
const response = captchaResultResponse({
|
||||
success: false,
|
||||
code: "invalid",
|
||||
provider: "custom",
|
||||
action: "login",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(
|
||||
captchaContext({
|
||||
locals: { captcha: { success: true, provider: "custom", action: "login" } },
|
||||
} as never),
|
||||
).toEqual({ success: true, provider: "custom", action: "login" });
|
||||
expect(captchaContext({ locals: { captcha: "forged" } } as never)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.54",
|
||||
"version": "0.8.56",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -25,6 +25,7 @@
|
||||
"@wrnexus/uploader": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/queue": "workspace:*",
|
||||
"@wrnexus/react": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*",
|
||||
"@wrnexus/observability": "workspace:*",
|
||||
|
||||
@@ -508,9 +508,7 @@ function validateConfiguredImports(_source: string, ast: PageAst, file: string):
|
||||
]);
|
||||
const missing = [...usedComponents].filter(
|
||||
(name) =>
|
||||
!compilerBuiltins.has(name) &&
|
||||
!options.globalComponents.has(name) &&
|
||||
!imported.has(name),
|
||||
!compilerBuiltins.has(name) && !options.globalComponents.has(name) && !imported.has(name),
|
||||
);
|
||||
if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout);
|
||||
if (!missing.length) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.8.13",
|
||||
"version": "0.8.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createServerIssue, issueFromError } from "../../src/server/issues.ts";
|
||||
|
||||
test("server issues have stable fingerprints and retain actionable error context", () => {
|
||||
const input = {
|
||||
ruleId: "server/query",
|
||||
category: "server" as const,
|
||||
severity: "warning" as const,
|
||||
title: "Slow query",
|
||||
message: "Query exceeded budget",
|
||||
pathname: "/orders",
|
||||
source: { file: "app/api/orders.ts", line: 12, column: 4 },
|
||||
};
|
||||
const first = createServerIssue(input);
|
||||
const second = createServerIssue(input);
|
||||
expect(first.id).not.toBe(second.id);
|
||||
expect(first.fingerprint).toBe(second.fingerprint);
|
||||
expect(first.metadata?.pathname).toBe("/orders");
|
||||
|
||||
const error = issueFromError(new Error("database offline"), { pathname: "/orders" });
|
||||
expect(error.severity).toBe("error");
|
||||
expect(error.message).toBe("database offline");
|
||||
expect(error.metadata?.stack).toContain("database offline");
|
||||
expect(error.recommendation).toContain("stack trace");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/store",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { StoreContainer, defineStore } from "../src/index.ts";
|
||||
|
||||
test("memory persistence includes only approved fields and migrates old state", async () => {
|
||||
const original = defineStore({
|
||||
name: "preferences-migration",
|
||||
kind: "global" as const,
|
||||
createSharedState: () => ({ theme: "dark", secret: "initial" }),
|
||||
persist: { storage: "memory" as const, version: 1, include: ["theme"] },
|
||||
actions: {
|
||||
setTheme: {
|
||||
runtime: "client" as const,
|
||||
handler: ({ state }: any, theme: string) => void (state.theme = theme),
|
||||
},
|
||||
},
|
||||
});
|
||||
const first = await new StoreContainer({ runtime: "client" }).use(original);
|
||||
await first.actions.setTheme("light");
|
||||
|
||||
const migrated = defineStore({
|
||||
...original,
|
||||
persist: {
|
||||
storage: "memory" as const,
|
||||
version: 2,
|
||||
include: ["theme"],
|
||||
migrate: (state: any) => ({ ...state, theme: `${state.theme}-v2` }),
|
||||
},
|
||||
});
|
||||
const second = await new StoreContainer({ runtime: "client" }).use(migrated);
|
||||
expect(second.state.theme).toBe("light-v2");
|
||||
expect(second.state.secret).toBe("initial");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.13",
|
||||
"version": "0.8.15",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { hasRequestSection, parseApiEntries, parseApiSections } from "../src/api-sections.ts";
|
||||
|
||||
test("api section scanning ignores keywords in literals, comments, and nested objects", () => {
|
||||
const source = `
|
||||
// request { body { forged: string } }
|
||||
response {
|
||||
return { label: "request { not a section }", nested: { body: true } }
|
||||
}
|
||||
`;
|
||||
expect(hasRequestSection(source)).toBe(false);
|
||||
expect(parseApiSections(source)?.response).toContain("nested");
|
||||
});
|
||||
|
||||
test("api entries normalize methods and reject malformed request fields", () => {
|
||||
expect(
|
||||
parseApiEntries(`lookup get /users/:id {
|
||||
response { return data }
|
||||
}`)[0],
|
||||
).toMatchObject({
|
||||
name: "lookup",
|
||||
method: "GET",
|
||||
path: "/users/:id",
|
||||
});
|
||||
expect(() =>
|
||||
parseApiSections("request { body { missingType } } response { return data }"),
|
||||
).toThrow('Expected "name: type"');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.13",
|
||||
"version": "0.8.15",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -139,7 +139,7 @@ function functionDeclaration(fn: RuntimeFunctionDecl): string {
|
||||
const params = fn.parameters
|
||||
.map(
|
||||
(param) =>
|
||||
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : param.name === "event" ? ": CustomEvent<any> & { target: HTMLElement }" : ": any"}${param.default ? ` = ${param.default}` : ""}`,
|
||||
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : param.name === "event" ? ": Event & { target: HTMLElement }" : ": any"}${param.default ? ` = ${param.default}` : ""}`,
|
||||
)
|
||||
.join(", ");
|
||||
// An omitted WRN parameter type follows JavaScript semantics. Emitting it as
|
||||
@@ -214,6 +214,31 @@ function safeBindingName(name: string): boolean {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(name) && !RESERVED_BINDING_NAMES.has(name);
|
||||
}
|
||||
|
||||
function safeViewExpression(expression: string, reservedProps: ReadonlySet<string>): string {
|
||||
if (!reservedProps.size) return expression;
|
||||
const scanner = ts.createScanner(
|
||||
ts.ScriptTarget.Latest,
|
||||
false,
|
||||
ts.LanguageVariant.Standard,
|
||||
expression,
|
||||
);
|
||||
let result = "";
|
||||
let copied = 0;
|
||||
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
||||
const start = scanner.getTokenPos();
|
||||
const end = scanner.getTextPos();
|
||||
const text = scanner.getTokenText();
|
||||
if (!reservedProps.has(text)) continue;
|
||||
const previous = expression.slice(0, start).trimEnd().at(-1);
|
||||
const following = expression.slice(end).trimStart().at(0);
|
||||
// Property access and object-literal keys are already valid TypeScript.
|
||||
if (previous === "." || following === ":") continue;
|
||||
result += expression.slice(copied, start) + `props.${text}`;
|
||||
copied = end;
|
||||
}
|
||||
return result + expression.slice(copied);
|
||||
}
|
||||
|
||||
export function virtualTypeScriptModule(
|
||||
source: string,
|
||||
filePath = "component.wrn",
|
||||
@@ -348,6 +373,9 @@ export function virtualTypeScriptModule(
|
||||
// misspelled state/function name cannot silently become `undefined` at
|
||||
// runtime. Each/handler locals mirror the browser evaluator's bindings.
|
||||
let bindingIndex = 0;
|
||||
const reservedProps = new Set(
|
||||
ast.props.map((prop) => prop.name).filter((name) => !safeBindingName(name)),
|
||||
);
|
||||
const appendViewBindings = (nodes: ViewNode[], locals: Set<string>): void => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element") {
|
||||
@@ -359,10 +387,15 @@ export function virtualTypeScriptModule(
|
||||
if (!expression.trim()) continue;
|
||||
const declarations = [
|
||||
...[...locals].map((name) => `declare const ${name}: any;`),
|
||||
...(attr.event ? ["declare const payload: any;", "declare const event: Event;"] : []),
|
||||
...(attr.event
|
||||
? [
|
||||
"declare const payload: any;",
|
||||
"declare const event: Event & { target: HTMLElement };",
|
||||
]
|
||||
: []),
|
||||
].join(" ");
|
||||
append(
|
||||
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${expression}); }`,
|
||||
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${safeViewExpression(expression, reservedProps)}); }`,
|
||||
attr.value,
|
||||
);
|
||||
}
|
||||
@@ -372,13 +405,16 @@ export function virtualTypeScriptModule(
|
||||
for (const branch of node.branches) {
|
||||
if (branch.cond)
|
||||
append(
|
||||
`namespace __wrn_view_${bindingIndex++} { void (${branch.cond}); }`,
|
||||
`namespace __wrn_view_${bindingIndex++} { void (${safeViewExpression(branch.cond, reservedProps)}); }`,
|
||||
branch.cond,
|
||||
);
|
||||
appendViewBindings(branch.body, locals);
|
||||
}
|
||||
} else if (node.type === "each") {
|
||||
append(`namespace __wrn_view_${bindingIndex++} { void (${node.list}); }`, node.list);
|
||||
append(
|
||||
`namespace __wrn_view_${bindingIndex++} { void (${safeViewExpression(node.list, reservedProps)}); }`,
|
||||
node.list,
|
||||
);
|
||||
const nested = new Set(locals);
|
||||
nested.add(node.item);
|
||||
if (node.index) nested.add(node.index);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { checkWrnSource } from "../src/index.ts";
|
||||
@@ -82,6 +82,23 @@ page Home {
|
||||
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-PROP-TYPE")).toBe(false);
|
||||
expect(diagnostics.some((item) => item.code === "WRN-TYPE-1389")).toBe(false);
|
||||
expect(diagnostics.some((item) => item.code === "WRN-TYPE-1005")).toBe(false);
|
||||
expect(
|
||||
checkWrnSource(readFileSync(componentPath, "utf8"), { appRoot: root, filePath: componentPath }),
|
||||
).toEqual([]);
|
||||
}, 15_000);
|
||||
|
||||
test("implicit DOM events satisfy untyped event handler parameters", () => {
|
||||
const root = fixture();
|
||||
const filePath = join(root, "app", "components", "Submit.wrn");
|
||||
const diagnostics = checkWrnSource(
|
||||
`component Submit {
|
||||
outputs { submit(payload: { event: Event }) }
|
||||
functions { client function submitForm(event) { output.submit({ event }) } }
|
||||
view { <form @submit="submitForm(event)"></form> }
|
||||
}`,
|
||||
{ appRoot: root, filePath },
|
||||
);
|
||||
expect(diagnostics).toEqual([]);
|
||||
}, 15_000);
|
||||
|
||||
test("does not treat UI component imports as JavaScript exports", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
UploadPolicyError,
|
||||
createSignedFileToken,
|
||||
enforceUploadPolicy,
|
||||
inspectUpload,
|
||||
sniffContentType,
|
||||
verifySignedFileToken,
|
||||
} from "../src/security.ts";
|
||||
|
||||
describe("upload policy hardening", () => {
|
||||
test("enforces size, type, filename, and checksum independently", async () => {
|
||||
const inspection = await inspectUpload("report.pdf", new Uint8Array([0x25, 0x50, 0x44, 0x46]));
|
||||
const cases: Array<[Parameters<typeof enforceUploadPolicy>[1], string | undefined, string]> = [
|
||||
[{ maxBytes: 3 }, undefined, "UPLOAD_TOO_LARGE"],
|
||||
[{ accept: ["image/png"] }, undefined, "UPLOAD_TYPE_REJECTED"],
|
||||
[{ filenamePattern: /^invoice-/ }, undefined, "UPLOAD_FILENAME_REJECTED"],
|
||||
[{ requireChecksum: true }, undefined, "UPLOAD_CHECKSUM_REQUIRED"],
|
||||
[{}, "0".repeat(64), "UPLOAD_CHECKSUM_MISMATCH"],
|
||||
];
|
||||
for (const [policy, checksum, code] of cases) {
|
||||
try {
|
||||
enforceUploadPolicy(inspection, policy, checksum);
|
||||
throw new Error("policy unexpectedly accepted upload");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UploadPolicyError);
|
||||
expect((error as UploadPolicyError).code).toBe(code);
|
||||
}
|
||||
}
|
||||
enforceUploadPolicy(
|
||||
inspection,
|
||||
{ maxBytes: 4, accept: ["application/pdf"] },
|
||||
inspection.sha256,
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes bounded signatures and rejects malformed signed-token inputs", async () => {
|
||||
expect(sniffContentType(new Uint8Array([0xff, 0xd8, 0xff]))).toBe("image/jpeg");
|
||||
expect(sniffContentType(new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe("application/zip");
|
||||
expect(sniffContentType(new Uint8Array([1, 2, 3]))).toBeNull();
|
||||
await expect(
|
||||
createSignedFileToken({ store: "", key: "x", expiresAt: 1 }, "long-enough-secret"),
|
||||
).rejects.toThrow("store");
|
||||
expect(await verifySignedFileToken("not-a-token", "long-enough-secret")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user