Skip to content

Commit

Permalink
feat: astro:env validateSecrets (#11337)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
florian-lefebvre and sarah11918 authored Jul 9, 2024
1 parent 75d118b commit 0a4b31f
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 63 deletions.
23 changes: 23 additions & 0 deletions .changeset/slow-roses-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'astro': patch
---

Adds a new property `experimental.env.validateSecrets` to allow validating private variables on the server.

By default, this is set to `false` and only public variables are checked on start. If enabled, secrets will also be checked on start (dev/build modes). This is useful for example in some CIs to make sure all your secrets are correctly set before deploying.

```js
// astro.config.mjs
import { defineConfig, envField } from "astro/config"

export default defineConfig({
experimental: {
env: {
schema: {
// ...
},
validateSecrets: true
}
}
})
```
31 changes: 31 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2166,6 +2166,37 @@ export interface AstroUserConfig {
* ```
*/
schema?: EnvSchema;

/**
* @docs
* @name experimental.env.validateSecrets
* @kind h4
* @type {boolean}
* @default `false`
* @version 4.11.6
* @description
*
* Whether or not to validate secrets on the server when starting the dev server or running a build.
*
* By default, only public variables are validated on the server when starting the dev server or a build, and private variables are validated at runtime only. If enabled, private variables will also be checked on start. This is useful in some continuous integration (CI) pipelines to make sure all your secrets are correctly set before deploying.
*
* ```js
* // astro.config.mjs
* import { defineConfig, envField } from "astro/config"
*
* export default defineConfig({
* experimental: {
* env: {
* schema: {
* // ...
* },
* validateSecrets: true
* }
* }
* })
* ```
*/
validateSecrets?: boolean;
};
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export const ASTRO_CONFIG_DEFAULTS = {
clientPrerender: false,
globalRoutePriority: false,
rewriting: false,
env: {
validateSecrets: false
}
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -526,6 +529,7 @@ export const AstroConfigSchema = z.object({
env: z
.object({
schema: EnvSchema.optional(),
validateSecrets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.env.validateSecrets)
})
.strict()
.optional(),
Expand Down
23 changes: 16 additions & 7 deletions packages/astro/src/env/vite-plugin-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export function astroEnv({
}
}

const validatedVariables = validatePublicVariables({ schema, loadedEnv });
const validatedVariables = validatePublicVariables({
schema,
loadedEnv,
validateSecrets: settings.config.experimental.env?.validateSecrets ?? false,
});

templates = {
...getTemplates(schema, fs, validatedVariables),
Expand Down Expand Up @@ -94,23 +98,28 @@ function resolveVirtualModuleId<T extends string>(id: T): `\0${T}` {
function validatePublicVariables({
schema,
loadedEnv,
validateSecrets,
}: {
schema: EnvSchema;
loadedEnv: Record<string, string>;
validateSecrets: boolean;
}) {
const valid: Array<{ key: string; value: any; type: string; context: 'server' | 'client' }> = [];
const invalid: Array<{ key: string; type: string }> = [];

for (const [key, options] of Object.entries(schema)) {
if (options.access !== 'public') {
const variable = loadedEnv[key] === '' ? undefined : loadedEnv[key];

if (options.access === 'secret' && !validateSecrets) {
continue;
}
const variable = loadedEnv[key];
const result = validateEnvVariable(variable === '' ? undefined : variable, options);
if (result.ok) {
valid.push({ key, value: result.value, type: result.type, context: options.context });
} else {

const result = validateEnvVariable(variable, options);
if (!result.ok) {
invalid.push({ key, type: result.type });
// We don't do anything with validated secrets so we don't store them
} else if (options.access === 'public') {
valid.push({ key, value: result.value, type: result.type, context: options.context });
}
}

Expand Down
125 changes: 69 additions & 56 deletions packages/astro/test/env-secret.test.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,90 @@
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { after, describe, it } from 'node:test';
import { afterEach, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js';

describe('astro:env public variables', () => {
describe('astro:env secret variables', () => {
/** @type {Awaited<ReturnType<typeof loadFixture>>} */
let fixture;
/** @type {Awaited<ReturnType<(typeof fixture)["loadTestAdapterApp"]>>} */
let app;
/** @type {Awaited<ReturnType<(typeof fixture)["startDevServer"]>>} */
/** @type {Awaited<ReturnType<(typeof fixture)["startDevServer"]>> | undefined} */
let devServer = undefined;

describe('Server variables', () => {
after(async () => {
await devServer?.stop();
afterEach(async () => {
await devServer?.stop();
if (process.env.KNOWN_SECRET) {
delete process.env.KNOWN_SECRET
}
});

it('works in dev', async () => {
process.env.KNOWN_SECRET = '5'
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
});
devServer = await fixture.startDevServer();
const response = await fixture.fetch('/');
assert.equal(response.status, 200);
});

it('works in dev', async () => {
writeFileSync(
new URL('./fixtures/astro-env-server-secret/.env', import.meta.url),
'KNOWN_SECRET=5',
'utf-8'
);
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
});
devServer = await fixture.startDevServer();
const response = await fixture.fetch('/');
assert.equal(response.status, 200);
it('builds without throwing', async () => {
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
output: 'server',
adapter: testAdapter({
env: {
KNOWN_SECRET: '123456',
UNKNOWN_SECRET: 'abc',
},
}),
});
await fixture.build();
assert.equal(true, true);
});

it('builds without throwing', async () => {
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
output: 'server',
adapter: testAdapter({
env: {
KNOWN_SECRET: '123456',
UNKNOWN_SECRET: 'abc',
},
}),
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
assert.equal(true, true);
it('adapter can set how env is retrieved', async () => {
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
output: 'server',
adapter: testAdapter({
env: {
KNOWN_SECRET: '123456',
UNKNOWN_SECRET: 'abc',
},
}),
});
await fixture.build();
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
assert.equal(response.status, 200);

it('adapter can set how env is retrieved', async () => {
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
output: 'server',
adapter: testAdapter({
env: {
KNOWN_SECRET: '123456',
UNKNOWN_SECRET: 'abc',
},
}),
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
assert.equal(response.status, 200);
const html = await response.text();
const $ = cheerio.load(html);

const html = await response.text();
const $ = cheerio.load(html);
const data = JSON.parse($('#data').text());

const data = JSON.parse($('#data').text());
assert.equal(data.KNOWN_SECRET, 123456);
assert.equal(data.UNKNOWN_SECRET, 'abc');
});

assert.equal(data.KNOWN_SECRET, 123456);
assert.equal(data.UNKNOWN_SECRET, 'abc');
it('fails if validateSecrets is enabled and secret is not set', async () => {
fixture = await loadFixture({
root: './fixtures/astro-env-server-secret/',
experimental: {
env: {
validateSecrets: true,
},
},
});

try {
await fixture.build();
assert.fail()
} catch (error) {
assert.equal(error instanceof Error, true);
assert.equal(error.title, 'Invalid Environment Variables');
assert.equal(error.message.includes('Variable KNOWN_SECRET is not of type: number.'), true);
}
});
});

0 comments on commit 0a4b31f

Please sign in to comment.