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
2 changes: 1 addition & 1 deletion platform/src/components/aws/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1904,7 +1904,7 @@ export class Function extends Component implements Link.Linkable {
stage: $app.stage,
});
for (const linkable of link || []) {
if (!Link.isLinkable(linkable)) continue;
Link.assertLinkable(linkable);
const def = linkable.getSSTLink();
for (const item of def.include || []) {
if (item.type === "environment") Object.assign(result, item.env);
Expand Down
2 changes: 1 addition & 1 deletion platform/src/components/cloudflare/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ export class Worker extends Component implements Link.Linkable {
if (!args.link) return result;
return output(args.link).apply((links) => {
for (let link of links) {
if (!Link.isLinkable(link)) continue;
Link.assertLinkable(link);
const name = output(link.urn).apply((uri) => uri.split("::").at(-1)!);
const item = link.getSSTLink();
const b = item.include?.find(
Expand Down
72 changes: 38 additions & 34 deletions platform/src/components/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export namespace Link {
{},
);
this.registerOutputs({
target: target,
target,
include,
properties: {
type: normalizeType(type),
Expand Down Expand Up @@ -99,47 +99,50 @@ export namespace Link {
getSSTLink(): Definition;
}

export function isLinkable(obj: any): obj is Linkable {
return "getSSTLink" in obj;
export function isLinkable(obj: unknown): obj is Linkable {
if (typeof obj !== "object" || obj === null) return false;

const candidate = obj as Linkable;
return (
typeof candidate.getSSTLink === "function" &&
Output.isInstance(candidate.urn)
);
}

export function assertLinkable(obj: unknown): asserts obj is Linkable {
if (obj === undefined)
throw new VisibleError(
"An undefined link was passed into a `link` array.",
);
if (!isLinkable(obj))
throw new VisibleError(
"An invalid resource was passed into a `link` array. Use `sst.Linkable` or `sst.Linkable.wrap()` to make it linkable.",
);
}

export function build(links: any[]) {
return links
.map((link) => {
if (!link)
throw new VisibleError(
"An undefined link was passed into a `link` array.",
);
return link;
})
.filter((l) => isLinkable(l))
.map((l: Linkable) => {
const link = l.getSSTLink();
return all([l.urn, link]).apply(([urn, link]) => ({
name: urn.split("::").at(-1)!,
properties: {
...link.properties,
type: normalizeType(urn.split("::").at(-2)!),
},
}));
});
return links.map((l) => {
assertLinkable(l);
const link = l.getSSTLink();
return all([l.urn, link]).apply(([urn, link]) => ({
name: urn.split("::").at(-1)!,
properties: {
...link.properties,
type: normalizeType(urn.split("::").at(-2)!),
},
}));
});
}

export function getProperties(links?: Input<any[]>) {
const linkProperties = output(links ?? []).apply((links) =>
links
.map((link) => {
if (!link)
throw new VisibleError(
"An undefined link was passed into a `link` array.",
);
return link;
})
.filter((l) => isLinkable(l))
.map((l: Linkable) => ({
links.map((l) => {
assertLinkable(l);
return {
urn: l.urn,
properties: l.getSSTLink().properties,
})),
};
}),
);

return output(linkProperties).apply((e) =>
Expand Down Expand Up @@ -179,7 +182,8 @@ export namespace Link {
): Output<T[]> {
if (!input) return output([]);
return output(input).apply((links) => {
return links.filter(isLinkable).flatMap((l: Linkable) => {
return links.flatMap((l) => {
assertLinkable(l);
const link = l.getSSTLink();
return (link.include || []).filter((i) => i.type === type) as T[];
});
Expand Down
88 changes: 81 additions & 7 deletions platform/test/components/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,72 @@ pulumi.runtime.setMocks(
describe("Link", () => {
let Secret: typeof import("../../src/components/secret").Secret;
let Link: typeof import("../../src/components/link").Link;
let Linkable: typeof import("../../src/components/linkable").Linkable;

function resolveOutput<T>(value: pulumi.Output<T>) {
return new Promise<T>((resolve) => {
value.apply((resolved) => {
resolve(resolved);
return resolved;
});
});
async function resolveOutput<T>(value: pulumi.Output<T>) {
// Observe rejected values and metadata so invalid links fail instead of hanging.
const result = value as pulumi.Output<T> & {
promise(): Promise<T>;
isKnown: Promise<boolean>;
isSecret: Promise<boolean>;
};
const [resolved] = await Promise.all([
result.promise(),
result.isKnown,
result.isSecret,
]);
return resolved;
}

beforeAll(async () => {
Secret = (await import("../../src/components/secret")).Secret;
Link = (await import("../../src/components/link")).Link;
Linkable = (await import("../../src/components/linkable")).Linkable;
});

it.each([
null,
false,
42,
"Uploads",
{},
{ getSSTLink: true, urn: pulumi.output("urn") },
{ getSSTLink: () => ({ properties: {} }) },
{ getSSTLink: () => ({ properties: {} }), urn: "urn" },
])(
"rejects invalid explicit links without breaking discovery: %j",
(link) => {
expect(Link.isLinkable(link)).toBe(false);
expect(() => Link.build([link])).toThrow(
"An invalid resource was passed into a `link` array.",
);
},
);

it("preserves the undefined-link error", () => {
expect(() => Link.build([undefined])).toThrow(
"An undefined link was passed into a `link` array.",
);
});

it("rejects an unwrapped Pulumi resource mixed with valid links", () => {
const secret = new Secret("MixedSecret", "test");
const resource = new pulumi.ComponentResource("test:index:Resource", "Raw");

expect(Link.isLinkable(resource)).toBe(false);
expect(() => Link.build([secret, resource])).toThrow("sst.Linkable.wrap()");
});

it("rejects invalid links when resolving properties", async () => {
await expect(
resolveOutput(Link.getProperties(Promise.resolve([{}]))),
).rejects.toThrow("An invalid resource was passed into a `link` array.");
});

it("rejects invalid links when extracting permissions", async () => {
await expect(
resolveOutput(Link.getInclude("aws.permission", Promise.resolve([{}]))),
).rejects.toThrow("An invalid resource was passed into a `link` array.");
});

it("normalizes type in build output", async () => {
Expand All @@ -61,4 +114,25 @@ describe("Link", () => {

expect(properties.MyOtherSecret.type).toBe("sst.sst.Secret");
});

it("keeps valid links without matching includes and extracts permissions", async () => {
const secret = new Secret("PermissionSecret", "test");
const permission = {
type: "aws.permission",
actions: ["s3:GetObject"],
resources: ["arn:aws:s3:::uploads/*"],
};
const storage = new Linkable("Storage", {
properties: { name: "uploads" },
include: [permission, { type: "environment", env: { EXAMPLE: "value" } }],
});

expect(
await resolveOutput(Link.getInclude("aws.permission", [secret, storage])),
).toEqual([permission]);
expect(await resolveOutput(Link.getProperties([secret, storage]))).toEqual({
PermissionSecret: { type: "sst.sst.Secret", value: "test" },
Storage: { type: "sst.sst.Linkable", name: "uploads" },
});
});
});