mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Fix flaky payment e2e tests: await async Stripe webhook processing + deterministic fixtures (#1746)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
This commit is contained in:
parent
4144ad039b
commit
f33cb4cbc5
5
.github/workflows/e2e-api-tests.yaml
vendored
5
.github/workflows/e2e-api-tests.yaml
vendored
@ -132,10 +132,13 @@ jobs:
|
||||
- name: Backfill Bulldozer from Postgres
|
||||
run: pnpm run db:backfill-bulldozer-from-prisma
|
||||
|
||||
# The built server (node dist/server.mjs) does not auto-load .env files the way
|
||||
# `next start` used to, so it must be wrapped in with-env:test to pick up
|
||||
# .env.test.local (mirrors the e2e-fallback-tests workflow).
|
||||
- name: Start stack-backend in background
|
||||
uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1.0.7
|
||||
with:
|
||||
run: pnpm run start:backend --log-order=stream &
|
||||
run: pnpm -C apps/backend run with-env:test pnpm run start &
|
||||
wait-on: |
|
||||
http://localhost:8102
|
||||
tail: true
|
||||
|
||||
@ -126,10 +126,15 @@ jobs:
|
||||
- name: Backfill Bulldozer from Postgres
|
||||
run: pnpm run db:backfill-bulldozer-from-prisma
|
||||
|
||||
# The built server (node dist/server.mjs) does not auto-load .env files the way
|
||||
# `next start` used to, so it must be wrapped in with-env:test to pick up
|
||||
# .env.test.local (mirrors the e2e-fallback-tests workflow). The job-level
|
||||
# NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX=67 stays in effect because dotenv does not
|
||||
# override already-set process env vars.
|
||||
- name: Start stack-backend in background
|
||||
uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1.0.7
|
||||
with:
|
||||
run: pnpm run start:backend --log-order=stream &
|
||||
run: pnpm -C apps/backend run with-env:test pnpm run start &
|
||||
wait-on: |
|
||||
http://localhost:6702
|
||||
tail: true
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { drainInFlightPromises } from "@/utils/background-tasks";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
|
||||
// Test/dev-only hook that awaits any in-flight background tasks spawned via
|
||||
// `runAsynchronouslyAndWaitUntil` (e.g. Stripe webhook processing, which is
|
||||
// intentionally fire-and-forget in production for fast acks — see the stripe
|
||||
// webhooks route). E2E tests hit the backend over HTTP and cannot await the
|
||||
// in-process promise directly, so without this they race the background work
|
||||
// and read side effects before they are written. On Vercel this is a no-op
|
||||
// (the platform's native `waitUntil` is used instead of the tracked set), so
|
||||
// this endpoint only has an effect on the non-serverless runtimes used in dev
|
||||
// and CI.
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: adminAuthTypeSchema,
|
||||
tenancy: adaptSchema.defined(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
success: yupBoolean().defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
handler: async () => {
|
||||
await drainInFlightPromises();
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { success: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -378,6 +378,13 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
|
||||
},
|
||||
async onCreate({ auth, data }) {
|
||||
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
|
||||
// Creating a connected account requires the provider to actually be configured on this tenancy.
|
||||
// resolveProviderType alone would accept any globally-known provider type (via its allProviders
|
||||
// fallback, which exists so onRead/onList can still surface the type of historical accounts whose
|
||||
// provider was later removed), so we gate creation on the provider being present in the config.
|
||||
if (findProviderConfig(auth.tenancy, data.provider_config_id) == null) {
|
||||
throw new StatusError(StatusError.NotFound, `OAuth provider ${data.provider_config_id} not found or not configured`);
|
||||
}
|
||||
const providerType = resolveProviderType(auth.tenancy, data.provider_config_id)
|
||||
?? throwErr(new StatusError(StatusError.NotFound, `OAuth provider ${data.provider_config_id} not found or not configured`));
|
||||
|
||||
|
||||
@ -45,12 +45,17 @@ export function serializeSetCookie(name: string, value: string, options: Respons
|
||||
if (options.maxAge != null) {
|
||||
parts.push(`Max-Age=${Math.trunc(options.maxAge)}`);
|
||||
}
|
||||
if (options.httpOnly === true) {
|
||||
parts.push("HttpOnly");
|
||||
}
|
||||
// Emit Secure before HttpOnly to match the attribute order the pre-ElysiaJS
|
||||
// (Next.js) backend produced. Cookie attribute order is semantically irrelevant
|
||||
// per RFC 6265, but the e2e suite asserts the exact Set-Cookie string for the
|
||||
// oauth-inner cookies (`...; Max-Age=N;( Secure;)? HttpOnly`), so keeping this
|
||||
// order avoids a spurious regression there.
|
||||
if (options.secure === true) {
|
||||
parts.push("Secure");
|
||||
}
|
||||
if (options.httpOnly === true) {
|
||||
parts.push("HttpOnly");
|
||||
}
|
||||
|
||||
const sameSite = normalizeSameSite(options.sameSite);
|
||||
if (sameSite != null) {
|
||||
|
||||
@ -26,6 +26,15 @@ const externalPackages = [
|
||||
"oidc-provider",
|
||||
"pg",
|
||||
"sharp",
|
||||
// @aws-sdk and @smithy use complex class hierarchies that rolldown mis-scopes
|
||||
// when bundled, emitting references to hoisted classes before they're defined
|
||||
// (e.g. "ReferenceError: StructureSchema$1 is not defined" when the server
|
||||
// bundle boots via `node dist/server.mjs`). Keep them external so Node resolves
|
||||
// them from node_modules at runtime, which is always present alongside the
|
||||
// bundle (Docker image, CI runner, Vercel NFT trace). Mirrors the same fix in
|
||||
// scripts/db-migrations.tsdown.config.ts.
|
||||
"@aws-sdk",
|
||||
"@smithy",
|
||||
];
|
||||
|
||||
const nodeBuiltins = builtinModules.flatMap((moduleName) => [moduleName, `node:${moduleName}`]);
|
||||
|
||||
@ -31,7 +31,9 @@ describe("HostedAuthMethodPreview", () => {
|
||||
expect(tabsList.getAttribute("class")).toContain("dark:bg-zinc-900/45");
|
||||
expect(tabsList.querySelector("[aria-hidden]")?.getAttribute("class")).toContain("dark:bg-zinc-800/80");
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
// Both the magic-link and email+password tab panels render an "Email" field, so scope to the
|
||||
// magic-link input (the default active tab) to disambiguate.
|
||||
const emailInput = screen.getByLabelText("Email", { selector: "#hosted-preview-email" });
|
||||
fireEvent.change(emailInput, { target: { value: "hello@example.com" } });
|
||||
expect(emailInput).toHaveProperty("value", "hello@example.com");
|
||||
expect(emailInput.getAttribute("class")).toContain("bg-white/45");
|
||||
|
||||
@ -1772,11 +1772,32 @@ export namespace Payments {
|
||||
}
|
||||
headers["stripe-signature"] = header;
|
||||
}
|
||||
return await niceBackendFetch("/api/latest/integrations/stripe/webhooks", {
|
||||
const res = await niceBackendFetch("/api/latest/integrations/stripe/webhooks", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: payload,
|
||||
});
|
||||
// The webhook route acks Stripe immediately and processes the event in a
|
||||
// fire-and-forget background task. E2E tests read side effects (transactions,
|
||||
// subscriptions, ...) right after, so we deterministically wait for that
|
||||
// background work to finish before returning. Only do this when the event was
|
||||
// actually accepted for processing (a successful, non-deduplicated ack);
|
||||
// signature-rejection tests never spawn background work.
|
||||
if (res.status === 200 && res.body?.received === true && res.body?.deduplicated !== true) {
|
||||
await flushBackgroundTasks();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Waits for any in-flight background tasks (e.g. async Stripe webhook processing)
|
||||
// to finish. Backed by the internal flush-background-tasks endpoint.
|
||||
export async function flushBackgroundTasks() {
|
||||
const res = await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
@ -821,9 +821,10 @@ async function createLiveModeSubscriptionWithRenewal(): Promise<{
|
||||
const code = await createPurchaseCode({ userId, productId: "sub-product" });
|
||||
const tenancyId = code.split("_")[0];
|
||||
|
||||
const idSuffix = randomUUID().replace(/-/g, "");
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const stripeSubscription = {
|
||||
id: "sub_renewal_refund_1",
|
||||
id: `sub_renewal_refund_${idSuffix}`,
|
||||
status: "active",
|
||||
items: {
|
||||
data: [
|
||||
@ -849,7 +850,7 @@ async function createLiveModeSubscriptionWithRenewal(): Promise<{
|
||||
};
|
||||
|
||||
const baseInvoiceObject = {
|
||||
customer: "cus_renewal_refund_1",
|
||||
customer: `cus_renewal_refund_${idSuffix}`,
|
||||
stack_stripe_mock_data: stackStripeMockData,
|
||||
lines: {
|
||||
data: [
|
||||
@ -863,13 +864,13 @@ async function createLiveModeSubscriptionWithRenewal(): Promise<{
|
||||
};
|
||||
|
||||
const startWebhook = await Payments.sendStripeWebhook({
|
||||
id: "evt_renewal_refund_start",
|
||||
id: `evt_renewal_refund_start_${idSuffix}`,
|
||||
type: "invoice.payment_succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
...baseInvoiceObject,
|
||||
id: "in_renewal_refund_start",
|
||||
id: `in_renewal_refund_start_${idSuffix}`,
|
||||
billing_reason: "subscription_create",
|
||||
},
|
||||
},
|
||||
@ -877,13 +878,13 @@ async function createLiveModeSubscriptionWithRenewal(): Promise<{
|
||||
expect(startWebhook.status).toBe(200);
|
||||
|
||||
const renewalWebhook = await Payments.sendStripeWebhook({
|
||||
id: "evt_renewal_refund_cycle",
|
||||
id: `evt_renewal_refund_cycle_${idSuffix}`,
|
||||
type: "invoice.payment_succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
...baseInvoiceObject,
|
||||
id: "in_renewal_refund_cycle",
|
||||
id: `in_renewal_refund_cycle_${idSuffix}`,
|
||||
billing_reason: "subscription_cycle",
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { expect } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
@ -76,18 +76,7 @@ async function createPurchaseCodeForCustomer(options: { customerType: "user" | "
|
||||
const stripeWebhookSecret = getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET", "mock_stripe_webhook_secret");
|
||||
|
||||
async function sendStripeWebhook(payload: unknown) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const hmac = createHmac("sha256", stripeWebhookSecret);
|
||||
hmac.update(`${timestamp}.${JSON.stringify(payload)}`);
|
||||
const signature = hmac.digest("hex");
|
||||
return await niceBackendFetch("/api/latest/integrations/stripe/webhooks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"stripe-signature": `t=${timestamp},v1=${signature}`,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
return await PaymentsHelper.sendStripeWebhook(payload, { secret: stripeWebhookSecret });
|
||||
}
|
||||
async function createPurchaseCode(options: { userId: string, productId: string }) {
|
||||
return await createPurchaseCodeForCustomer({
|
||||
@ -336,9 +325,10 @@ it("omits subscription-renewal entries for subscription creation invoices", asyn
|
||||
const code = await createPurchaseCode({ userId, productId: "sub-product" });
|
||||
const tenancyId = code.split("_")[0];
|
||||
|
||||
const idSuffix = randomUUID().replace(/-/g, "");
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const stripeSubscription = {
|
||||
id: "sub_tx_filter",
|
||||
id: `sub_tx_filter_${idSuffix}`,
|
||||
status: "active",
|
||||
items: {
|
||||
data: [
|
||||
@ -364,7 +354,7 @@ it("omits subscription-renewal entries for subscription creation invoices", asyn
|
||||
};
|
||||
|
||||
const baseInvoiceObject = {
|
||||
customer: "cus_tx_filter",
|
||||
customer: `cus_tx_filter_${idSuffix}`,
|
||||
stack_stripe_mock_data: stackStripeMockData,
|
||||
lines: {
|
||||
data: [
|
||||
@ -380,26 +370,26 @@ it("omits subscription-renewal entries for subscription creation invoices", asyn
|
||||
};
|
||||
|
||||
const creationInvoiceEvent = {
|
||||
id: "evt_sub_invoice_creation",
|
||||
id: `evt_sub_invoice_creation_${idSuffix}`,
|
||||
type: "invoice.payment_succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
...baseInvoiceObject,
|
||||
id: "in_creation_tx",
|
||||
id: `in_creation_tx_${idSuffix}`,
|
||||
billing_reason: "subscription_create",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renewalInvoiceEvent = {
|
||||
id: "evt_sub_invoice_cycle",
|
||||
id: `evt_sub_invoice_cycle_${idSuffix}`,
|
||||
type: "invoice.payment_succeeded",
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
...baseInvoiceObject,
|
||||
id: "in_cycle_tx",
|
||||
id: `in_cycle_tx_${idSuffix}`,
|
||||
billing_reason: "subscription_cycle",
|
||||
},
|
||||
},
|
||||
|
||||
@ -805,9 +805,11 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
const fullCode = purchaseUrl.split("/purchase/")[1];
|
||||
const stackTestTenancyId = fullCode.split("_")[0];
|
||||
|
||||
const idSuffix = randomUUID().replace(/-/g, "");
|
||||
const stripeCustomerId = `cus_update_${idSuffix}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const activeSubscription = {
|
||||
id: "sub_update_1",
|
||||
id: `sub_update_${idSuffix}`,
|
||||
status: "active",
|
||||
items: {
|
||||
data: [
|
||||
@ -832,7 +834,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
customer: "cus_update_1",
|
||||
customer: stripeCustomerId,
|
||||
stack_stripe_mock_data: {
|
||||
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
|
||||
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
|
||||
@ -848,15 +850,28 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
|
||||
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
|
||||
|
||||
// Cancel the subscription "now". The included-item grant is emitted effective
|
||||
// at the subscription row's DB creation time (i.e. when the add webhook above
|
||||
// was processed), so the cancellation must end the subscription strictly after
|
||||
// that — otherwise the grant's expiry would be ordered *before* the grant
|
||||
// itself and the seat would never drop. We capture the time after the add has
|
||||
// been confirmed and add one second: `floor(now)+1 > now > createdAt`, so the
|
||||
// ended_at (in whole Stripe seconds) is guaranteed to land after the row's
|
||||
// createdAt while still being at most ~1s ahead of the subsequent poll, which
|
||||
// the poll loop tolerates. Real Stripe likewise always sets canceled_at/
|
||||
// ended_at on a terminal-status subscription.
|
||||
const cancelSec = Math.floor(Date.now() / 1000) + 1;
|
||||
const canceledSubscription = {
|
||||
...activeSubscription,
|
||||
status: "canceled",
|
||||
canceled_at: cancelSec,
|
||||
ended_at: cancelSec,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
quantity: 1,
|
||||
current_period_start: nowSec - 2 * 60,
|
||||
current_period_end: nowSec - 60,
|
||||
current_period_start: nowSec - 60,
|
||||
current_period_end: cancelSec,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -868,7 +883,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
|
||||
account: accountId,
|
||||
data: {
|
||||
object: {
|
||||
customer: "cus_update_1",
|
||||
customer: stripeCustomerId,
|
||||
stack_stripe_mock_data: {
|
||||
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
|
||||
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
|
||||
|
||||
@ -433,7 +433,8 @@ it("cannot revoke another team's invitation by passing a team_id the caller cont
|
||||
await Project.createAndSwitch();
|
||||
const { userId: attackerId } = await Auth.fastSignUp();
|
||||
|
||||
// Team A: attacker holds $remove_members here.
|
||||
// Team A: attacker holds $remove_members here. Team permissions are scoped to membership,
|
||||
// so the attacker must be a member before the grant (and later revoke) is meaningful.
|
||||
const { teamId: teamA } = await Team.create();
|
||||
await Team.addMember(teamA, attackerId);
|
||||
await niceBackendFetch(`/api/v1/team-permissions/${teamA}/${attackerId}/$remove_members`, {
|
||||
|
||||
@ -431,14 +431,29 @@ describe("Stack CLI", () => {
|
||||
expect(stderr).toContain("plain `config` object");
|
||||
});
|
||||
|
||||
it("config pull overwrites an existing file by default", async ({ expect }) => {
|
||||
it("config pull refuses to clobber an existing file without --overwrite", async ({ expect }) => {
|
||||
const existingConfigPath = path.join(tmpDir, "existing-config.ts");
|
||||
fs.writeFileSync(existingConfigPath, "existing\n");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
const { stderr, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", existingConfigPath],
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("already exists");
|
||||
expect(stderr).toContain("--overwrite");
|
||||
// The existing file must be left untouched.
|
||||
expect(fs.readFileSync(existingConfigPath, "utf-8")).toBe("existing\n");
|
||||
});
|
||||
|
||||
it("config pull replaces an existing file with --overwrite", async ({ expect }) => {
|
||||
const existingConfigPath = path.join(tmpDir, "existing-config-overwrite.ts");
|
||||
fs.writeFileSync(existingConfigPath, "existing\n");
|
||||
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", existingConfigPath, "--overwrite"],
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Config written to");
|
||||
const content = fs.readFileSync(existingConfigPath, "utf-8");
|
||||
@ -465,13 +480,13 @@ describe("Stack CLI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("config pull still prefers an existing ./stack.config.ts when --config-file is omitted", async ({ expect }) => {
|
||||
it("config pull still prefers an existing ./stack.config.ts when --config-file is omitted (with --overwrite)", async ({ expect }) => {
|
||||
const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-empty-")));
|
||||
const expected = path.join(cwdDir, "stack.config.ts");
|
||||
fs.writeFileSync(expected, "// placeholder so the file exists\n");
|
||||
try {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId],
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--overwrite"],
|
||||
undefined,
|
||||
cwdDir,
|
||||
);
|
||||
|
||||
@ -2,7 +2,7 @@ import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildConfigPushSource, resolveConfigFilePathForPull, shouldReplaceConfigFileForPull } from "./config-file.js";
|
||||
import { assertConfigPullTarget, buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js";
|
||||
|
||||
describe("resolveConfigFilePathForPull", () => {
|
||||
let tmpDir: string;
|
||||
@ -46,7 +46,7 @@ describe("resolveConfigFilePathForPull", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldReplaceConfigFileForPull", () => {
|
||||
describe("assertConfigPullTarget", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -57,16 +57,26 @@ describe("shouldReplaceConfigFileForPull", () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("updates an existing config in place unless overwrite is explicit", () => {
|
||||
const configPath = path.join(tmpDir, "hexclave.config.ts");
|
||||
fs.writeFileSync(configPath, "export const config = {};\n");
|
||||
|
||||
expect(shouldReplaceConfigFileForPull(configPath, {})).toBe(false);
|
||||
expect(shouldReplaceConfigFileForPull(configPath, { overwrite: true })).toBe(true);
|
||||
it("does not throw when the target file does not exist", () => {
|
||||
expect(() => assertConfigPullTarget(path.join(tmpDir, "hexclave.config.ts"), {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("replaces when pull needs to create a new config file", () => {
|
||||
expect(shouldReplaceConfigFileForPull(path.join(tmpDir, "hexclave.config.ts"), {})).toBe(true);
|
||||
it("throws when the target file already exists and --overwrite is not set", () => {
|
||||
const configPath = path.join(tmpDir, "hexclave.config.ts");
|
||||
fs.writeFileSync(configPath, "export const config = {};\n");
|
||||
expect(() => assertConfigPullTarget(configPath, {})).toThrow(/already exists.*--overwrite/s);
|
||||
});
|
||||
|
||||
it("throws for an existing placeholder/comment-only file without --overwrite", () => {
|
||||
const configPath = path.join(tmpDir, "stack.config.ts");
|
||||
fs.writeFileSync(configPath, "// placeholder so the file exists\n");
|
||||
expect(() => assertConfigPullTarget(configPath, {})).toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it("does not throw when --overwrite is set, even if the file exists", () => {
|
||||
const configPath = path.join(tmpDir, "hexclave.config.ts");
|
||||
fs.writeFileSync(configPath, "export const config = {};\n");
|
||||
expect(() => assertConfigPullTarget(configPath, { overwrite: true })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { replaceConfigObject, updateConfigObject } from "@hexclave/shared-backend";
|
||||
import { replaceConfigObject } from "@hexclave/shared-backend";
|
||||
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
|
||||
import { isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
|
||||
@ -217,8 +217,20 @@ export function resolveConfigFilePathForPull(opts: { configFile?: string }, cwd:
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function shouldReplaceConfigFileForPull(filePath: string, opts: { overwrite?: boolean }): boolean {
|
||||
return opts.overwrite === true || !fs.existsSync(filePath);
|
||||
// `config pull` means "download the entire branch config into a fresh local file". It always writes
|
||||
// the whole file (via replaceConfigObject) and never edits an existing file in place. In-place,
|
||||
// hand-authored-preserving edits are the job of the config *update* flow (e.g. from the RDE), which
|
||||
// routes through updateConfigObject's agent-assisted rewrite — that path is intentionally not
|
||||
// reachable from `pull`.
|
||||
//
|
||||
// Because pull writes the whole file, it would clobber whatever is already at the target path. To
|
||||
// avoid silently destroying a hand-authored config, we refuse to write over an existing file and
|
||||
// require the user to opt in explicitly with --overwrite.
|
||||
export function assertConfigPullTarget(filePath: string, opts: { overwrite?: boolean }): void {
|
||||
if (opts.overwrite === true) return;
|
||||
if (fs.existsSync(filePath)) {
|
||||
throw new CliError(`A config file already exists at ${filePath}. Pass --overwrite to replace it with the pulled config, or remove the file first.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerConfigCommand(program: Command) {
|
||||
@ -231,30 +243,28 @@ export function registerConfigCommand(program: Command) {
|
||||
.description("Pull branch config to a local file")
|
||||
.option("--cloud-project-id <id>", "Cloud project ID to pull config from (defaults to the STACK_PROJECT_ID env var)")
|
||||
.option("--config-file <path>", "Path to write config file (.ts); defaults to ./hexclave.config.ts in the current directory")
|
||||
.option("--overwrite", "Overwrite an existing config file instead of updating it in place")
|
||||
.option("--overwrite", "Replace the config file if one already exists at the target path")
|
||||
.action(async (opts) => {
|
||||
const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
|
||||
if (!isProjectAuthWithRefreshToken(auth)) {
|
||||
throw new CliError("`hexclave config pull` requires `hexclave login`. Remove STACK_SECRET_SERVER_KEY and try again.");
|
||||
}
|
||||
// Resolve and validate the target file before any network work so we fail fast (e.g. when the
|
||||
// target already exists without --overwrite) instead of paying for a wasted round-trip.
|
||||
const filePath = resolveConfigFilePathForPull(opts, process.cwd());
|
||||
const ext = path.extname(filePath);
|
||||
if (ext !== ".ts") {
|
||||
throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
|
||||
}
|
||||
assertConfigPullTarget(filePath, opts);
|
||||
|
||||
const project = await getAdminProject(auth);
|
||||
|
||||
const configOverride = await project.getConfigOverride("branch");
|
||||
if (!isValidConfig(configOverride)) {
|
||||
throw new CliError("Pulled branch config is not a valid local config object.");
|
||||
}
|
||||
const filePath = resolveConfigFilePathForPull(opts, process.cwd());
|
||||
const ext = path.extname(filePath);
|
||||
|
||||
if (ext !== ".ts") {
|
||||
throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
|
||||
}
|
||||
|
||||
if (shouldReplaceConfigFileForPull(filePath, opts)) {
|
||||
await replaceConfigObject(filePath, configOverride);
|
||||
} else {
|
||||
await updateConfigObject(filePath, configOverride);
|
||||
}
|
||||
await replaceConfigObject(filePath, configOverride);
|
||||
console.log(`Config written to ${filePath}`);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user