Skip to content
Merged
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
4 changes: 2 additions & 2 deletions features/record/common.feature
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Feature: record common
Scenario: No authorization information
When I run the command with args "record export --base-url $$TEST_KINTONE_BASE_URL --app 1"
Then I should get the exit code is non-zero
And The output error message should match with the pattern: "\[401\] \[CB_AU01\] Please login."
And The output error message should match with the pattern: "Authentication required \(login or API token\)"

Scenario: Incorrect API token
When I run the command with args "record export --base-url $$TEST_KINTONE_BASE_URL --app 1 --api-token abc"
Expand All @@ -82,7 +82,7 @@ Feature: record common
Given Load username and password of user "user_for_common" as env vars: "USERNAME" and "PASSWORD"
When I run the command with args "record export --base-url $$TEST_KINTONE_BASE_URL --app 1 --username $USERNAME"
Then I should get the exit code is non-zero
And The output error message should match with the pattern: "\[401\] \[CB_WA01\] Password authentication failed."
And The output error message should match with the pattern: "Authentication required \(login or API token\)"

Scenario: Incorrect password
Given Load username and password of user "user_for_common" as env vars: "USERNAME" and "PASSWORD"
Expand Down
4 changes: 2 additions & 2 deletions features/record/delete.feature
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Feature: record delete
And Load app ID of the app "app_for_delete" as env var: "APP_ID"
And Load app token of the app "app_for_delete" with exact permissions "view,delete" as env var: "API_TOKEN"
And Load username and password of the app "app_for_delete" with exact permissions "add" as env vars: "USERNAME" and "PASSWORD"
When I run the command with args "record delete --app $APP_ID --base-url $$TEST_KINTONE_BASE_URL --api-token $API_TOKEN --username $USERNAME --password $PASSWORD --yes"
When I run the command with args "record delete --app $APP_ID --base-url $$TEST_KINTONE_BASE_URL --api-token $API_TOKEN --yes"
Then I should get the exit code is zero
And The app "app_for_delete" should have no records

Expand Down Expand Up @@ -99,7 +99,7 @@ Feature: record delete
And Load username and password of the app "app_for_delete" with exact permissions "view,delete" as env vars: "USERNAME" and "PASSWORD"
When I run the command with args "record delete --app $APP_ID --base-url $$TEST_KINTONE_BASE_URL --username $USERNAME --password $PASSWORD --yes"
Then I should get the exit code is non-zero
And The output error message should match with the pattern: "ERROR: The delete command only supports API token authentication."
And The output error message should match with the pattern: "Authentication required \(API token\)"

@serial(app_for_delete)
Scenario: Specify records with a file
Expand Down
21 changes: 0 additions & 21 deletions src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,6 @@ describe("api", () => {
});
});

it("should prioritize username and password over apiToken", () => {
const apiClient = buildRestAPIClient({
baseUrl: BASE_URL,
username: USERNAME,
password: PASSWORD,
apiToken: API_TOKEN,
});
expect(apiClient).toBeInstanceOf(KintoneRestAPIClient);
expect(KintoneRestAPIClient).toHaveBeenCalledWith({
baseUrl: BASE_URL,
auth: {
username: USERNAME,
password: PASSWORD,
},
userAgent: expectedUa,
httpsAgent: new https.Agent(),
proxy: false,
socketTimeout: DEFAULT_SOCKET_TIMEOUT,
});
});

