Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 73 additions & 30 deletions src/core/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
archiveApplicationEffect as archiveAppServiceEffect,
createApplicationEffect as createAppServiceEffect,
createReleaseEffect as createReleaseServiceEffect,
activateReleaseEffect as activateReleaseServiceEffect,
disableApplicationEffect as disableAppServiceEffect,
enableApplicationEffect as enableAppServiceEffect,
getApplicationAndLatestReleaseEffect as getAppAndReleaseServiceEffect,
Expand Down Expand Up @@ -377,6 +378,18 @@ interface CreateReleaseResult {
};
}

interface ActivateReleaseResult {
activateRelease: {
id: string;
version: string;
description?: string;
status: string;
activatedAt?: string;
createdAt: string;
updatedAt: string;
};
}

interface AppListResult {
applications: {
edges: Array<{
Expand Down Expand Up @@ -468,6 +481,54 @@ function callCreateRelease(
);
}

function callActivateRelease(
applicationId: string,
releaseId: string,
opts: Parameters<typeof activateReleaseServiceEffect>[2],
) {
return narrowResult(
activateReleaseServiceEffect(applicationId, releaseId, opts),
(r) => r as ActivateReleaseResult,
);
}

/**
* Activate the release, then set the parent application status to ACTIVE.
* Deploy must activate the release before promoting the application.
*/
function finalizeDeployActivation(
applicationId: string,
releaseId: string,
accessToken: string,
options?: DeployOptions,
): Effect.Effect<void, CliError, FileSystem | Keychain | Fetch> {
return Effect.gen(function* () {
yield* emitProgress(options, {
type: "step",
name: "release.activate",
status: "started",
});
yield* callActivateRelease(applicationId, releaseId, { accessToken });
yield* emitProgress(options, {
type: "step",
name: "release.activate",
status: "completed",
});

yield* emitProgress(options, {
type: "step",
name: "application.activate",
status: "started",
});
yield* callUpdateApp(applicationId, { status: "ACTIVE" }, { accessToken });
yield* emitProgress(options, {
type: "step",
name: "application.activate",
status: "completed",
});
});
}

// ---------------------------------------------------------------------------
// Public Effect-first API
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -939,7 +1000,8 @@ export function applicationReleaseEffect(
* 6. Post-bundle security scan (Phase 2.5)
* 7. Get presigned upload URLs (Phase 3)
* 8. Upload artifacts to S3 (Phase 4)
* 9. Update application status to ACTIVE
* 9. Activate the release (activateRelease)
* 10. Update application status to ACTIVE
*/
export function applicationDeployEffect(
applicationName: string,
Expand Down Expand Up @@ -1065,21 +1127,12 @@ export function applicationDeployEffect(

// If no extensions found, skip security scan and bundling (no-op)
if (extensions.length === 0) {
yield* emitProgress(options, {
type: "step",
name: "application.activate",
status: "started",
});
yield* callUpdateApp(
appResult.application.id,
{ status: "ACTIVE" },
{ accessToken },
yield* finalizeDeployActivation(
applicationId,
releaseId,
accessToken,
options,
);
yield* emitProgress(options, {
type: "step",
name: "application.activate",
status: "completed",
});
yield* emitProgress(options, {
type: "step",
name: "deploy",
Expand Down Expand Up @@ -1374,22 +1427,12 @@ export function applicationDeployEffect(
});
}

// Update application status to ACTIVE
yield* emitProgress(options, {
type: "step",
name: "application.activate",
status: "started",
});
yield* callUpdateApp(
appResult.application.id,
{ status: "ACTIVE" },
{ accessToken },
yield* finalizeDeployActivation(
applicationId,
releaseId,
accessToken,
options,
);
yield* emitProgress(options, {
type: "step",
name: "application.activate",
status: "completed",
});
yield* emitProgress(options, {
type: "step",
name: "deploy",
Expand Down
43 changes: 43 additions & 0 deletions src/services/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ export const CreateReleaseMutation = graphql(`
}
`);

export const ActivateReleaseMutation = graphql(`
mutation ActivateRelease($applicationId: ID!, $releaseId: ID!) {
activateRelease(applicationId: $applicationId, releaseId: $releaseId) {
id
version
description
status
activatedAt
createdAt
updatedAt
}
}
`);

export const EnableApplicationMutation = graphql(`
mutation EnableApplication($input: MutationEnableStoreApplicationInput!) {
enableStoreApplication(input: $input) {
Expand Down Expand Up @@ -360,6 +374,35 @@ export function createReleaseEffect(
});
}

export function activateReleaseEffect(
applicationId: string,
releaseId: string,
{ accessToken }: { accessToken: string | null },
) {
return Effect.gen(function* () {
if (!accessToken) {
return yield* Effect.fail(
new AuthenticationError({
message: "Access token is required",
userMessage: "Authentication required",
}),
);
}

const client = yield* makeGraphQLClientEffect();

return yield* Effect.tryPromise({
try: () =>
client.request(
ActivateReleaseMutation,
{ applicationId, releaseId },
getRequestHeaders(accessToken),
),
catch: mapGraphQLError,
});
});
}

export function enableApplicationEffect(
input: { applicationName: string; storeId: string },
{ accessToken }: { accessToken: string | null },
Expand Down
53 changes: 53 additions & 0 deletions tests/setup/handlers/application-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export const applicationHandlers = [
case "CreateRelease":
return handleCreateReleaseMutation(variables);

case "ActivateRelease":
return handleActivateReleaseMutation(variables);

case "EnableApplication":
return handleEnableApplicationMutation(variables);

Expand Down Expand Up @@ -299,6 +302,56 @@ function handleCreateReleaseMutation(variables: Record<string, unknown>) {
});
}

function handleActivateReleaseMutation(variables: Record<string, unknown>) {
const { applicationId, releaseId } = variables;

if (!applicationId || !releaseId) {
return HttpResponse.json(
{
errors: [
{
message: "applicationId and releaseId are required",
extensions: { code: "BAD_USER_INPUT" },
},
],
},
{ status: 400 },
);
}

const app = applicationFixtures.applications.find(
(app) => app.id === applicationId,
);
if (!app) {
return HttpResponse.json(
{
errors: [
{
message: `Application with id "${applicationId}" not found`,
extensions: { code: "NOT_FOUND" },
},
],
},
{ status: 404 },
);
}

const now = new Date().toISOString();
return HttpResponse.json({
data: {
activateRelease: {
id: releaseId,
version: "1.0.0",
description: "Activated release",
status: "ACTIVE",
activatedAt: now,
createdAt: now,
updatedAt: now,
},
},
});
}

function handleEnableApplicationMutation(variables: Record<string, unknown>) {
const { input } = variables;

Expand Down
51 changes: 51 additions & 0 deletions tests/unit/application-deploy-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ describe("Application Deploy with Security Scanning", () => {
}),
);

vi.spyOn(applicationsService, "activateReleaseEffect").mockReturnValue(
Effect.succeed({
activateRelease: {
id: "release-123",
version: "1.0.0",
description: "Initial release",
status: "ACTIVE",
activatedAt: "2025-01-01T00:00:00Z",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
}),
);

// Setup spy for getExtensionsFromConfig (will be configured per test)
getExtensionsFromConfigSpy = vi.spyOn(
configService,
Expand Down Expand Up @@ -153,6 +167,7 @@ exec('rm -rf /'); // SEC001 violation
const exit = await runEffectExit(applicationDeployEffect("test-app"));
const err = extractFailure(exit) as { userMessage: string };
expect(err.userMessage).toContain("blocked");
expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled();
expect(
applicationsService.updateApplicationEffect,
).not.toHaveBeenCalled();
Expand Down Expand Up @@ -242,6 +257,14 @@ export function greet(name: string) {
event.status === "completed",
),
).toBe(true);
expect(
progressEvents.some(
(event) =>
event.type === "step" &&
event.name === "release.activate" &&
event.status === "completed",
),
).toBe(true);
expect(
progressEvents.some(
(event) =>
Expand All @@ -250,11 +273,23 @@ export function greet(name: string) {
event.status === "completed",
),
).toBe(true);
expect(applicationsService.activateReleaseEffect).toHaveBeenCalledWith(
"app-123",
"release-123",
{ accessToken: "test-token" },
);
expect(applicationsService.updateApplicationEffect).toHaveBeenCalledWith(
"app-123",
{ status: "ACTIVE" },
{ accessToken: "test-token" },
);
const activateOrder =
vi.mocked(applicationsService.activateReleaseEffect).mock
.invocationCallOrder[0];
const updateOrder =
vi.mocked(applicationsService.updateApplicationEffect).mock
.invocationCallOrder[0];
expect(activateOrder).toBeLessThan(updateOrder);
} finally {
process.chdir(originalCwd);
}
Expand Down Expand Up @@ -359,6 +394,7 @@ exec('dangerous command'); // SEC001
const exit = await runEffectExit(applicationDeployEffect("test-app"));
const err = extractFailure(exit) as { userMessage: string };
expect(err.userMessage).toContain("blocked");
expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled();
expect(
applicationsService.updateApplicationEffect,
).not.toHaveBeenCalled();
Expand All @@ -380,11 +416,23 @@ exec('dangerous command'); // SEC001
expect(result.totalExtensions).toBe(0);
expect(result.securityReports).toHaveLength(0);
// Deployment should still proceed
expect(applicationsService.activateReleaseEffect).toHaveBeenCalledWith(
"app-123",
"release-123",
{ accessToken: "test-token" },
);
expect(applicationsService.updateApplicationEffect).toHaveBeenCalledWith(
"app-123",
{ status: "ACTIVE" },
{ accessToken: "test-token" },
);
const activateOrder =
vi.mocked(applicationsService.activateReleaseEffect).mock
.invocationCallOrder[0];
const updateOrder =
vi.mocked(applicationsService.updateApplicationEffect).mock
.invocationCallOrder[0];
expect(activateOrder).toBeLessThan(updateOrder);
} finally {
process.chdir(originalCwd);
}
Expand All @@ -410,6 +458,7 @@ exec('dangerous command'); // SEC001
const exit = await runEffectExit(applicationDeployEffect("test-app"));
const err = extractFailure(exit) as { userMessage: string };
expect(err.userMessage).toContain("handle path");
expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled();
expect(
applicationsService.updateApplicationEffect,
).not.toHaveBeenCalled();
Expand Down Expand Up @@ -438,6 +487,7 @@ exec('dangerous command'); // SEC001
const exit = await runEffectExit(applicationDeployEffect("test-app"));
const err = extractFailure(exit) as { userMessage: string };
expect(err.userMessage).toContain("source path");
expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled();
expect(
applicationsService.updateApplicationEffect,
).not.toHaveBeenCalled();
Expand Down Expand Up @@ -520,6 +570,7 @@ export function run() {
expect(err.userMessage).toBe("upload failed");
expect(existsSync(artifactPath)).toBe(false);
expect(existsSync(sourcemapPath)).toBe(false);
expect(applicationsService.activateReleaseEffect).not.toHaveBeenCalled();
expect(
applicationsService.updateApplicationEffect,
).not.toHaveBeenCalled();
Expand Down