diff --git a/languageserver/src/context-providers.test.ts b/languageserver/src/context-providers.test.ts new file mode 100644 index 0000000..59f948e --- /dev/null +++ b/languageserver/src/context-providers.test.ts @@ -0,0 +1,76 @@ +import {data, DescriptionDictionary} from "@actions/expressions"; +import {WorkflowContext} from "@actions/languageservice/context/workflow-context"; +import {Mode} from "@actions/languageservice/context-providers/default"; +import {contextProviders} from "./context-providers"; +import {RepositoryContext} from "./initializationOptions"; +import {TTLCache} from "./utils/cache"; + +describe("contextProviders", () => { + const mockCache = new TTLCache(); + const mockRepo: RepositoryContext = { + id: 123, + owner: "test-owner", + name: "test-repo", + organizationOwned: true, + workspaceUri: "file:///workspace" + }; + const mockWorkflowContext: WorkflowContext = { + uri: "test.yaml", + template: undefined + }; + + describe("when client is undefined", () => { + it("should return incomplete context for secrets", async () => { + const config = contextProviders(undefined, mockRepo, mockCache); + const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("should return incomplete context for vars", async () => { + const config = contextProviders(undefined, mockRepo, mockCache); + const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("should preserve defaultContext and mark as incomplete for secrets", async () => { + const config = contextProviders(undefined, mockRepo, mockCache); + const defaultContext = new DescriptionDictionary(); + defaultContext.add("EXISTING_SECRET", new data.StringData("test")); + + const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation); + + expect(result).toBe(defaultContext); + expect((result as DescriptionDictionary).complete).toBe(false); + expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined(); + }); + + it("should return undefined for other contexts like steps", async () => { + const config = contextProviders(undefined, mockRepo, mockCache); + const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation); + + expect(result).toBeUndefined(); + }); + }); + + describe("when both client and repo are undefined", () => { + it("should return incomplete context for secrets", async () => { + const config = contextProviders(undefined, undefined, mockCache); + const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("should return incomplete context for vars", async () => { + const config = contextProviders(undefined, undefined, mockCache); + const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + }); +}); diff --git a/languageserver/src/context-providers.ts b/languageserver/src/context-providers.ts index 243baf7..4ae90ef 100644 --- a/languageserver/src/context-providers.ts +++ b/languageserver/src/context-providers.ts @@ -15,7 +15,18 @@ export function contextProviders( cache: TTLCache ): ContextProviderConfig { if (!repo || !client) { - return {getContext: () => Promise.resolve(undefined)}; + // When GitHub client/repo is unavailable, return an incomplete dictionary + // to avoid false "Context access might be invalid" warnings + return { + getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => { + if (name === "secrets" || name === "vars") { + const context = defaultContext || new DescriptionDictionary(); + context.complete = false; + return Promise.resolve(context); + } + return Promise.resolve(undefined); + } + }; } const getContext = async ( diff --git a/languageservice/src/context-providers/default.test.ts b/languageservice/src/context-providers/default.test.ts new file mode 100644 index 0000000..7557837 --- /dev/null +++ b/languageservice/src/context-providers/default.test.ts @@ -0,0 +1,97 @@ +import {DescriptionDictionary} from "@actions/expressions"; +import {WorkflowContext} from "../context/workflow-context"; +import {getContext, Mode} from "./default"; + +describe("getContext", () => { + const emptyWorkflowContext: WorkflowContext = { + uri: "test.yaml", + template: undefined + }; + + describe("when no contextProviderConfig is provided", () => { + it("should mark secrets context as incomplete", async () => { + const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation); + + const secretsContext = result.get("secrets") as DescriptionDictionary; + expect(secretsContext).toBeDefined(); + expect(secretsContext.complete).toBe(false); + }); + + it("should mark vars context as incomplete", async () => { + const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation); + + const varsContext = result.get("vars") as DescriptionDictionary; + expect(varsContext).toBeDefined(); + expect(varsContext.complete).toBe(false); + }); + + it("should not mark other contexts as incomplete", async () => { + const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation); + + const envContext = result.get("env") as DescriptionDictionary; + const githubContext = result.get("github") as DescriptionDictionary; + + // These contexts are derived from the workflow file, so they can be complete + expect(envContext).toBeDefined(); + expect(envContext.complete).toBe(true); + expect(githubContext).toBeDefined(); + expect(githubContext.complete).toBe(true); + }); + }); + + describe("when contextProviderConfig returns a value", () => { + it("should use the provided context for secrets", async () => { + const providedContext = new DescriptionDictionary(); + providedContext.complete = true; // Provider fetched from API, so it's complete + + const config = { + getContext: () => Promise.resolve(providedContext) + }; + + const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation); + + const secretsContext = result.get("secrets"); + expect(secretsContext).toBe(providedContext); + expect((secretsContext as DescriptionDictionary).complete).toBe(true); + }); + + it("should use the provided context for vars", async () => { + const providedContext = new DescriptionDictionary(); + providedContext.complete = true; + + const config = { + getContext: () => Promise.resolve(providedContext) + }; + + const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation); + + const varsContext = result.get("vars"); + expect(varsContext).toBe(providedContext); + expect((varsContext as DescriptionDictionary).complete).toBe(true); + }); + }); + + describe("when contextProviderConfig returns undefined", () => { + it("should mark secrets as incomplete", async () => { + const config = { + getContext: () => Promise.resolve(undefined) + }; + + const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation); + + const secretsContext = result.get("secrets") as DescriptionDictionary; + expect(secretsContext.complete).toBe(false); + }); + + it("should mark vars as incomplete", async () => { + const config = { + getContext: () => Promise.resolve(undefined) + }; + + const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation); + + const varsContext = result.get("vars") as DescriptionDictionary; + expect(varsContext.complete).toBe(false); + }); + }); +}); diff --git a/languageservice/src/context-providers/default.ts b/languageservice/src/context-providers/default.ts index 7c2ddf3..0b59dec 100644 --- a/languageservice/src/context-providers/default.ts +++ b/languageservice/src/context-providers/default.ts @@ -40,7 +40,15 @@ export async function getContext( continue; } - value = (await config?.getContext(contextName, value, workflowContext, mode)) || value; + const remoteValue = await config?.getContext(contextName, value, workflowContext, mode); + if (remoteValue) { + value = remoteValue; + } else if (contextName === "secrets" || contextName === "vars") { + // Without a context provider to fetch remote secrets/vars, we can't know + // what values exist, so mark the context as incomplete to avoid false + // "Context access might be invalid" warnings + value.complete = false; + } context.add(contextName, value, getDescription(RootContext, contextName)); } diff --git a/languageservice/src/validate.incomplete-context.test.ts b/languageservice/src/validate.incomplete-context.test.ts new file mode 100644 index 0000000..f118528 --- /dev/null +++ b/languageservice/src/validate.incomplete-context.test.ts @@ -0,0 +1,152 @@ +/** + * Test validation behavior when no context providers are configured. + * + * When contextProviderConfig is not provided (or returns incomplete data), + * we should skip validation for secrets/vars rather than showing false + * positive "Context access might be invalid" warnings. + * + * This is important for offline/disconnected scenarios where API calls + * to fetch secrets/vars are not possible. + */ + +import {validate} from "./validate"; +import {createDocument} from "./test-utils/document"; +import {clearCache} from "./utils/workflow-cache"; + +beforeEach(() => { + clearCache(); +}); + +describe("validation without context providers", () => { + describe("secrets context", () => { + it("should not warn on secrets.GITHUB_TOKEN", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "test" + env: + GH_TOKEN: \${{ secrets.GITHUB_TOKEN }} +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should not warn on custom secrets when no provider configured", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "test" + env: + API_KEY: \${{ secrets.MY_API_KEY }} +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should not warn on secrets with environment", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - run: echo "test" + env: + API_KEY: \${{ secrets.API_KEY }} +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + }); + + describe("vars context", () => { + it("should not warn on vars when no provider configured", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "\${{ vars.ENVIRONMENT }}" +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should not warn on vars with environment", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - run: echo "\${{ vars.API_URL }}" +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should not warn on vars with fallback pattern", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "\${{ vars.OPTIONAL_VAR || 'default-value' }}" +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + }); + + describe("combined secrets and vars", () => { + it("should not warn on workflow using both secrets and vars", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - run: | + echo "Deploying to \${{ vars.API_URL }}" + echo "Using region \${{ vars.AWS_REGION }}" + env: + API_KEY: \${{ secrets.API_KEY }} + AWS_SECRET: \${{ secrets.AWS_SECRET_ACCESS_KEY }} +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + }); +});