@bugmole/cli 0.4.0 → 0.6.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/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject } from "../testing/harness.js";
|
|
4
|
+
import { normalizeAttribution } from "./attribution.js";
|
|
5
|
+
|
|
6
|
+
const now = Date.parse("2026-09-25T12:00:00Z");
|
|
7
|
+
|
|
8
|
+
test("attribution keeps only values that match the contract", () => {
|
|
9
|
+
const cleaned = normalizeAttribution({
|
|
10
|
+
ref: " Partner_42 ",
|
|
11
|
+
utm_source: " newsletter ",
|
|
12
|
+
utm_medium: "email",
|
|
13
|
+
utm_campaign: "x".repeat(150),
|
|
14
|
+
utm_content: "line\nbreak",
|
|
15
|
+
utm_term: 7,
|
|
16
|
+
landing_path: "/pricing/?utm_source=newsletter#plans",
|
|
17
|
+
referrer_host: "News.Example.com",
|
|
18
|
+
first_seen_at: "2026-09-20T08:30:00.000Z",
|
|
19
|
+
}, now);
|
|
20
|
+
assert.deepEqual(cleaned, {
|
|
21
|
+
ref: "partner_42",
|
|
22
|
+
utm_source: "newsletter",
|
|
23
|
+
utm_medium: "email",
|
|
24
|
+
utm_campaign: "x".repeat(100),
|
|
25
|
+
utm_content: null,
|
|
26
|
+
utm_term: null,
|
|
27
|
+
landing_path: "/pricing/",
|
|
28
|
+
referrer_host: "news.example.com",
|
|
29
|
+
first_seen_at: "2026-09-20T08:30:00.000Z",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const invalid = normalizeAttribution({
|
|
33
|
+
ref: "bad code!",
|
|
34
|
+
utm_source: "ok",
|
|
35
|
+
landing_path: "//evil.test/path",
|
|
36
|
+
referrer_host: "www.bugmole.com",
|
|
37
|
+
first_seen_at: "2027-01-01T00:00:00Z",
|
|
38
|
+
}, now);
|
|
39
|
+
assert.equal(invalid?.ref, null);
|
|
40
|
+
assert.equal(invalid?.landing_path, null);
|
|
41
|
+
assert.equal(invalid?.referrer_host, null);
|
|
42
|
+
assert.equal(invalid?.first_seen_at, null);
|
|
43
|
+
assert.equal(invalid?.utm_source, "ok");
|
|
44
|
+
|
|
45
|
+
assert.equal(normalizeAttribution({ ref: "a".repeat(41) }, now), null);
|
|
46
|
+
assert.equal(normalizeAttribution({ referrer_host: "https://news.example.com/x" }, now), null);
|
|
47
|
+
assert.equal(normalizeAttribution({ first_seen_at: "2026-09-20T08:30:00Z" }, now), null, "a date alone says nothing");
|
|
48
|
+
assert.equal(normalizeAttribution({ landing_path: "/docs/", first_seen_at: "2025-01-01T00:00:00Z" }, now)?.first_seen_at, null);
|
|
49
|
+
assert.equal(normalizeAttribution([], now), null);
|
|
50
|
+
assert.equal(normalizeAttribution("ref=x", now), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the first touch is recorded once and never overwritten", async () => {
|
|
54
|
+
const { env, db } = createTestEnv();
|
|
55
|
+
const first = await call(env, "POST", "/api/attribution", {
|
|
56
|
+
as: "user_new",
|
|
57
|
+
body: { ref: "Partner", utm_campaign: "launch", landing_path: "/", referrer_host: "news.example.com", bogus: "ignored" },
|
|
58
|
+
});
|
|
59
|
+
assert.equal(first.status, 201, JSON.stringify(first.json));
|
|
60
|
+
assert.equal(first.json.recorded, true);
|
|
61
|
+
|
|
62
|
+
const second = await call(env, "POST", "/api/attribution", { as: "user_new", body: { ref: "other", utm_campaign: "later" } });
|
|
63
|
+
assert.equal(second.status, 200);
|
|
64
|
+
assert.deepEqual(second.json, { recorded: false, reason: "already_recorded" });
|
|
65
|
+
|
|
66
|
+
const row = db.db.prepare("SELECT ref, utm_campaign, utm_source, landing_path, referrer_host FROM signup_attribution WHERE aegis_id = ?").get("user_new");
|
|
67
|
+
assert.deepEqual({ ...row }, { ref: "partner", utm_campaign: "launch", utm_source: null, landing_path: "/", referrer_host: "news.example.com" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an account older than a day can't be attributed", async () => {
|
|
71
|
+
const { env, db } = createTestEnv();
|
|
72
|
+
db.db.prepare("INSERT INTO aegis_users (id, created_at) VALUES ('user_old', datetime('now', '-3 days'))").run();
|
|
73
|
+
const result = await call(env, "POST", "/api/attribution", { as: "user_old", body: { ref: "partner" } });
|
|
74
|
+
assert.deepEqual(result.json, { recorded: false, reason: "account_too_old" });
|
|
75
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM signup_attribution").get()?.n, 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("nothing usable, API keys and the dashboard service record nothing", async () => {
|
|
79
|
+
const { env, db } = createTestEnv();
|
|
80
|
+
const empty = await call(env, "POST", "/api/attribution", { as: "user_new", body: { ref: "no spaces allowed", utm_source: " " } });
|
|
81
|
+
assert.deepEqual(empty.json, { recorded: false, reason: "nothing_to_record" });
|
|
82
|
+
|
|
83
|
+
const { owner, projectId } = await seedProject(env);
|
|
84
|
+
const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "ci", projectIds: [projectId] } });
|
|
85
|
+
const byKey = await call(env, "POST", "/api/attribution", { token: key.json.token, body: { ref: "partner" } });
|
|
86
|
+
assert.equal(byKey.status, 403);
|
|
87
|
+
const byService = await call(env, "POST", "/api/attribution", { as: "bugmole-dashboard-service", body: { ref: "partner" } });
|
|
88
|
+
assert.equal(byService.status, 403);
|
|
89
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM signup_attribution").get()?.n, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function withScents(env: Record<string, unknown>, codes: string[]) {
|
|
93
|
+
env.SCENTS = {
|
|
94
|
+
fetch: async (url: string) => {
|
|
95
|
+
const code = decodeURIComponent(String(url).split("/public/ref/")[1] ?? "");
|
|
96
|
+
return new Response(JSON.stringify({ valid: codes.includes(code) }), { headers: { "content-type": "application/json" } });
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return env;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("a typed referral code credits a recent account once, and only real codes", async () => {
|
|
103
|
+
const { env, db } = createTestEnv();
|
|
104
|
+
withScents(env as unknown as Record<string, unknown>, ["jane"]);
|
|
105
|
+
db.db.prepare("INSERT INTO aegis_users (id, created_at) VALUES ('user_new', datetime('now', '-5 days'))").run();
|
|
106
|
+
|
|
107
|
+
const before = await call(env, "GET", "/api/attribution/me", { as: "user_new" });
|
|
108
|
+
assert.equal(before.json.ref, null);
|
|
109
|
+
assert.equal(before.json.canEnterCode, true);
|
|
110
|
+
|
|
111
|
+
const typo = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "jan" } });
|
|
112
|
+
assert.equal(typo.status, 404);
|
|
113
|
+
const bad = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "not a code!" } });
|
|
114
|
+
assert.equal(bad.status, 400);
|
|
115
|
+
|
|
116
|
+
const ok = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: " JANE " } });
|
|
117
|
+
assert.equal(ok.status, 201, JSON.stringify(ok.json));
|
|
118
|
+
assert.equal(db.db.prepare("SELECT ref FROM signup_attribution WHERE aegis_id = 'user_new'").get()?.ref, "jane");
|
|
119
|
+
|
|
120
|
+
const again = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "jane" } });
|
|
121
|
+
assert.equal(again.status, 409);
|
|
122
|
+
const after = await call(env, "GET", "/api/attribution/me", { as: "user_new" });
|
|
123
|
+
assert.deepEqual([after.json.ref, after.json.canEnterCode], ["jane", false]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a code fills in a campaign-only attribution but never replaces a code", async () => {
|
|
127
|
+
const { env, db } = createTestEnv();
|
|
128
|
+
withScents(env as unknown as Record<string, unknown>, ["jane", "bob"]);
|
|
129
|
+
await call(env, "POST", "/api/attribution", { as: "user_new", body: { utm_campaign: "launch" } });
|
|
130
|
+
const ok = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "jane" } });
|
|
131
|
+
assert.equal(ok.status, 201);
|
|
132
|
+
const row = db.db.prepare("SELECT ref, utm_campaign FROM signup_attribution WHERE aegis_id = 'user_new'").get();
|
|
133
|
+
assert.deepEqual({ ...row }, { ref: "jane", utm_campaign: "launch" });
|
|
134
|
+
const swap = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "bob" } });
|
|
135
|
+
assert.equal(swap.status, 409);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("codes can't be added after 30 days, or when Scents can't be asked", async () => {
|
|
139
|
+
const { env, db } = createTestEnv();
|
|
140
|
+
db.db.prepare("INSERT INTO aegis_users (id, created_at) VALUES ('user_old', datetime('now', '-40 days'))").run();
|
|
141
|
+
const unreachable = await call(env, "POST", "/api/attribution/code", { as: "user_old", body: { code: "jane" } });
|
|
142
|
+
assert.equal(unreachable.status, 503);
|
|
143
|
+
withScents(env as unknown as Record<string, unknown>, ["jane"]);
|
|
144
|
+
const late = await call(env, "POST", "/api/attribution/code", { as: "user_old", body: { code: "jane" } });
|
|
145
|
+
assert.equal(late.status, 409);
|
|
146
|
+
assert.equal((await call(env, "GET", "/api/attribution/me", { as: "user_old" })).json.canEnterCode, false);
|
|
147
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Sign-up attribution: which affiliate code (?ref=) and campaign (utm_* tags)
|
|
2
|
+
// brought a new user. bugmole.com keeps the first visit's tags in the bm_attr
|
|
3
|
+
// cookie on .bugmole.com; the dashboard reads it after sign-in and posts it
|
|
4
|
+
// here once. First touch wins, and only accounts created in the last day can
|
|
5
|
+
// be attributed, so visiting an affiliate link later can't claim an old user.
|
|
6
|
+
// Nothing here is sent anywhere else; Scents reads the table.
|
|
7
|
+
import type { Env, Principal } from "../core.js";
|
|
8
|
+
import { requestJson, response } from "../core.js";
|
|
9
|
+
import { registerRoutes } from "../routes/index.js";
|
|
10
|
+
|
|
11
|
+
/** How long after an account first appears it may still be attributed. */
|
|
12
|
+
export const ATTRIBUTION_WINDOW_HOURS = 24;
|
|
13
|
+
/** How long after signing up someone may still type an affiliate's referral code. */
|
|
14
|
+
export const REFERRAL_CODE_WINDOW_DAYS = 30;
|
|
15
|
+
|
|
16
|
+
// The dashboard's own identity for calls made without a person (webhooks).
|
|
17
|
+
const DASHBOARD_SERVICE_ID = "bugmole-dashboard-service";
|
|
18
|
+
const UTM_FIELDS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"] as const;
|
|
19
|
+
// The cookie lives 90 days; allow a little over that, and a little clock skew.
|
|
20
|
+
const MAX_AGE_MS = 100 * 86_400_000;
|
|
21
|
+
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
export type SignupAttribution = {
|
|
24
|
+
ref: string | null;
|
|
25
|
+
utm_source: string | null;
|
|
26
|
+
utm_medium: string | null;
|
|
27
|
+
utm_campaign: string | null;
|
|
28
|
+
utm_content: string | null;
|
|
29
|
+
utm_term: string | null;
|
|
30
|
+
landing_path: string | null;
|
|
31
|
+
referrer_host: string | null;
|
|
32
|
+
first_seen_at: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function text(value: unknown, max: number): string | null {
|
|
36
|
+
if (typeof value !== "string") return null;
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
// Control characters mean the value was tampered with, not typed into a link.
|
|
39
|
+
if (!trimmed || /[\u0000-\u001f\u007f]/u.test(trimmed)) return null;
|
|
40
|
+
return trimmed.slice(0, max);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ref(value: unknown): string | null {
|
|
44
|
+
if (typeof value !== "string") return null;
|
|
45
|
+
const code = value.trim().toLowerCase();
|
|
46
|
+
return /^[a-z0-9_-]{1,40}$/u.test(code) ? code : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function landingPath(value: unknown): string | null {
|
|
50
|
+
if (typeof value !== "string") return null;
|
|
51
|
+
const pathOnly = value.trim().split(/[?#]/u)[0];
|
|
52
|
+
// A path on bugmole.com: starts with one slash, no spaces.
|
|
53
|
+
if (!/^\/(?!\/)[^\s\u0000-\u001f\u007f]*$/u.test(pathOnly)) return null;
|
|
54
|
+
return pathOnly.slice(0, 200);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function referrerHost(value: unknown): string | null {
|
|
58
|
+
if (typeof value !== "string") return null;
|
|
59
|
+
const host = value.trim().toLowerCase().replace(/\.$/u, "");
|
|
60
|
+
if (host.length > 100 || !/^[a-z0-9-]+(\.[a-z0-9-]+)*$/u.test(host)) return null;
|
|
61
|
+
// Only outside sites count as referrers.
|
|
62
|
+
if (host === "bugmole.com" || host.endsWith(".bugmole.com")) return null;
|
|
63
|
+
return host;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function firstSeenAt(value: unknown, now: number): string | null {
|
|
67
|
+
if (typeof value !== "string" || value.length > 40) return null;
|
|
68
|
+
const at = Date.parse(value);
|
|
69
|
+
if (!Number.isFinite(at) || at > now + CLOCK_SKEW_MS || at < now - MAX_AGE_MS) return null;
|
|
70
|
+
return new Date(at).toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Keeps only values that match the contract; anything else becomes null.
|
|
75
|
+
* Returns null when nothing usable is left.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeAttribution(input: unknown, now = Date.now()): SignupAttribution | null {
|
|
78
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
79
|
+
const source = input as Record<string, unknown>;
|
|
80
|
+
const result: SignupAttribution = {
|
|
81
|
+
ref: ref(source.ref),
|
|
82
|
+
utm_source: null,
|
|
83
|
+
utm_medium: null,
|
|
84
|
+
utm_campaign: null,
|
|
85
|
+
utm_content: null,
|
|
86
|
+
utm_term: null,
|
|
87
|
+
landing_path: landingPath(source.landing_path),
|
|
88
|
+
referrer_host: referrerHost(source.referrer_host),
|
|
89
|
+
first_seen_at: firstSeenAt(source.first_seen_at, now),
|
|
90
|
+
};
|
|
91
|
+
for (const field of UTM_FIELDS) result[field] = text(source[field], 100);
|
|
92
|
+
const { first_seen_at: _when, ...facts } = result;
|
|
93
|
+
return Object.values(facts).some((value) => value !== null) ? result : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function recordAttribution(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
97
|
+
// Only a person signed in on the dashboard, never an API key or runner.
|
|
98
|
+
if (principal.apiKeyId || principal.runner || !principal.scopes.includes("*") || principal.aegisId === DASHBOARD_SERVICE_ID) {
|
|
99
|
+
return response({ error: "Only the dashboard records sign-up attribution" }, 403);
|
|
100
|
+
}
|
|
101
|
+
const attribution = normalizeAttribution(await requestJson(request));
|
|
102
|
+
if (!attribution) return response({ recorded: false, reason: "nothing_to_record" });
|
|
103
|
+
const values = [
|
|
104
|
+
attribution.ref,
|
|
105
|
+
...UTM_FIELDS.map((field) => attribution[field]),
|
|
106
|
+
attribution.landing_path,
|
|
107
|
+
attribution.referrer_host,
|
|
108
|
+
attribution.first_seen_at,
|
|
109
|
+
];
|
|
110
|
+
const [, inserted] = await env.DB.batch([
|
|
111
|
+
// The dashboard can call this before anything else has created the row.
|
|
112
|
+
env.DB.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").bind(principal.aegisId),
|
|
113
|
+
env.DB.prepare(`
|
|
114
|
+
INSERT INTO signup_attribution
|
|
115
|
+
(aegis_id, ref, utm_source, utm_medium, utm_campaign, utm_content, utm_term, landing_path, referrer_host, first_seen_at)
|
|
116
|
+
SELECT id, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
117
|
+
FROM aegis_users
|
|
118
|
+
WHERE id = ? AND created_at > datetime('now', ?)
|
|
119
|
+
ON CONFLICT(aegis_id) DO NOTHING
|
|
120
|
+
`).bind(...values, principal.aegisId, `-${ATTRIBUTION_WINDOW_HOURS} hours`),
|
|
121
|
+
]);
|
|
122
|
+
if (inserted?.meta?.changes === 1) return response({ recorded: true }, 201);
|
|
123
|
+
const existing = await env.DB.prepare("SELECT 1 AS found FROM signup_attribution WHERE aegis_id = ?")
|
|
124
|
+
.bind(principal.aegisId).first<{ found: number }>();
|
|
125
|
+
return response({ recorded: false, reason: existing ? "already_recorded" : "account_too_old" });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function signedInPerson(principal: Principal): boolean {
|
|
129
|
+
return !principal.apiKeyId && !principal.runner && principal.scopes.includes("*") && principal.aegisId !== DASHBOARD_SERVICE_ID;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether `code` is a real affiliate's code, asked of Scents (which owns the affiliate list). */
|
|
133
|
+
export async function isAffiliateCode(env: Env, code: string): Promise<boolean | null> {
|
|
134
|
+
if (!env.SCENTS) return null;
|
|
135
|
+
try {
|
|
136
|
+
const res = await env.SCENTS.fetch(`https://scents.bugmole.com/public/ref/${encodeURIComponent(code)}`);
|
|
137
|
+
if (!res.ok) return null;
|
|
138
|
+
return ((await res.json()) as { valid?: boolean }).valid === true;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The signed-in person's referral: the code that brought them, and whether they may still type one. */
|
|
145
|
+
async function myReferral(env: Env, principal: Principal): Promise<Response> {
|
|
146
|
+
const row = await env.DB.prepare(
|
|
147
|
+
`SELECT u.created_at, a.ref FROM aegis_users u LEFT JOIN signup_attribution a ON a.aegis_id = u.id WHERE u.id = ?`,
|
|
148
|
+
).bind(principal.aegisId).first<{ created_at: string | null; ref: string | null }>();
|
|
149
|
+
const created = row?.created_at ? Date.parse(`${row.created_at.replace(" ", "T")}Z`) : Date.now();
|
|
150
|
+
const deadline = new Date(created + REFERRAL_CODE_WINDOW_DAYS * 86_400_000);
|
|
151
|
+
return response({ ref: row?.ref ?? null, canEnterCode: !row?.ref && Date.now() < deadline.getTime(), deadline: deadline.toISOString() });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Someone types an affiliate's referral code (they may have heard it, or
|
|
156
|
+
* declined the cookie that would have carried the link's ?ref=). Once per
|
|
157
|
+
* person, within 30 days of signing up, only if nothing credited them yet,
|
|
158
|
+
* and only a code that belongs to an affiliate, so a typo can't lock in.
|
|
159
|
+
*/
|
|
160
|
+
async function enterReferralCode(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
161
|
+
const code = ref(((await requestJson(request)) as { code?: unknown } | null)?.code);
|
|
162
|
+
if (!code) return response({ error: "Enter the referral code: letters, numbers, - or _" }, 400);
|
|
163
|
+
const valid = await isAffiliateCode(env, code);
|
|
164
|
+
if (valid === null) return response({ error: "Couldn't check that code right now. Try again in a minute." }, 503);
|
|
165
|
+
if (!valid) return response({ error: "That referral code doesn't exist. Check the spelling." }, 404);
|
|
166
|
+
const [, written] = await env.DB.batch([
|
|
167
|
+
env.DB.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").bind(principal.aegisId),
|
|
168
|
+
env.DB.prepare(`
|
|
169
|
+
INSERT INTO signup_attribution (aegis_id, ref)
|
|
170
|
+
SELECT id, ? FROM aegis_users WHERE id = ? AND created_at > datetime('now', ?)
|
|
171
|
+
ON CONFLICT(aegis_id) DO UPDATE SET ref = excluded.ref WHERE signup_attribution.ref IS NULL
|
|
172
|
+
`).bind(code, principal.aegisId, `-${REFERRAL_CODE_WINDOW_DAYS} days`),
|
|
173
|
+
]);
|
|
174
|
+
if (written?.meta?.changes === 1) return response({ recorded: true, ref: code }, 201);
|
|
175
|
+
const existing = await env.DB.prepare("SELECT ref FROM signup_attribution WHERE aegis_id = ?").bind(principal.aegisId).first<{ ref: string | null }>();
|
|
176
|
+
return response(
|
|
177
|
+
existing?.ref
|
|
178
|
+
? { error: `Your account is already credited to code ${existing.ref}.`, ref: existing.ref }
|
|
179
|
+
: { error: `Referral codes can only be added in the first ${REFERRAL_CODE_WINDOW_DAYS} days after signing up.` },
|
|
180
|
+
409,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
registerRoutes(async ({ request, env, principal, url }) => {
|
|
185
|
+
if (url.pathname === "/api/attribution/me" || url.pathname === "/api/attribution/code") {
|
|
186
|
+
if (!signedInPerson(principal)) return response({ error: "Only the dashboard can do this" }, 403);
|
|
187
|
+
if (url.pathname === "/api/attribution/me" && request.method === "GET") return myReferral(env, principal);
|
|
188
|
+
if (url.pathname === "/api/attribution/code" && request.method === "POST") return enterReferralCode(request, env, principal);
|
|
189
|
+
return response({ error: "Method not allowed" }, 405);
|
|
190
|
+
}
|
|
191
|
+
if (url.pathname !== "/api/attribution") return null;
|
|
192
|
+
if (request.method !== "POST") return response({ error: "Method not allowed" }, 405);
|
|
193
|
+
return recordAttribution(request, env, principal);
|
|
194
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// whose email Aegis confirmed.
|
|
4
4
|
import type { Env, Principal } from "../core.js";
|
|
5
5
|
import { response } from "../core.js";
|
|
6
|
-
import { entitlementMode } from "../billing/enforcement.js";
|
|
6
|
+
import { billingLinks, entitlementMode } from "../billing/enforcement.js";
|
|
7
7
|
import { getWorkspacePlan } from "../billing/entitlements.js";
|
|
8
8
|
import { flagMode } from "../flags.js";
|
|
9
9
|
|
|
@@ -30,7 +30,7 @@ export async function freeWorkspaceLimit(env: Env, principal: Principal): Promis
|
|
|
30
30
|
error: "You already have a Free workspace. Upgrade it, or ask a teammate to invite you to theirs.",
|
|
31
31
|
code: "free_workspace_exists",
|
|
32
32
|
workspaceId: owned[0],
|
|
33
|
-
|
|
33
|
+
...billingLinks(env, owned[0], "team"),
|
|
34
34
|
}, 402);
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -6,6 +6,7 @@ import { newId, response, requestJson } from "../core.js";
|
|
|
6
6
|
import { recordAudit, actorOf } from "../org/audit.js";
|
|
7
7
|
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
8
8
|
import { ownedFreeWorkspaces } from "./policy.js";
|
|
9
|
+
import { domainIdentity, identityInUse, identityValues } from "../projects/identity.js";
|
|
9
10
|
|
|
10
11
|
const MAX_NAME = 80;
|
|
11
12
|
|
|
@@ -54,6 +55,13 @@ async function onboard(env: Env, principal: Principal, request: Request): Promis
|
|
|
54
55
|
const workspaceId = `${slugify(workspaceName, "workspace")}-${suffix()}`;
|
|
55
56
|
const projectId = `${slugify(projectName, "project")}-${suffix()}`;
|
|
56
57
|
const environment = siteUrl ? { id: "production", name: "Production", baseUrl: siteUrl } : null;
|
|
58
|
+
// The site's domain is the project's identity: one that's already someone
|
|
59
|
+
// else's Free project can't start a second Free workspace.
|
|
60
|
+
const identity = await domainIdentity(siteUrl);
|
|
61
|
+
if (identity) {
|
|
62
|
+
const inUse = await identityInUse(env, principal, workspaceId, identity);
|
|
63
|
+
if (inUse) return inUse;
|
|
64
|
+
}
|
|
57
65
|
const statements = [
|
|
58
66
|
env.DB.prepare(`
|
|
59
67
|
INSERT INTO aegis_users (id, email, email_verified) VALUES (?, ?, 1)
|
|
@@ -63,9 +71,10 @@ async function onboard(env: Env, principal: Principal, request: Request): Promis
|
|
|
63
71
|
env.DB.prepare("INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
|
|
64
72
|
.bind(workspaceId, principal.aegisId),
|
|
65
73
|
env.DB.prepare(`
|
|
66
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, default_execution_mode,
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, default_execution_mode,
|
|
75
|
+
identity_kind, identity_key, identity_hash, identity_origin, created_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, 'bugmole', 'managed', 'cloud', ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
77
|
+
`).bind(projectId, workspaceId, workspaceId, projectName, environment?.id ?? "production", ...identityValues(identity)),
|
|
69
78
|
env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
|
|
70
79
|
.bind(projectId, projectName),
|
|
71
80
|
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Workspace membership for people who sign in through a workspace's identity
|
|
2
2
|
// provider: roles follow their IdP groups, and the IdP can remove them.
|
|
3
3
|
import { type Env, type WorkspaceRole, isWorkspaceRole } from "../core.js";
|
|
4
|
-
import {
|
|
4
|
+
import { blockedBy, featureState } from "../feature-access.js";
|
|
5
5
|
import { type AuditActor, recordAudit } from "../org/audit.js";
|
|
6
6
|
|
|
7
7
|
/** Roles an IdP group may grant. Ownership is only ever given by a person. */
|
|
@@ -65,7 +65,9 @@ export async function applyOrgMembership(
|
|
|
65
65
|
actor: AuditActor = { type: "user", id: identity.aegisId },
|
|
66
66
|
): Promise<void> {
|
|
67
67
|
const workspace = await workspaceForOrg(env, org.id);
|
|
68
|
-
|
|
68
|
+
// Only the rollout gates this: a workspace that lost SSO on its plan keeps
|
|
69
|
+
// membership sync through the grace period syncSsoEntitlements manages.
|
|
70
|
+
if (!workspace || blockedBy(await featureState(env, workspace.id, "sso")) === "rollout") return;
|
|
69
71
|
const role = pickRole(await groupRoleMappings(env, workspace.id), org.groups, workspace.defaultRole);
|
|
70
72
|
const existing = await env.DB.prepare(
|
|
71
73
|
"SELECT role, status, source FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ?",
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// roles here, and act on Aegis's deprovisioning events.
|
|
4
4
|
import { type Env, memberOfWorkspace, requestJson, response } from "../core.js";
|
|
5
5
|
import { escapeHtml } from "../email/sender.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { entitlementsFor } from "../../billing/plan-catalog.js";
|
|
7
|
+
import { blockedBy, effectiveFeatures, type FeatureState } from "../feature-access.js";
|
|
8
8
|
import { planChangedHooks } from "../hooks.js";
|
|
9
9
|
import { registerJob } from "../jobs/index.js";
|
|
10
10
|
import { enqueue, registerOutboxHandler } from "../notifications/outbox.js";
|
|
@@ -76,12 +76,28 @@ export function losesAccess(previous: Allowed, next: Allowed): boolean {
|
|
|
76
76
|
return (previous.sso && !next.sso) || (previous.scim && !next.scim);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* What the workspace is granted: the shared evaluator's answer, except that a
|
|
81
|
+
* rollout block (kill switch, beta, an incident override) falls back to the
|
|
82
|
+
* plan — it hides the feature without telling Aegis to revoke it.
|
|
83
|
+
*/
|
|
84
|
+
async function grantedFeatures(env: SsoEnv, workspaceId: string) {
|
|
85
|
+
const { plan, features: evaluated } = await effectiveFeatures(env, workspaceId);
|
|
86
|
+
const byPlan = entitlementsFor(plan.plan, plan.overrides).features;
|
|
87
|
+
const granted = (state: FeatureState, planValue: boolean) => blockedBy(state) === "rollout" ? planValue : state.enabled;
|
|
88
|
+
return {
|
|
89
|
+
evaluated,
|
|
90
|
+
features: { sso: granted(evaluated.sso, byPlan.sso), scim: granted(evaluated.scim, byPlan.scim) },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
async function availability(env: SsoEnv, workspaceId: string) {
|
|
80
|
-
const { features } = await
|
|
81
|
-
|
|
95
|
+
const { evaluated, features } = await grantedFeatures(env, workspaceId);
|
|
96
|
+
const ssoBlocked = blockedBy(evaluated.sso);
|
|
97
|
+
if (ssoBlocked === "rollout" || !aegisOrgConfigured(env) || !registryUrl(env)) {
|
|
82
98
|
return { available: false, reason: "Single sign-on isn't available yet.", features };
|
|
83
99
|
}
|
|
84
|
-
if (
|
|
100
|
+
if (ssoBlocked === "plan") return { available: false, reason: "Single sign-on is included in Business and Enterprise.", features };
|
|
85
101
|
return { available: true, features };
|
|
86
102
|
}
|
|
87
103
|
|
|
@@ -251,8 +267,8 @@ async function passthrough(context: AuthedRouteContext, workspace: WorkspaceSsoR
|
|
|
251
267
|
if (!params) continue;
|
|
252
268
|
if (Object.values(params).some((value) => !SAFE_ID.test(value))) return response({ error: "Not found" }, 404);
|
|
253
269
|
if (entry.needs === "scim") {
|
|
254
|
-
const { features } = await
|
|
255
|
-
if (!features.scim) return response({ error: "SCIM provisioning is included in Enterprise." }, 402);
|
|
270
|
+
const { features } = await effectiveFeatures(env, workspace.id);
|
|
271
|
+
if (!features.scim.enabled) return response({ error: "SCIM provisioning is included in Enterprise." }, 402);
|
|
256
272
|
}
|
|
257
273
|
let body: Record<string, unknown> | undefined;
|
|
258
274
|
if (entry.fields) {
|
|
@@ -395,7 +411,7 @@ async function ownerEmails(env: Env, workspaceId: string): Promise<string[]> {
|
|
|
395
411
|
export async function syncSsoEntitlements(env: SsoEnv, workspaceId: string, now = new Date()): Promise<void> {
|
|
396
412
|
const workspace = await workspaceSso(env, workspaceId);
|
|
397
413
|
if (!workspace?.aegisOrgId) return;
|
|
398
|
-
const { features } = await
|
|
414
|
+
const { features } = await grantedFeatures(env, workspaceId);
|
|
399
415
|
const previous = decodeAllowed(workspace.entitlement);
|
|
400
416
|
const allowed = planAllows(features);
|
|
401
417
|
let graceUntil = workspace.graceUntil;
|
|
@@ -255,14 +255,15 @@ test("losing SSO starts a 14-day notice before Aegis turns it off", async () =>
|
|
|
255
255
|
assert.equal(row().ent, "sso");
|
|
256
256
|
assert.equal(row().grace, new Date(now.getTime() + SSO_GRACE_DAYS * 86_400_000).toISOString());
|
|
257
257
|
assert.deepEqual(aegis.org.entitlements, { sso: true, scim: false }, "still on during the notice");
|
|
258
|
-
|
|
258
|
+
// The outbox queues on the real clock, so drain on it too; the synced dates stay fixed.
|
|
259
|
+
await drainOutbox(env, new Date());
|
|
259
260
|
assert.equal(email.sent.length, 1);
|
|
260
261
|
assert.match(email.sent[0].subject, /Single sign-on for Acme ends on 2026-10-15/);
|
|
261
262
|
|
|
262
263
|
// A daily re-sync during the notice changes nothing and sends no second email.
|
|
263
264
|
await syncSsoEntitlements(env, workspaceId, new Date("2026-10-05T00:00:00Z"));
|
|
264
265
|
assert.equal(aegis.org.entitlements.sso, true);
|
|
265
|
-
await drainOutbox(env, new Date(
|
|
266
|
+
await drainOutbox(env, new Date());
|
|
266
267
|
assert.equal(email.sent.length, 1);
|
|
267
268
|
|
|
268
269
|
await syncSsoEntitlements(env, workspaceId, new Date("2026-10-16T00:00:00Z"));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject } from "./testing/harness.js";
|
|
4
|
+
|
|
5
|
+
async function inviteAndJoin(env: Parameters<typeof call>[0], inviter: string, workspaceId: string, who: string, role: string) {
|
|
6
|
+
const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: inviter, body: { email: `${who}@example.com`, role } });
|
|
7
|
+
assert.equal(invited.status, 201, JSON.stringify(invited.json));
|
|
8
|
+
const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
|
|
9
|
+
assert.equal((await call(env, "POST", `/api/invitations/${token}/accept`, { as: who })).status, 200);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function taskApi(env: Parameters<typeof call>[0], projectId: string, owner: string) {
|
|
13
|
+
let key = 0;
|
|
14
|
+
return {
|
|
15
|
+
create: (taskType: string, as = owner) => call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
16
|
+
as,
|
|
17
|
+
body: { title: `A ${taskType} task`, description: "Do the thing on https://shop.test", taskType, idempotencyKey: `k${key += 1}` },
|
|
18
|
+
}),
|
|
19
|
+
claim: (taskId: string) => call(env, "POST", `/api/tasks/${taskId}/claim`, { as: owner, body: { workerId: "worker-1" } }),
|
|
20
|
+
approve: (taskId: string, as = owner) => call(env, "POST", `/api/tasks/${taskId}/approve`, { as }),
|
|
21
|
+
setMode: (taskApprovalMode: string, as = owner) => call(env, "PATCH", `/api/projects/${projectId}/settings`, { as, body: { taskApprovalMode } }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("in auto mode, investigations start by themselves and code changes wait for a manager's approval", async () => {
|
|
26
|
+
const { env } = createTestEnv();
|
|
27
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
28
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
29
|
+
const tasks = taskApi(env, projectId, owner);
|
|
30
|
+
|
|
31
|
+
const investigation = (await tasks.create("investigation")).json.task;
|
|
32
|
+
assert.equal(investigation.approvedBy, "auto:auto");
|
|
33
|
+
assert.ok(investigation.approvedAt);
|
|
34
|
+
assert.equal((await tasks.claim(investigation.id)).status, 200);
|
|
35
|
+
|
|
36
|
+
const code = (await tasks.create("code")).json.task;
|
|
37
|
+
assert.equal(code.approvedAt, null, "a code change waits");
|
|
38
|
+
const early = await tasks.claim(code.id);
|
|
39
|
+
assert.equal(early.status, 409);
|
|
40
|
+
assert.match(early.json.error, /waiting for approval/);
|
|
41
|
+
|
|
42
|
+
assert.equal((await tasks.approve(code.id, "user_member")).status, 403, "members can queue tasks but not approve them");
|
|
43
|
+
const approved = await tasks.approve(code.id);
|
|
44
|
+
assert.equal(approved.status, 200, JSON.stringify(approved.json));
|
|
45
|
+
assert.equal(approved.json.task.approvedBy, owner);
|
|
46
|
+
assert.equal((await tasks.approve(code.id)).status, 409, "approving twice");
|
|
47
|
+
assert.equal((await tasks.claim(code.id)).status, 200);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("manual holds every task, extreme holds none, and only managers change the mode", async () => {
|
|
51
|
+
const { env } = createTestEnv();
|
|
52
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
53
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
54
|
+
const tasks = taskApi(env, projectId, owner);
|
|
55
|
+
|
|
56
|
+
assert.equal((await tasks.setMode("manual", "user_member")).status, 403);
|
|
57
|
+
assert.equal((await tasks.setMode("reckless")).status, 422);
|
|
58
|
+
assert.equal((await tasks.setMode("manual")).status, 200);
|
|
59
|
+
const listed = await call(env, "GET", "/api/projects", { as: owner });
|
|
60
|
+
assert.equal(listed.json.projects.find((project: { id: string }) => project.id === projectId).taskApprovalMode, "manual");
|
|
61
|
+
assert.equal((await tasks.create("investigation")).json.task.approvedAt, null, "manual holds even an investigation");
|
|
62
|
+
|
|
63
|
+
assert.equal((await tasks.setMode("extreme")).status, 200);
|
|
64
|
+
const code = (await tasks.create("code")).json.task;
|
|
65
|
+
assert.equal(code.approvedBy, "auto:extreme");
|
|
66
|
+
assert.equal((await tasks.claim(code.id)).status, 200);
|
|
67
|
+
});
|