it("should pass basic auth params to the apiClient correctly", () => {
const BASIC_AUTH_USERNAME = "basic_auth_username";
const BASIC_AUTH_PASSWORD = "basic_auth_password";
Expand Down
104 changes: 104 additions & 0 deletions src/cli/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { vi } from "vitest";
import childProcess from "child_process";
import { promisify } from "util";
import path from "path";

const projectRoot = path.resolve(__dirname, "../../../");
const exec = promisify(childProcess.exec);
const packageJson = require(path.resolve(projectRoot, "package.json"));
const mainFilePath = path.resolve(projectRoot, packageJson.bin["cli-kintone"]);

vi.setConfig({ testTimeout: 30000 });

// Remove auth env vars to test auth validation
const envWithoutAuth = (() => {
const {
KINTONE_USERNAME: _u,
KINTONE_PASSWORD: _p,
KINTONE_API_TOKEN: _t,
...rest
} = process.env;
return rest as NodeJS.ProcessEnv;
})();

const checkRejectArg = ({
arg,
errorMessage,
}: {
arg: string;
errorMessage: string | RegExp;
}) => {
return expect(
exec(`cross-env LC_ALL='en_US' node ${mainFilePath} ${arg}`, {
env: envWithoutAuth,
}),
).rejects.toThrow(errorMessage);
};

describe("record import", () => {
it("should reject when no auth is provided", () => {
return checkRejectArg({
arg: "record import --base-url https://example.com --app 1 --file-path /tmp/test.csv",
errorMessage: /Authentication required \(login or API token\)/,
});
});
});

describe("record export", () => {
it("should reject when no auth is provided", () => {
return checkRejectArg({
arg: "record export --base-url https://example.com --app 1",
errorMessage: /Authentication required \(login or API token\)/,
});
});
});

describe("record delete", () => {
it("should reject when no auth is provided", () => {
return checkRejectArg({
arg: "record delete --base-url https://example.com --app 1 --yes",
errorMessage: /Authentication required \(API token\)/,
});
});

it("should reject --username with auth required error", () => {
return checkRejectArg({
arg: "record delete --base-url https://example.com --app 1 --yes --username user",
errorMessage: /Authentication required \(API token\)/,
});
});
});

describe("plugin upload", () => {
it("should reject when no auth is provided", () => {
return checkRejectArg({
arg: "plugin upload --base-url https://example.com --input /tmp/plugin.zip",
errorMessage: /Authentication required \(login\)/,
});
});

it("should reject --api-token with auth required error", () => {
return checkRejectArg({
arg: "plugin upload --base-url https://example.com --input /tmp/plugin.zip --api-token xxx",
errorMessage: /Authentication required \(login\)/,
});
});
});

describe("customize apply", () => {
it("should reject when no auth is provided", () => {
return checkRejectArg({
arg: "customize apply --base-url https://example.com --app 1 --input /tmp/manifest.json",
errorMessage: /Authentication required \(login\)/,
});
});
});

describe("customize export", () => {
it("should reject when no auth is provided", () => {
return checkRejectArg({
arg: "customize export --base-url https://example.com --app 1",
errorMessage: /Authentication required \(login\)/,
});
});
});
110 changes: 110 additions & 0 deletions src/cli/__tests__/connectionOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import yargsFactory from "yargs";
import { buildConnectionOptions } from "../connectionOptions";

const buildParser = (
config: Parameters<typeof buildConnectionOptions>[1],
args: string[],
) => {
return buildConnectionOptions(yargsFactory(args), config)
.option("base-url", { default: "https://example.com" })
.exitProcess(false)
.fail((msg, err) => {
throw err || new Error(msg);
});
};

describe("buildConnectionOptions with password auth", () => {
const config = { auth: ["password"] } as const;

it("should accept when username and password are provided", () => {
expect(() =>
buildParser(config, [
"--username",
"user",
"--password",
"pw",
]).parseSync(),
).not.toThrow();
});

it("should reject when only username is provided", () => {
expect(() =>
buildParser(config, ["--username", "user"]).parseSync(),
).toThrow("Authentication required (login)");
});

it("should reject when only password is provided", () => {
expect(() => buildParser(config, ["--password", "pw"]).parseSync()).toThrow(
"Authentication required (login)",
);
});

it("should reject when no auth is provided", () => {
expect(() => buildParser(config, []).parseSync()).toThrow(
"Authentication required (login)",
);
});
});

describe("buildConnectionOptions with apiToken auth", () => {
const config = { auth: ["apiToken"] } as const;

it("should accept when api-token is provided", () => {
expect(() =>
buildParser(config, ["--api-token", "token"]).parseSync(),
).not.toThrow();
});

it("should reject when no auth is provided", () => {
expect(() => buildParser(config, []).parseSync()).toThrow(
"Authentication required (API token)",
);
});
});

describe("buildConnectionOptions with either auth", () => {
const config = { auth: ["password", "apiToken"] } as const;

it("should accept when username and password are provided", () => {
expect(() =>
buildParser(config, [
"--username",
"user",
"--password",
"pw",
]).parseSync(),
).not.toThrow();
});

it("should accept when only api-token is provided", () => {
expect(() =>
buildParser(config, ["--api-token", "token"]).parseSync(),
).not.toThrow();
});

it("should accept when both are provided and clear api-token", async () => {
const argv = await buildParser(config, [
"--username",
"user",
"--password",
"pw",
"--api-token",
"token",
]).parse();

expect(argv.username).toBe("user");
expect(argv["api-token"]).toBeUndefined();
});

it("should reject when only username is provided without password", () => {
expect(() =>
buildParser(config, ["--username", "user"]).parseSync(),
).toThrow("Authentication required (login or API token)");
});

it("should reject when no auth is provided", () => {
expect(() => buildParser(config, []).parseSync()).toThrow(
"Authentication required (login or API token)",
);
});
});
37 changes: 37 additions & 0 deletions src/cli/authOptions/apiTokenAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { AuthModule } from "./types";
import type yargs from "yargs";

type OptionsDefinition = Parameters<yargs.Argv["options"]>[0];

const options = {
"api-token": {
describe: "App's API token",
default: process.env.KINTONE_API_TOKEN,
defaultDescription: "KINTONE_API_TOKEN",
type: "array",
string: true,
requiresArg: true,
},
} satisfies OptionsDefinition;

export const apiTokenAuth = {
label: "API token",
priority: 1,
options,
hiddenOptions: {
"api-token": { ...options["api-token"], hidden: true },
},
check: (argv) => {
const apiToken = argv["api-token"];
if (!apiToken) {
return false;
}
if (Array.isArray(apiToken)) {
return apiToken.some(Boolean);
}
return !!apiToken;
},
clear: (argv) => {
argv["api-token"] = undefined;
},
} satisfies AuthModule;
36 changes: 36 additions & 0 deletions src/cli/authOptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type { AuthArgv, AuthModule, AuthResolvedArgv } from "./types";

import type { AuthArgv, AuthModule } from "./types";
import { passwordAuth } from "./passwordAuth";
import { apiTokenAuth } from "./apiTokenAuth";

export type AuthMethod = "password" | "apiToken";

export const authModules = {
password: passwordAuth,
apiToken: apiTokenAuth,
} satisfies Record<AuthMethod, AuthModule>;

export const checkAuth = (
methods: readonly AuthMethod[],
argv: AuthArgv,
): true => {
if (methods.some((m) => authModules[m].check(argv))) {
return true;
}
const description = methods.map((m) => authModules[m].label).join(" or ");
throw new Error(`Authentication required (${description})`);
};

export const resolveAuthPriority = (
methods: readonly AuthMethod[],
argv: AuthArgv,
): void => {
const matched = methods
.filter((m) => authModules[m].check(argv))
.sort((a, b) => authModules[a].priority - authModules[b].priority);

for (const rest of matched.slice(1)) {
authModules[rest].clear(argv);
}
};
38 changes: 38 additions & 0 deletions src/cli/authOptions/passwordAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { AuthModule } from "./types";
import type yargs from "yargs";

type OptionsDefinition = Parameters<yargs.Argv["options"]>[0];

const options = {
username: {
alias: "u",
describe: "Kintone Username",
default: process.env.KINTONE_USERNAME,
defaultDescription: "KINTONE_USERNAME",
type: "string",
requiresArg: true,
},
password: {
alias: "p",
describe: "Kintone Password",
default: process.env.KINTONE_PASSWORD,
defaultDescription: "KINTONE_PASSWORD",
type: "string",
requiresArg: true,
},
} satisfies OptionsDefinition;

export const passwordAuth = {
label: "login",
priority: 0,
options,
hiddenOptions: {
username: { ...options.username, hidden: true },
password: { ...options.password, hidden: true },
},
check: (argv) => !!argv.username && !!argv.password,
clear: (argv) => {
argv.username = undefined;
argv.password = undefined;
},
} satisfies AuthModule;
Loading
Loading