Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ yarn cli <command> [args]
- `dashboard`: Generate a new dashboard configuration to be imported in Datadog.
- `oss`: Make the code compliant with our Open Source rules.
- `integrity`: Automate the verification of the repository. (Also runs `oss` command).
- `reset-oauth`: Clear the cached Datadog Apps OAuth token to restart the authorization flow.
133 changes: 133 additions & 0 deletions packages/tools/src/commands/reset-oauth/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import {
getDatadogOAuthConfig,
readOAuthTokenFromKeychain,
writeOAuthTokenToKeychain,
} from '@dd/core/helpers/oauth-request';
import commands from '@dd/tools/commands/reset-oauth/index';
import { Cli } from 'clipanion';
import { Writable } from 'stream';

// Marker that makes the mocked keychain reject a delete, to exercise the
// command's error-collection branch.
const FORCE_DELETE_ERROR = 'FORCE_DELETE_ERROR';

const mockKeyringStore = new Map<string, string>();

jest.mock('@napi-rs/keyring', () => ({
AsyncEntry: class {
private readonly key: string;

constructor(service: string, username: string) {
this.key = `${service}:${username}`;
}

async deletePassword() {
const stored = mockKeyringStore.get(this.key);
if (stored && stored.includes(FORCE_DELETE_ERROR)) {
throw new Error('Simulated keychain failure.');
}
mockKeyringStore.delete(this.key);
}

async getPassword() {
return mockKeyringStore.get(this.key);
}

async setPassword(password: string) {
mockKeyringStore.set(this.key, password);
}
},
}));

const seedToken = async (site: string, accessToken = `token-${site}`) => {
const options = getDatadogOAuthConfig(site);
await writeOAuthTokenToKeychain(
site,
{ accessToken, clientId: options.clientId, site },
options,
);
};

const hasToken = async (site: string) => {
const token = await readOAuthTokenFromKeychain(site, getDatadogOAuthConfig(site));
return token !== undefined;
};

describe('Command reset-oauth', () => {
const cli = new Cli();
cli.register(commands[0]);

// Discard clipanion's own output (it writes the thrown-error report to
// stdout) to keep the test logs clean.
const discard = new Writable({
write(chunk, encoding, callback) {
callback();
},
});
const context = { ...Cli.defaultContext, stdout: discard, stderr: discard };
const run = (args: string[]) => cli.run(args, context);

let logSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;

beforeEach(() => {
mockKeyringStore.clear();
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
});

test('Should clear the token for every site by default.', async () => {
await seedToken('datadoghq.com');
await seedToken('datadoghq.eu');

const exitCode = await run(['reset-oauth']);

expect(exitCode).toBe(0);
expect(await hasToken('datadoghq.com')).toBe(false);
expect(await hasToken('datadoghq.eu')).toBe(false);
});

test('Should only clear the token for the targeted site.', async () => {
await seedToken('datadoghq.com');
await seedToken('datadoghq.eu');

const exitCode = await run(['reset-oauth', '--site', 'datadoghq.eu']);

expect(exitCode).toBe(0);
expect(await hasToken('datadoghq.eu')).toBe(false);
expect(await hasToken('datadoghq.com')).toBe(true);
});

test('Should reject an unknown site without touching stored tokens.', async () => {
await seedToken('datadoghq.com');

const exitCode = await run(['reset-oauth', '--site', 'nope.com']);

expect(exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown site "nope.com"'));
expect(await hasToken('datadoghq.com')).toBe(true);
});

test('Should succeed when no token is cached.', async () => {
const exitCode = await run(['reset-oauth', '--site', 'datadoghq.com']);

expect(exitCode).toBe(0);
});

test('Should report a non-zero exit code when a token cannot be cleared.', async () => {
await seedToken('datadoghq.com', FORCE_DELETE_ERROR);

const exitCode = await run(['reset-oauth', '--site', 'datadoghq.com']);

expect(exitCode).toBe(1);
});
});
73 changes: 73 additions & 0 deletions packages/tools/src/commands/reset-oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { Command, Option } from 'clipanion';

class ResetOAuth extends Command {
static paths = [['reset-oauth']];

static usage = Command.Usage({
category: `Contribution`,
description: `Clear the cached Datadog Apps OAuth token to restart the authorization flow.`,
details: `
OAuth tokens obtained by the Apps plugin are persisted in the OS credential store (keychain).

This command removes those cached tokens so the next upload triggers a fresh browser authorization.

By default it clears the token for every known Datadog site. Pass --site to target a single site.
`,
examples: [
[`Reset for all sites`, `$0 reset-oauth`],
[`Reset for a specific site`, `$0 reset-oauth --site datadoghq.eu`],
],
});

site = Option.String('--site', {
description: 'Only clear the token for this Datadog site (e.g. datadoghq.com).',
});

async execute() {
const { SITES } = await import('@dd/core/constants');
const { deleteOAuthTokenFromKeychain, getDatadogOAuthConfig } = await import(
'@dd/core/helpers/oauth-request'
);
const { green, red, dim } = await import('@dd/tools/helpers');

const isKnownSite = this.site && SITES.some((site) => site === this.site);
if (this.site && !isKnownSite) {
console.error(
red(`Unknown site "${this.site}". Supported sites: ${SITES.join(', ')}.`),
);
return 1;
}

const sites = this.site ? [this.site] : [...SITES];
const errors: string[] = [];

for (const site of sites) {
try {
const options = getDatadogOAuthConfig(site);
await deleteOAuthTokenFromKeychain(site, options);
console.log(` Cleared cached OAuth token for ${green(site)}.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`[${red('Error')}] ${site} - ${dim(message)}`);
}
}

if (errors.length > 0) {
console.error(errors.join('\n'));
throw new Error(
`Could not clear ${errors.length} OAuth token${errors.length > 1 ? 's' : ''}.`,
);
}

console.log(
green(`\nDone. The next Datadog Apps upload will start a fresh authorization.`),
);
return 0;
}
}

export default [ResetOAuth];
Loading