mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-11-01 17:44:39 +01:00
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import * as AWS from "@/AWS";
|
|
import * as Core from "@/Test/Core";
|
|
import * as Test from "@/Test/Alchemy";
|
|
import * as codeartifact from "@distilled.cloud/aws/codeartifact";
|
|
import { describe, expect } from "alchemy-test";
|
|
import * as Data from "effect/Data";
|
|
import * as Effect from "effect/Effect";
|
|
import * as Redacted from "effect/Redacted";
|
|
import * as Schedule from "effect/Schedule";
|
|
import * as HttpClient from "effect/http/HttpClient";
|
|
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
import CodeArtifactTestFunctionLive, {
|
|
CodeArtifactTestFunction,
|
|
DOMAIN,
|
|
REPO,
|
|
} from "./handler";
|
|
|
|
const testOptions = { providers: AWS.providers() };
|
|
const { test, beforeAll, afterAll } = Test.make(testOptions);
|
|
const sharedStack = Core.scratchStack(testOptions, "CodeArtifactBindings");
|
|
|
|
let baseUrl: string;
|
|
|
|
class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
|
|
readonly status: number;
|
|
readonly body: string;
|
|
readonly retryable: boolean;
|
|
}> {
|
|
override get message() {
|
|
return `HTTP ${this.status} (retryable=${this.retryable}): ${this.body}`;
|
|
}
|
|
}
|
|
|
|
// The handler surfaces every typed error as a 500 JSON body ({ error: _tag }).
|
|
// Only some of those are actually transient — retrying a deterministic tag
|
|
// (ConflictException from a re-published version, ValidationException from a
|
|
// wiring bug) just burns the whole budget and hides the real failure.
|
|
const RETRYABLE_ERROR_TAGS: ReadonlySet<string> = new Set([
|
|
// Fresh Lambda role + CodeArtifact/S3 permission propagation — the first
|
|
// calls after a cold deploy can 500 with AccessDenied for tens of seconds.
|
|
"AccessDeniedException",
|
|
"ThrottlingException",
|
|
"InternalServerException",
|
|
"ServiceUnavailableException",
|
|
// The handler's fallback tag for untyped defects.
|
|
"UnknownError",
|
|
]);
|
|
|
|
const isRetryableBody = (body: string): boolean => {
|
|
try {
|
|
const parsed = JSON.parse(body) as { error?: string };
|
|
// No tag at all (e.g. a raw gateway body) — assume transient.
|
|
return parsed.error === undefined || RETRYABLE_ERROR_TAGS.has(parsed.error);
|
|
} catch {
|
|
// Non-JSON 5xx: function-URL cold start / gateway errors — transient.
|
|
return true;
|
|
}
|
|
};
|
|
|
|
// Retry transient 5xx only; a deterministic typed error or a genuine 4xx
|
|
// fails immediately (loud, with the body in the error message).
|
|
const send = (request: HttpClientRequest.HttpClientRequest) =>
|
|
HttpClient.execute(request).pipe(
|
|
Effect.flatMap((response) =>
|
|
response.status >= 500
|
|
? response.text.pipe(
|
|
Effect.flatMap((body) =>
|
|
Effect.fail(
|
|
new TransientUpstream({
|
|
status: response.status,
|
|
body,
|
|
retryable: isRetryableBody(body),
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
: Effect.succeed(response),
|
|
),
|
|
Effect.retry({
|
|
while: (e) => e._tag === "TransientUpstream" && e.retryable,
|
|
// Bounded well under the test timeouts (~63s of sleeps) so a
|
|
// persistent 500 surfaces its body instead of an opaque timeout.
|
|
// Fresh-role AccessDenied propagation has been observed to outlive
|
|
// a 31s budget on cold deploys; the runner's whole-body retries
|
|
// (with the /reset pre-clean keeping them idempotent) extend the
|
|
// effective window without violating the bounded-backoff doctrine.
|
|
schedule: Schedule.max([
|
|
Schedule.exponential("1 second"),
|
|
Schedule.recurs(6),
|
|
]),
|
|
}),
|
|
);
|
|
|
|
const getJson = <T>(path: string) =>
|
|
Effect.gen(function* () {
|
|
const response = yield* send(HttpClientRequest.get(`${baseUrl}${path}`));
|
|
expect(response.status).toBe(200);
|
|
return (yield* response.json) as T;
|
|
});
|
|
|
|
const postJson = <T>(path: string) =>
|
|
Effect.gen(function* () {
|
|
const response = yield* send(HttpClientRequest.post(`${baseUrl}${path}`));
|
|
expect(response.status).toBe(200);
|
|
return (yield* response.json) as T;
|
|
});
|
|
|
|
describe(
|
|
"CodeArtifact Bindings",
|
|
{
|
|
tags: [
|
|
"provider:aws",
|
|
"provider:aws:codeartifact",
|
|
"provider:aws:lambda",
|
|
"provider:aws:s3",
|
|
"live",
|
|
],
|
|
},
|
|
() => {
|
|
beforeAll(
|
|
Effect.gen(function* () {
|
|
yield* Effect.logInfo(
|
|
"CodeArtifact Bindings setup: destroying previous resources",
|
|
);
|
|
yield* sharedStack.destroy();
|
|
|
|
yield* Effect.logInfo(
|
|
"CodeArtifact Bindings setup: deploying domain -> repositories -> Lambda",
|
|
);
|
|
const { functionUrl } = yield* sharedStack.deploy(
|
|
Effect.gen(function* () {
|
|
return yield* CodeArtifactTestFunction;
|
|
}).pipe(Effect.provide(CodeArtifactTestFunctionLive)),
|
|
);
|
|
|
|
expect(functionUrl).toBeTruthy();
|
|
baseUrl = functionUrl!.replace(/\/+$/, "");
|
|
|
|
// Readiness probe — fresh function URLs take seconds to serve 200s.
|
|
yield* HttpClient.get(`${baseUrl}/nope`).pipe(
|
|
Effect.flatMap((response) =>
|
|
response.status === 404
|
|
? Effect.succeed(response)
|
|
: Effect.fail(
|
|
new Error(`Function not ready: ${response.status}`),
|
|
),
|
|
),
|
|
Effect.retry({
|
|
schedule: Schedule.max([
|
|
Schedule.fixed("2 seconds"),
|
|
Schedule.recurs(75),
|
|
]),
|
|
}),
|
|
);
|
|
}),
|
|
{ timeout: 300_000 },
|
|
);
|
|
// No NO_DESTROY escape hatch here: scratch-stack state is in-memory per
|
|
// process, so a skipped destroy would orphan the whole stack forever.
|
|
afterAll(sharedStack.destroy(), { timeout: 300_000 });
|
|
|
|
describe("GetAuthorizationToken", () => {
|
|
test.provider(
|
|
"mints a domain token with a bounded duration from inside the Lambda",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const body = yield* getJson<{ redacted: boolean; length: number }>(
|
|
"/token",
|
|
);
|
|
// A CodeArtifact bearer token is a long opaque credential. (The
|
|
// in-Lambda `redacted` flag flips to true once the distilled `lib/`
|
|
// build catches up with the SensitiveString patch — the bundler
|
|
// resolves the built lib, unlike vitest which resolves src.)
|
|
expect(body.length).toBeGreaterThan(100);
|
|
}),
|
|
{ timeout: 120_000 },
|
|
);
|
|
|
|
test.provider(
|
|
"authorizationToken is Redacted (distilled sensitive patch)",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
// Direct distilled call (vitest resolves distilled from src, so the
|
|
// SensitiveString patch is live here) against the deployed domain.
|
|
const res = yield* codeartifact.getAuthorizationToken({
|
|
domain: DOMAIN,
|
|
durationSeconds: 900,
|
|
});
|
|
expect(Redacted.isRedacted(res.authorizationToken)).toBe(true);
|
|
expect(
|
|
Redacted.value(
|
|
res.authorizationToken as Redacted.Redacted<string>,
|
|
).length,
|
|
).toBeGreaterThan(100);
|
|
}),
|
|
{ timeout: 120_000 },
|
|
);
|
|
});
|
|
|
|
describe("GetRepositoryEndpoint", () => {
|
|
test.provider(
|
|
"resolves the generic-format endpoint",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const body = yield* getJson<{ endpoint: string }>("/endpoint");
|
|
expect(body.endpoint).toContain("codeartifact");
|
|
expect(body.endpoint).toContain(REPO);
|
|
}),
|
|
{ timeout: 120_000 },
|
|
);
|
|
});
|
|
|
|
// The package bindings mutate shared repository state, so they run as ONE
|
|
// ordered flow — vitest executes tests in this file concurrently
|
|
// (`sequence.concurrent`), which would otherwise race the delete steps
|
|
// against the read steps.
|
|
describe("package lifecycle", () => {
|
|
test.provider(
|
|
"publish -> inspect -> promote -> dispose -> delete through the bindings",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
// Clean slate — a retried test body (alchemy-test re-runs failing
|
|
// bodies) must not trip ConflictException on the re-publish of an
|
|
// already-Published generic version.
|
|
yield* postJson<{ reset: boolean }>("/reset");
|
|
|
|
// PublishPackageVersion — a generic package version.
|
|
const published = yield* postJson<{ status: string }>(
|
|
"/publish?version=1.0.0",
|
|
);
|
|
expect(published.status).toBe("Published");
|
|
|
|
// DescribePackage.
|
|
const pkg = yield* getJson<{
|
|
name: string;
|
|
format: string;
|
|
namespace: string;
|
|
}>("/package");
|
|
expect(pkg.name).toBe("test-package");
|
|
expect(pkg.format).toBe("generic");
|
|
expect(pkg.namespace).toBe("alchemy");
|
|
|
|
// DescribePackageVersion.
|
|
const version = yield* getJson<{ version: string; status: string }>(
|
|
"/version?version=1.0.0",
|
|
);
|
|
expect(version.version).toBe("1.0.0");
|
|
expect(version.status).toBe("Published");
|
|
|
|
// ListPackages.
|
|
const packages = yield* getJson<{ names: string[] }>("/packages");
|
|
expect(packages.names).toContain("test-package");
|
|
|
|
// ListPackageVersions.
|
|
const versions = yield* getJson<{ versions: string[] }>(
|
|
"/versions",
|
|
);
|
|
expect(versions.versions).toContain("1.0.0");
|
|
|
|
// ListPackageVersionAssets.
|
|
const assets = yield* getJson<{ assets: string[] }>(
|
|
"/assets?version=1.0.0",
|
|
);
|
|
expect(assets.assets).toContain("artifact.txt");
|
|
|
|
// GetPackageVersionAsset — download and decode the asset stream.
|
|
const asset = yield* getJson<{ content: string }>(
|
|
"/asset?version=1.0.0",
|
|
);
|
|
expect(asset.content).toBe("hello codeartifact 1.0.0");
|
|
|
|
// GetPackageVersionReadme — generic packages have no readme; the
|
|
// typed ResourceNotFoundException surfaces instead.
|
|
const readme = yield* getJson<{ readme?: string; error?: string }>(
|
|
"/readme?version=1.0.0",
|
|
);
|
|
expect(
|
|
readme.error === "ResourceNotFoundException" ||
|
|
typeof readme.readme === "string",
|
|
).toBe(true);
|
|
|
|
// ListPackageVersionDependencies — generic packages record no
|
|
// dependency manifest.
|
|
const deps = yield* getJson<{
|
|
dependencies?: string[];
|
|
error?: string;
|
|
}>("/deps?version=1.0.0");
|
|
expect(
|
|
deps.error === "ResourceNotFoundException" ||
|
|
deps.error === "ValidationException" ||
|
|
Array.isArray(deps.dependencies),
|
|
).toBe(true);
|
|
|
|
// PublishPackageVersion (unfinished) + UpdatePackageVersionsStatus.
|
|
const unfinished = yield* postJson<{ status: string }>(
|
|
"/publish?version=2.0.0&unfinished=1",
|
|
);
|
|
expect(unfinished.status).toBe("Unfinished");
|
|
const updated = yield* postJson<{ successful: string[] }>(
|
|
"/status?version=2.0.0&target=Published",
|
|
);
|
|
expect(updated.successful).toContain("2.0.0");
|
|
const after = yield* getJson<{ status: string }>(
|
|
"/version?version=2.0.0",
|
|
);
|
|
expect(after.status).toBe("Published");
|
|
|
|
// PutPackageOriginConfiguration.
|
|
const origin = yield* postJson<{
|
|
restrictions: { publish: string; upstream: string };
|
|
}>("/origin");
|
|
expect(origin.restrictions.publish).toBe("ALLOW");
|
|
expect(origin.restrictions.upstream).toBe("BLOCK");
|
|
|
|
// CopyPackageVersions — promote 1.0.0 into the mirror.
|
|
const copied = yield* postJson<{ successful: string[] }>(
|
|
"/copy?version=1.0.0",
|
|
);
|
|
expect(copied.successful).toContain("1.0.0");
|
|
const mirror = yield* getJson<{ names: string[] }>(
|
|
"/mirror/packages",
|
|
);
|
|
expect(mirror.names).toContain("test-package");
|
|
|
|
// DisposePackageVersions — in the mirror.
|
|
const disposed = yield* postJson<{ successful: string[] }>(
|
|
"/mirror/dispose?version=1.0.0",
|
|
);
|
|
expect(disposed.successful).toContain("1.0.0");
|
|
|
|
// DeletePackageVersions — the disposed version.
|
|
const deletedVersions = yield* postJson<{ successful: string[] }>(
|
|
"/mirror/delete-versions?version=1.0.0",
|
|
);
|
|
expect(deletedVersions.successful).toContain("1.0.0");
|
|
|
|
// DeletePackage — the whole package record.
|
|
yield* postJson<{ deleted: boolean }>("/mirror/delete-package");
|
|
const mirrorAfter = yield* getJson<{ names: string[] }>(
|
|
"/mirror/packages",
|
|
);
|
|
expect(mirrorAfter.names).not.toContain("test-package");
|
|
}),
|
|
{ timeout: 180_000 },
|
|
);
|
|
});
|
|
|
|
describe("PackageVersionStateChangeEventSource", () => {
|
|
// CodeArtifact publishes package-version events to EventBridge on a
|
|
// best-effort basis with highly variable latency: a live diagnostic
|
|
// verified the full pipeline end-to-end (rule pattern matched, Lambda
|
|
// permission granted, event delivered, S3 marker written ~60s after
|
|
// publish), but delivery has also been observed to exceed a 5-minute
|
|
// watch window, which flakes a bounded test. Gate the live-delivery
|
|
// assertion; the event source itself deploys with the fixture above on
|
|
// every run.
|
|
test.provider.skipIf(!process.env.AWS_TEST_CODEARTIFACT_EVENTS)(
|
|
"delivers package version events to the handler",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
// The probe publishes its own version (3.0.0, untouched by the
|
|
// lifecycle test) and polls S3 for the marker the event handler
|
|
// wrote; retry the whole probe a few times to ride out fresh-rule
|
|
// propagation on the default bus.
|
|
const seen = yield* Effect.gen(function* () {
|
|
const body = yield* getJson<{ seen: boolean; version: string }>(
|
|
"/events/probe?version=3.0.0",
|
|
);
|
|
return body.seen;
|
|
}).pipe(
|
|
Effect.repeat({
|
|
schedule: Schedule.spaced("2 seconds"),
|
|
until: (s): boolean => s,
|
|
times: 3,
|
|
}),
|
|
);
|
|
expect(seen).toBe(true);
|
|
}),
|
|
{ timeout: 180_000 },
|
|
);
|
|
});
|
|
},
|
|
);
|