@sudobility/testomniac_runner 0.0.128
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/.dockerignore +75 -0
- package/.env.example +67 -0
- package/.github/workflows/ci-cd.yml +30 -0
- package/.prettierignore +62 -0
- package/.prettierrc +11 -0
- package/.vscode/settings.json +29 -0
- package/CLAUDE.md +170 -0
- package/Dockerfile +76 -0
- package/README.md +22 -0
- package/bun.lock +707 -0
- package/docs/superpowers/specs/2026-04-20-smarter-scanner-navigation-design.md +121 -0
- package/eslint.config.js +80 -0
- package/package.json +55 -0
- package/plans/DATA.md +703 -0
- package/plans/POLLING.md +569 -0
- package/plans/RUNNER.md +288 -0
- package/src/adapters/PuppeteerAdapter.ts +394 -0
- package/src/auth/credential-manager.ts +17 -0
- package/src/auth/form-identifier.test.ts +136 -0
- package/src/auth/form-identifier.ts +54 -0
- package/src/auth/login-executor.ts +112 -0
- package/src/auth/password-detector.test.ts +61 -0
- package/src/auth/password-detector.ts +119 -0
- package/src/auth/signic-registrar.ts +186 -0
- package/src/browser/chromium.ts +35 -0
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +35 -0
- package/src/email/deep-link.test.ts +17 -0
- package/src/email/deep-link.ts +23 -0
- package/src/email/sender.ts +35 -0
- package/src/email/templates.ts +34 -0
- package/src/index.test.ts +17 -0
- package/src/index.ts +110 -0
- package/src/orchestrator.ts +220 -0
- package/src/plugins/content/ai-checks.ts +115 -0
- package/src/plugins/content/checks.test.ts +49 -0
- package/src/plugins/content/checks.ts +141 -0
- package/src/plugins/content/index.ts +73 -0
- package/src/plugins/registry.test.ts +49 -0
- package/src/plugins/registry.ts +21 -0
- package/src/plugins/security/header-checks.ts +56 -0
- package/src/plugins/security/html-checks.ts +93 -0
- package/src/plugins/security/index.ts +58 -0
- package/src/plugins/security/network-checks.test.ts +74 -0
- package/src/plugins/security/network-checks.ts +136 -0
- package/src/plugins/seo/checks.test.ts +70 -0
- package/src/plugins/seo/checks.ts +173 -0
- package/src/plugins/seo/index.ts +85 -0
- package/src/plugins/types.ts +43 -0
- package/src/plugins/ui-consistency/comparator.test.ts +108 -0
- package/src/plugins/ui-consistency/comparator.ts +58 -0
- package/src/plugins/ui-consistency/index.ts +36 -0
- package/src/plugins/ui-consistency/style-extractor.ts +79 -0
- package/src/runner/executor.test.ts +37 -0
- package/src/runner/executor.ts +167 -0
- package/src/runner/reporter.ts +19 -0
- package/src/runner/worker-pool.ts +106 -0
- package/src/runner-manager.ts +163 -0
- package/src/scanner/email-checker.ts +106 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { identifyFormType } from "./form-identifier";
|
|
3
|
+
import type { FormInfo } from "@sudobility/testomniac_types";
|
|
4
|
+
|
|
5
|
+
describe("form-identifier", () => {
|
|
6
|
+
it("identifies login form by field pattern", () => {
|
|
7
|
+
const form: FormInfo = {
|
|
8
|
+
selector: "form",
|
|
9
|
+
action: "/login",
|
|
10
|
+
method: "POST",
|
|
11
|
+
fieldCount: 2,
|
|
12
|
+
fields: [
|
|
13
|
+
{
|
|
14
|
+
selector: "#email",
|
|
15
|
+
name: "email",
|
|
16
|
+
type: "email",
|
|
17
|
+
label: "Email",
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
selector: "#pass",
|
|
22
|
+
name: "password",
|
|
23
|
+
type: "password",
|
|
24
|
+
label: "Password",
|
|
25
|
+
required: true,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
submitSelector: 'button[type="submit"]',
|
|
29
|
+
};
|
|
30
|
+
expect(identifyFormType(form, "https://example.com/login")).toBe("login");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("identifies signup form by field pattern", () => {
|
|
34
|
+
const form: FormInfo = {
|
|
35
|
+
selector: "form",
|
|
36
|
+
action: "/register",
|
|
37
|
+
method: "POST",
|
|
38
|
+
fieldCount: 4,
|
|
39
|
+
fields: [
|
|
40
|
+
{
|
|
41
|
+
selector: "#name",
|
|
42
|
+
name: "name",
|
|
43
|
+
type: "text",
|
|
44
|
+
label: "Name",
|
|
45
|
+
required: true,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
selector: "#email",
|
|
49
|
+
name: "email",
|
|
50
|
+
type: "email",
|
|
51
|
+
label: "Email",
|
|
52
|
+
required: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
selector: "#pass",
|
|
56
|
+
name: "password",
|
|
57
|
+
type: "password",
|
|
58
|
+
label: "Password",
|
|
59
|
+
required: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
selector: "#confirm",
|
|
63
|
+
name: "confirm",
|
|
64
|
+
type: "password",
|
|
65
|
+
label: "Confirm Password",
|
|
66
|
+
required: true,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
submitSelector: 'button[type="submit"]',
|
|
70
|
+
};
|
|
71
|
+
expect(identifyFormType(form, "https://example.com/register")).toBe(
|
|
72
|
+
"signup"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("identifies login form by URL", () => {
|
|
77
|
+
const form: FormInfo = {
|
|
78
|
+
selector: "form",
|
|
79
|
+
action: "",
|
|
80
|
+
method: "POST",
|
|
81
|
+
fieldCount: 2,
|
|
82
|
+
fields: [
|
|
83
|
+
{
|
|
84
|
+
selector: "#user",
|
|
85
|
+
name: "user",
|
|
86
|
+
type: "text",
|
|
87
|
+
label: "User",
|
|
88
|
+
required: true,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
selector: "#pass",
|
|
92
|
+
name: "pass",
|
|
93
|
+
type: "password",
|
|
94
|
+
label: "Pass",
|
|
95
|
+
required: true,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
submitSelector: "button",
|
|
99
|
+
};
|
|
100
|
+
expect(identifyFormType(form, "https://example.com/signin")).toBe("login");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("identifies other forms", () => {
|
|
104
|
+
const form: FormInfo = {
|
|
105
|
+
selector: "form",
|
|
106
|
+
action: "/contact",
|
|
107
|
+
method: "POST",
|
|
108
|
+
fieldCount: 3,
|
|
109
|
+
fields: [
|
|
110
|
+
{
|
|
111
|
+
selector: "#name",
|
|
112
|
+
name: "name",
|
|
113
|
+
type: "text",
|
|
114
|
+
label: "Name",
|
|
115
|
+
required: true,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
selector: "#email",
|
|
119
|
+
name: "email",
|
|
120
|
+
type: "email",
|
|
121
|
+
label: "Email",
|
|
122
|
+
required: true,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
selector: "#msg",
|
|
126
|
+
name: "message",
|
|
127
|
+
type: "textarea",
|
|
128
|
+
label: "Message",
|
|
129
|
+
required: true,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
submitSelector: "button",
|
|
133
|
+
};
|
|
134
|
+
expect(identifyFormType(form, "https://example.com/contact")).toBe("other");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_URL_PATTERNS,
|
|
3
|
+
SIGNUP_URL_PATTERNS,
|
|
4
|
+
} from "@sudobility/testomniac_runner_service";
|
|
5
|
+
import type { FormInfo } from "@sudobility/testomniac_types";
|
|
6
|
+
|
|
7
|
+
export type FormType = "login" | "signup" | "other";
|
|
8
|
+
|
|
9
|
+
export function identifyFormType(form: FormInfo, pageUrl: string): FormType {
|
|
10
|
+
const url = pageUrl.toLowerCase();
|
|
11
|
+
const fields = form.fields;
|
|
12
|
+
|
|
13
|
+
const isLoginUrl = AUTH_URL_PATTERNS.some(p => url.includes(p));
|
|
14
|
+
const isSignupUrl = SIGNUP_URL_PATTERNS.some(p => url.includes(p));
|
|
15
|
+
|
|
16
|
+
const hasPassword = fields.some(f => f.type === "password");
|
|
17
|
+
const hasEmail = fields.some(
|
|
18
|
+
f =>
|
|
19
|
+
f.type === "email" ||
|
|
20
|
+
f.name === "email" ||
|
|
21
|
+
f.label.toLowerCase().includes("email")
|
|
22
|
+
);
|
|
23
|
+
const hasUsername = fields.some(
|
|
24
|
+
f => f.name === "username" || f.label.toLowerCase().includes("username")
|
|
25
|
+
);
|
|
26
|
+
const hasName = fields.some(f => {
|
|
27
|
+
const n = (f.name + " " + f.label).toLowerCase();
|
|
28
|
+
return (
|
|
29
|
+
n.includes("name") && !n.includes("username") && !n.includes("email")
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
const hasMessage = fields.some(f => {
|
|
33
|
+
const n = (f.name + " " + f.label).toLowerCase();
|
|
34
|
+
return n.includes("message") || n.includes("subject");
|
|
35
|
+
});
|
|
36
|
+
const hasConfirmPassword =
|
|
37
|
+
fields.filter(f => f.type === "password").length >= 2;
|
|
38
|
+
|
|
39
|
+
if (hasMessage) return "other";
|
|
40
|
+
if (!hasPassword) return "other";
|
|
41
|
+
|
|
42
|
+
if (hasPassword && (hasEmail || hasUsername)) {
|
|
43
|
+
if (hasConfirmPassword || hasName || isSignupUrl) return "signup";
|
|
44
|
+
if (isLoginUrl) return "login";
|
|
45
|
+
if (fields.length <= 2) return "login";
|
|
46
|
+
if (hasName) return "signup";
|
|
47
|
+
return "login";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isSignupUrl) return "signup";
|
|
51
|
+
if (isLoginUrl) return "login";
|
|
52
|
+
|
|
53
|
+
return "other";
|
|
54
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Page } from "puppeteer-core";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
import type { Credentials, FormInfo } from "@sudobility/testomniac_types";
|
|
4
|
+
|
|
5
|
+
const logger = pino({ name: "login-executor" });
|
|
6
|
+
|
|
7
|
+
export async function executeLogin(
|
|
8
|
+
page: Page,
|
|
9
|
+
form: FormInfo,
|
|
10
|
+
credentials: Credentials
|
|
11
|
+
): Promise<boolean> {
|
|
12
|
+
logger.info({ url: page.url() }, "executing login");
|
|
13
|
+
|
|
14
|
+
const emailField = form.fields.find(
|
|
15
|
+
f =>
|
|
16
|
+
f.type === "email" ||
|
|
17
|
+
f.name === "email" ||
|
|
18
|
+
f.label.toLowerCase().includes("email") ||
|
|
19
|
+
f.name === "username" ||
|
|
20
|
+
f.label.toLowerCase().includes("username")
|
|
21
|
+
);
|
|
22
|
+
if (emailField) {
|
|
23
|
+
const value = credentials.email || credentials.username || "";
|
|
24
|
+
const el = await page.waitForSelector(emailField.selector, {
|
|
25
|
+
timeout: 5000,
|
|
26
|
+
});
|
|
27
|
+
if (el) {
|
|
28
|
+
await el.click({ clickCount: 3 });
|
|
29
|
+
await el.type(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const passwordField = form.fields.find(f => f.type === "password");
|
|
34
|
+
if (passwordField) {
|
|
35
|
+
const el = await page.waitForSelector(passwordField.selector, {
|
|
36
|
+
timeout: 5000,
|
|
37
|
+
});
|
|
38
|
+
if (el) {
|
|
39
|
+
await el.click({ clickCount: 3 });
|
|
40
|
+
await el.type(credentials.password);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (form.submitSelector) {
|
|
45
|
+
const btn = await page.waitForSelector(form.submitSelector, {
|
|
46
|
+
timeout: 5000,
|
|
47
|
+
});
|
|
48
|
+
if (btn) await btn.click();
|
|
49
|
+
} else {
|
|
50
|
+
await page.keyboard.press("Enter");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await page.waitForNavigation({
|
|
55
|
+
waitUntil: "networkidle0",
|
|
56
|
+
timeout: 10_000,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logger.debug({ err }, "navigation timeout after login submit — may be SPA");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (credentials.twoFactorCode) {
|
|
63
|
+
const tfaSelectors = [
|
|
64
|
+
'input[name*="code"]',
|
|
65
|
+
'input[name*="otp"]',
|
|
66
|
+
'input[name*="2fa"]',
|
|
67
|
+
'input[name*="verification"]',
|
|
68
|
+
'input[name*="authenticator"]',
|
|
69
|
+
'input[placeholder*="code"]',
|
|
70
|
+
'input[placeholder*="verification"]',
|
|
71
|
+
];
|
|
72
|
+
for (const sel of tfaSelectors) {
|
|
73
|
+
try {
|
|
74
|
+
const el = await page.waitForSelector(sel, {
|
|
75
|
+
timeout: 3000,
|
|
76
|
+
visible: true,
|
|
77
|
+
});
|
|
78
|
+
if (el) {
|
|
79
|
+
await el.type(credentials.twoFactorCode);
|
|
80
|
+
await page.keyboard.press("Enter");
|
|
81
|
+
try {
|
|
82
|
+
await page.waitForNavigation({
|
|
83
|
+
waitUntil: "networkidle0",
|
|
84
|
+
timeout: 10_000,
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.debug({ err }, "navigation timeout during 2FA submission");
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
logger.debug({ err, selector: sel }, "2FA field selector not found");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const currentUrl = page.url().toLowerCase();
|
|
99
|
+
const stillOnLogin = ["/login", "/signin", "/sign-in", "/auth"].some(p =>
|
|
100
|
+
currentUrl.includes(p)
|
|
101
|
+
);
|
|
102
|
+
if (stillOnLogin) {
|
|
103
|
+
logger.warn(
|
|
104
|
+
{ url: currentUrl },
|
|
105
|
+
"login may have failed — still on auth page"
|
|
106
|
+
);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
logger.info({ url: page.url() }, "login succeeded");
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
detectPasswordRequirements,
|
|
4
|
+
generatePasswordTestInteractions,
|
|
5
|
+
} from "./password-detector";
|
|
6
|
+
|
|
7
|
+
describe("password-detector", () => {
|
|
8
|
+
it("detects minimum length", () => {
|
|
9
|
+
const reqs = detectPasswordRequirements(
|
|
10
|
+
"Password must be at least 8 characters"
|
|
11
|
+
);
|
|
12
|
+
expect(reqs.minLength).toBe(8);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("detects uppercase requirement", () => {
|
|
16
|
+
const reqs = detectPasswordRequirements("Must contain an uppercase letter");
|
|
17
|
+
expect(reqs.requiresUppercase).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("detects multiple requirements", () => {
|
|
21
|
+
const text =
|
|
22
|
+
"Password must be at least 10 characters, contain uppercase, lowercase, a number, and a special character. No spaces allowed.";
|
|
23
|
+
const reqs = detectPasswordRequirements(text);
|
|
24
|
+
expect(reqs.minLength).toBe(10);
|
|
25
|
+
expect(reqs.requiresUppercase).toBe(true);
|
|
26
|
+
expect(reqs.requiresLowercase).toBe(true);
|
|
27
|
+
expect(reqs.requiresNumber).toBe(true);
|
|
28
|
+
expect(reqs.requiresSpecial).toBe(true);
|
|
29
|
+
expect(reqs.noSpaces).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns defaults when no requirements detected", () => {
|
|
33
|
+
const reqs = detectPasswordRequirements("Create your account");
|
|
34
|
+
expect(reqs.minLength).toBeUndefined();
|
|
35
|
+
expect(reqs.requiresUppercase).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("generates fail cases before pass case", () => {
|
|
39
|
+
const reqs = detectPasswordRequirements(
|
|
40
|
+
"At least 8 characters, must include uppercase and a number"
|
|
41
|
+
);
|
|
42
|
+
const cases = generatePasswordTestInteractions(reqs);
|
|
43
|
+
const failCases = cases.filter(c => c.shouldFail);
|
|
44
|
+
const passCases = cases.filter(c => !c.shouldFail);
|
|
45
|
+
expect(failCases.length).toBeGreaterThanOrEqual(2);
|
|
46
|
+
expect(passCases.length).toBe(1);
|
|
47
|
+
const firstPassIndex = cases.findIndex(c => !c.shouldFail);
|
|
48
|
+
const lastFailIndex =
|
|
49
|
+
cases.length - 1 - [...cases].reverse().findIndex(c => c.shouldFail);
|
|
50
|
+
expect(firstPassIndex).toBeGreaterThan(lastFailIndex);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("generates too-short password", () => {
|
|
54
|
+
const reqs = detectPasswordRequirements("At least 8 characters");
|
|
55
|
+
const cases = generatePasswordTestInteractions(reqs);
|
|
56
|
+
const tooShort = cases.find(c => c.description.includes("too short"));
|
|
57
|
+
expect(tooShort).toBeDefined();
|
|
58
|
+
expect(tooShort!.password.length).toBeLessThan(8);
|
|
59
|
+
expect(tooShort!.shouldFail).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface PasswordRequirements {
|
|
2
|
+
minLength?: number;
|
|
3
|
+
requiresUppercase: boolean;
|
|
4
|
+
requiresLowercase: boolean;
|
|
5
|
+
requiresNumber: boolean;
|
|
6
|
+
requiresSpecial: boolean;
|
|
7
|
+
noSpaces: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PasswordTestInteraction {
|
|
11
|
+
password: string;
|
|
12
|
+
description: string;
|
|
13
|
+
shouldFail: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function detectPasswordRequirements(
|
|
17
|
+
visibleText: string
|
|
18
|
+
): PasswordRequirements {
|
|
19
|
+
const lower = visibleText.toLowerCase();
|
|
20
|
+
const reqs: PasswordRequirements = {
|
|
21
|
+
requiresUppercase: false,
|
|
22
|
+
requiresLowercase: false,
|
|
23
|
+
requiresNumber: false,
|
|
24
|
+
requiresSpecial: false,
|
|
25
|
+
noSpaces: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const lengthMatch =
|
|
29
|
+
lower.match(/(?:at least|minimum|min\.?)\s*(\d+)\s*character/i) ||
|
|
30
|
+
lower.match(/(\d+)\+?\s*character/i);
|
|
31
|
+
if (lengthMatch) {
|
|
32
|
+
reqs.minLength = parseInt(lengthMatch[1]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (/uppercase|capital letter/i.test(lower)) reqs.requiresUppercase = true;
|
|
36
|
+
if (/lowercase/i.test(lower)) reqs.requiresLowercase = true;
|
|
37
|
+
if (
|
|
38
|
+
/number|digit|\d/i.test(lower) &&
|
|
39
|
+
/must|require|contain|include/i.test(lower)
|
|
40
|
+
)
|
|
41
|
+
reqs.requiresNumber = true;
|
|
42
|
+
if (
|
|
43
|
+
/special character|symbol|[!@#$%^&*]/i.test(lower) &&
|
|
44
|
+
/must|require|contain|include/i.test(lower)
|
|
45
|
+
)
|
|
46
|
+
reqs.requiresSpecial = true;
|
|
47
|
+
if (/no\s*spaces/i.test(lower)) reqs.noSpaces = true;
|
|
48
|
+
|
|
49
|
+
return reqs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function generatePasswordTestInteractions(
|
|
53
|
+
reqs: PasswordRequirements
|
|
54
|
+
): PasswordTestInteraction[] {
|
|
55
|
+
const cases: PasswordTestInteraction[] = [];
|
|
56
|
+
const goodLength = Math.max(reqs.minLength || 8, 8);
|
|
57
|
+
|
|
58
|
+
let validPassword = "Aa1!";
|
|
59
|
+
while (validPassword.length < goodLength) {
|
|
60
|
+
validPassword += "xY2@".charAt(validPassword.length % 4);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (reqs.minLength) {
|
|
64
|
+
const tooShort = validPassword.slice(0, reqs.minLength - 1);
|
|
65
|
+
cases.push({
|
|
66
|
+
password: tooShort,
|
|
67
|
+
description: `too short (${tooShort.length} chars, need ${reqs.minLength})`,
|
|
68
|
+
shouldFail: true,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (reqs.requiresUppercase) {
|
|
73
|
+
cases.push({
|
|
74
|
+
password: validPassword.toLowerCase(),
|
|
75
|
+
description: "no uppercase letter",
|
|
76
|
+
shouldFail: true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (reqs.requiresLowercase) {
|
|
81
|
+
cases.push({
|
|
82
|
+
password: validPassword.toUpperCase(),
|
|
83
|
+
description: "no lowercase letter",
|
|
84
|
+
shouldFail: true,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (reqs.requiresNumber) {
|
|
89
|
+
cases.push({
|
|
90
|
+
password: validPassword.replace(/\d/g, "a"),
|
|
91
|
+
description: "no number",
|
|
92
|
+
shouldFail: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (reqs.requiresSpecial) {
|
|
97
|
+
cases.push({
|
|
98
|
+
password: validPassword.replace(/[^a-zA-Z0-9]/g, "a"),
|
|
99
|
+
description: "no special character",
|
|
100
|
+
shouldFail: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (reqs.noSpaces) {
|
|
105
|
+
cases.push({
|
|
106
|
+
password: validPassword.slice(0, 4) + " " + validPassword.slice(4),
|
|
107
|
+
description: "contains space",
|
|
108
|
+
shouldFail: true,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
cases.push({
|
|
113
|
+
password: validPassword,
|
|
114
|
+
description: "valid password satisfying all requirements",
|
|
115
|
+
shouldFail: false,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return cases;
|
|
119
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { SignicClient } from "@sudobility/signic_sdk";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { secp256k1 } from "@noble/curves/secp256k1";
|
|
4
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
5
|
+
import type { Page } from "puppeteer-core";
|
|
6
|
+
import pino from "pino";
|
|
7
|
+
import type { FormInfo, Credentials } from "@sudobility/testomniac_types";
|
|
8
|
+
import { loadConfig } from "../config/index";
|
|
9
|
+
|
|
10
|
+
const logger = pino({ name: "signic-registrar" });
|
|
11
|
+
|
|
12
|
+
function generatePrivateKey(): Uint8Array {
|
|
13
|
+
return randomBytes(32);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function privateKeyToAddress(privateKey: Uint8Array): string {
|
|
17
|
+
const publicKey = secp256k1.getPublicKey(privateKey, false).slice(1);
|
|
18
|
+
const hash = keccak_256(publicKey);
|
|
19
|
+
return `0x${Buffer.from(hash.slice(-20)).toString("hex")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function signEthMessage(
|
|
23
|
+
message: string,
|
|
24
|
+
privateKey: Uint8Array
|
|
25
|
+
): { address: string; signature: string } {
|
|
26
|
+
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
|
|
27
|
+
const hash = keccak_256(
|
|
28
|
+
Buffer.concat([Buffer.from(prefix), Buffer.from(message)])
|
|
29
|
+
);
|
|
30
|
+
const sig = secp256k1.sign(hash, privateKey);
|
|
31
|
+
const r = sig.r.toString(16).padStart(64, "0");
|
|
32
|
+
const s = sig.s.toString(16).padStart(64, "0");
|
|
33
|
+
const v = (sig.recovery! + 27).toString(16).padStart(2, "0");
|
|
34
|
+
return {
|
|
35
|
+
address: privateKeyToAddress(privateKey),
|
|
36
|
+
signature: `0x${r}${s}${v}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function generatePassword(): string {
|
|
41
|
+
return `TestPass!${randomBytes(4).toString("hex")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function autoRegister(
|
|
45
|
+
page: Page,
|
|
46
|
+
signupForm: FormInfo
|
|
47
|
+
): Promise<Credentials | null> {
|
|
48
|
+
const config = loadConfig();
|
|
49
|
+
const privateKey = generatePrivateKey();
|
|
50
|
+
const walletAddress = privateKeyToAddress(privateKey);
|
|
51
|
+
|
|
52
|
+
const client = new SignicClient({
|
|
53
|
+
indexerUrl: config.signicIndexerUrl,
|
|
54
|
+
wildduckUrl: config.signicWildduckUrl,
|
|
55
|
+
signMessage: async (message: string) => signEthMessage(message, privateKey),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const email = client.getEmailAddress();
|
|
59
|
+
const password = generatePassword();
|
|
60
|
+
|
|
61
|
+
logger.info({ email }, "auto-registering with Signic email");
|
|
62
|
+
|
|
63
|
+
await client.connect(walletAddress);
|
|
64
|
+
|
|
65
|
+
for (const field of signupForm.fields) {
|
|
66
|
+
const label = (field.name + " " + field.label).toLowerCase();
|
|
67
|
+
let value = "";
|
|
68
|
+
|
|
69
|
+
if (field.type === "email" || label.includes("email")) {
|
|
70
|
+
value = email;
|
|
71
|
+
} else if (field.type === "password") {
|
|
72
|
+
value = password;
|
|
73
|
+
} else if (label.includes("name") && !label.includes("username")) {
|
|
74
|
+
value = "Test User";
|
|
75
|
+
} else if (label.includes("username")) {
|
|
76
|
+
value = `testuser_${randomBytes(3).toString("hex")}`;
|
|
77
|
+
} else {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const el = await page.waitForSelector(field.selector, { timeout: 5000 });
|
|
83
|
+
if (el) {
|
|
84
|
+
await el.click({ clickCount: 3 });
|
|
85
|
+
await el.type(value);
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logger.warn(
|
|
89
|
+
{ err, selector: field.selector },
|
|
90
|
+
"could not fill signup field"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (signupForm.submitSelector) {
|
|
96
|
+
const btn = await page.waitForSelector(signupForm.submitSelector, {
|
|
97
|
+
timeout: 5000,
|
|
98
|
+
});
|
|
99
|
+
if (btn) await btn.click();
|
|
100
|
+
} else {
|
|
101
|
+
await page.keyboard.press("Enter");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await page.waitForNavigation({
|
|
106
|
+
waitUntil: "networkidle0",
|
|
107
|
+
timeout: 10_000,
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.debug({ err }, "navigation timeout after signup form submission");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
logger.info("polling Signic inbox for verification email");
|
|
114
|
+
let verified = false;
|
|
115
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
116
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
117
|
+
const { emails } = await client.getUnreadEmails(5);
|
|
118
|
+
if (emails.length === 0) continue;
|
|
119
|
+
|
|
120
|
+
const latestEmail = await client.getEmail(emails[0].id);
|
|
121
|
+
const body = latestEmail.text || latestEmail.html?.join("") || "";
|
|
122
|
+
|
|
123
|
+
const otpMatch = body.match(/\b(\d{4,8})\b/);
|
|
124
|
+
if (otpMatch) {
|
|
125
|
+
const otp = otpMatch[1];
|
|
126
|
+
logger.info({ otp }, "found OTP in verification email");
|
|
127
|
+
const otpSelectors = [
|
|
128
|
+
'input[name*="code"]',
|
|
129
|
+
'input[name*="otp"]',
|
|
130
|
+
'input[name*="verification"]',
|
|
131
|
+
'input[placeholder*="code"]',
|
|
132
|
+
'input[type="number"]',
|
|
133
|
+
];
|
|
134
|
+
for (const sel of otpSelectors) {
|
|
135
|
+
try {
|
|
136
|
+
const el = await page.waitForSelector(sel, {
|
|
137
|
+
timeout: 3000,
|
|
138
|
+
visible: true,
|
|
139
|
+
});
|
|
140
|
+
if (el) {
|
|
141
|
+
await el.type(otp);
|
|
142
|
+
await page.keyboard.press("Enter");
|
|
143
|
+
try {
|
|
144
|
+
await page.waitForNavigation({
|
|
145
|
+
waitUntil: "networkidle0",
|
|
146
|
+
timeout: 10_000,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.debug(
|
|
150
|
+
{ err },
|
|
151
|
+
"navigation timeout during OTP verification"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
verified = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
logger.debug({ err, selector: sel }, "OTP field selector not found");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (verified) break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const linkMatch =
|
|
166
|
+
body.match(/https?:\/\/[^\s"'<>]+verify[^\s"'<>]*/i) ||
|
|
167
|
+
body.match(/https?:\/\/[^\s"'<>]+confirm[^\s"'<>]*/i);
|
|
168
|
+
if (linkMatch) {
|
|
169
|
+
logger.info({ link: linkMatch[0] }, "found verification link");
|
|
170
|
+
await page.goto(linkMatch[0], {
|
|
171
|
+
waitUntil: "networkidle0",
|
|
172
|
+
timeout: 15_000,
|
|
173
|
+
});
|
|
174
|
+
verified = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!verified) {
|
|
180
|
+
logger.warn("could not verify account — no OTP or link found");
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
logger.info({ email }, "auto-registration complete");
|
|
185
|
+
return { email, password };
|
|
186
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import puppeteer, { type Browser, type Page } from "puppeteer-core";
|
|
2
|
+
import type { Config } from "../config/index";
|
|
3
|
+
import type { Screen } from "@sudobility/testomniac_types";
|
|
4
|
+
|
|
5
|
+
export class ChromiumManager {
|
|
6
|
+
private browser: Browser | null = null;
|
|
7
|
+
|
|
8
|
+
constructor(private config: Config) {}
|
|
9
|
+
|
|
10
|
+
async launch(): Promise<Browser> {
|
|
11
|
+
this.browser = await puppeteer.launch({
|
|
12
|
+
executablePath: this.config.chromiumPath,
|
|
13
|
+
userDataDir: this.config.userDataDir,
|
|
14
|
+
headless: true,
|
|
15
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
16
|
+
});
|
|
17
|
+
return this.browser;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async newPage(screen?: Screen): Promise<Page> {
|
|
21
|
+
if (!this.browser) throw new Error("Browser not launched");
|
|
22
|
+
const page = await this.browser.newPage();
|
|
23
|
+
if (screen) {
|
|
24
|
+
await page.setViewport({ width: screen.width, height: screen.height });
|
|
25
|
+
}
|
|
26
|
+
return page;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async close(): Promise<void> {
|
|
30
|
+
if (this.browser) {
|
|
31
|
+
await this.browser.close();
|
|
32
|
+
this.browser = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|