Skip to content

refactor: extract login service #339

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 22, 2024
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
72 changes: 23 additions & 49 deletions src/cli/cmd-login.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { AuthenticationError, NpmLoginService } from "../services/npm-login";
import { AuthenticationError } from "../services/npm-login";
import { GetUpmConfigPath, GetUpmConfigPathError } from "../io/upm-config-io";
import { EnvParseError, ParseEnvService } from "../services/parse-env";
import { BasicAuth, encodeBasicAuth, TokenAuth } from "../domain/upm-config";
import { coerceRegistryUrl } from "../domain/registry-url";
import {
promptEmail,
Expand All @@ -12,9 +11,9 @@ import {
import { CmdOptions } from "./options";
import { Ok, Result } from "ts-results-es";
import { NpmrcLoadError, NpmrcSaveError } from "../io/npmrc-io";
import { AuthNpmrcService } from "../services/npmrc-auth";
import { Logger } from "npmlog";
import { SaveAuthToUpmConfig, UpmAuthStoreError } from "../services/upm-auth";
import { UpmAuthStoreError } from "../services/upm-auth";
import { LoginService } from "../services/login";

/**
* Errors which may occur when logging in.
Expand Down Expand Up @@ -53,10 +52,8 @@ export type LoginCmd = (
*/
export function makeLoginCmd(
parseEnv: ParseEnvService,
authNpmrc: AuthNpmrcService,
npmLogin: NpmLoginService,
getUpmConfigPath: GetUpmConfigPath,
saveAuthToUpmConfig: SaveAuthToUpmConfig,
login: LoginService,
log: Logger
): LoginCmd {
return async (options) => {
Expand All @@ -82,53 +79,30 @@ export function makeLoginCmd(
if (configPathResult.isErr()) return configPathResult;
const configPath = configPathResult.value;

if (options.basicAuth) {
// basic auth
const _auth = encodeBasicAuth(username, password);
const result = await saveAuthToUpmConfig(configPath, loginRegistry, {
email,
alwaysAuth,
_auth,
} satisfies BasicAuth).promise;
if (result.isErr()) return result;
log.notice("config", "saved unity config at " + configPath);
} else {
// npm login
const loginResult = await npmLogin(
loginRegistry,
username,
password,
email
).promise;
if (loginResult.isErr()) {
if (loginResult.error.status === 401)
const loginResult = await login(
username,
password,
email,
alwaysAuth,
loginRegistry,
configPath,
options.basicAuth ? "basic" : "token",
() => log.notice("auth", `you are authenticated as '${username}'`),
(npmrcPath) => log.notice("config", `saved to npm config: ${npmrcPath}`)
).promise;

if (loginResult.isErr()) {
const loginError = loginResult.error;
if (loginError instanceof AuthenticationError) {
if (loginError.status === 401)
log.warn("401", "Incorrect username or password");
else
log.error(
loginResult.error.status.toString(),
loginResult.error.message
);
return loginResult;
else log.error(loginError.status.toString(), loginError.message);
}
log.notice("auth", `you are authenticated as '${username}'`);
const token = loginResult.value;

// write npm token
const updateResult = await authNpmrc(loginRegistry, token).promise;
if (updateResult.isErr()) return updateResult;
updateResult.map((configPath) =>
log.notice("config", `saved to npm config: ${configPath}`)
);

const storeResult = await saveAuthToUpmConfig(configPath, loginRegistry, {
email,
alwaysAuth,
token,
} satisfies TokenAuth).promise;
if (storeResult.isErr()) return storeResult;
log.notice("config", "saved unity config at " + configPath);
return loginResult;
}

log.notice("config", "saved unity config at " + configPath);
return Ok(undefined);
};
}
13 changes: 4 additions & 9 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import {
import npmlog from "npmlog";
import { makeResolveLatestVersionService } from "../services/resolve-latest-version";
import {
makeUpmConfigPathGetter,
makeUpmConfigLoader,
makeUpmConfigPathGetter,
} from "../io/upm-config-io";
import { makeTextReader, makeTextWriter } from "../io/file-io";
import {
Expand All @@ -45,6 +45,7 @@ import { makeSaveAuthToUpmConfigService } from "../services/upm-auth";
import { makePackumentFetcher } from "../io/packument-io";
import { makePackagesSearcher } from "../services/search-packages";
import { makeRemotePackumentResolver } from "../services/resolve-remote-packument";
import { makeLoginService } from "../services/login";

// Composition root

Expand Down Expand Up @@ -88,6 +89,7 @@ const saveAuthToUpmConfig = makeSaveAuthToUpmConfigService(
writeFile
);
const searchPackages = makePackagesSearcher(searchRegistry, fetchAllPackuments);
const login = makeLoginService(saveAuthToUpmConfig, npmLogin, authNpmrc);

const addCmd = makeAddCmd(
parseEnv,
Expand All @@ -97,14 +99,7 @@ const addCmd = makeAddCmd(
writeProjectManifest,
log
);
const loginCmd = makeLoginCmd(
parseEnv,
authNpmrc,
npmLogin,
getUpmConfigPath,
saveAuthToUpmConfig,
log
);
const loginCmd = makeLoginCmd(parseEnv, getUpmConfigPath, login, log);
const searchCmd = makeSearchCmd(parseEnv, searchPackages, log);
const depsCmd = makeDepsCmd(parseEnv, resolveDependencies, log);
const removeCmd = makeRemoveCmd(
Expand Down
89 changes: 89 additions & 0 deletions src/services/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { AsyncResult } from "ts-results-es";
import { BasicAuth, encodeBasicAuth, TokenAuth } from "../domain/upm-config";
import { RegistryUrl } from "../domain/registry-url";
import { SaveAuthToUpmConfig, UpmAuthStoreError } from "./upm-auth";
import { NpmLoginError, NpmLoginService } from "./npm-login";
import { AuthNpmrcService, NpmrcAuthTokenUpdateError } from "./npmrc-auth";

/**
* Error which may occur when logging in a user.
*/
export type LoginError =
| UpmAuthStoreError
| NpmLoginError
| NpmrcAuthTokenUpdateError;

/**
* Function for logging in a user to a npm registry. Supports both basic and
* token-based authentication.
* @param username The username with which to login.
* @param password The password with which to login.
* @param email The email with which to login.
* @param alwaysAuth Whether to always authenticate.
* @param registry The url of the npm registry with which to authenticate.
* @param configPath Path of the upm-config file in which to store
* authentication information.
* @param authMode Whether to use basic or token-based authentication.
* @param onNpmAuthSuccess Callback that notifies when authentication
* with the npm registry was successful. Only called with token-based
* authentication.
* @param onNpmrcUpdated Callback that notifies when the .npmrc file was
* updated. Only called with token-based authentication.
*/
export type LoginService = (
username: string,
password: string,
email: string,
alwaysAuth: boolean,
registry: RegistryUrl,
configPath: string,
authMode: "basic" | "token",
onNpmAuthSuccess: () => void,
onNpmrcUpdated: (npmrcPath: string) => void
) => AsyncResult<void, LoginError>;

/**
* Makes a {@link LoginService} function.
*/
export function makeLoginService(
saveAuthToUpmConfig: SaveAuthToUpmConfig,
npmLogin: NpmLoginService,
authNpmrc: AuthNpmrcService
): LoginService {
return (
username,
password,
email,
alwaysAuth,
registry,
configPath,
authMode,
onNpmAuthSuccess,
onNpmrcUpdated
) => {
if (authMode === "basic") {
// basic auth
const _auth = encodeBasicAuth(username, password);
return saveAuthToUpmConfig(configPath, registry, {
email,
alwaysAuth,
_auth,
} satisfies BasicAuth);
}

// npm login
return npmLogin(registry, username, password, email).andThen((token) => {
onNpmAuthSuccess();
// write npm token
return authNpmrc(registry, token).andThen((npmrcPath) => {
onNpmrcUpdated(npmrcPath);
// Save config
return saveAuthToUpmConfig(configPath, registry, {
email,
alwaysAuth,
token,
} satisfies TokenAuth);
});
});
};
}
138 changes: 138 additions & 0 deletions test/cli/cmd-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { IOError } from "../../src/io/file-io";
import { Err, Ok } from "ts-results-es";
import { makeLoginCmd } from "../../src/cli/cmd-login";
import { mockService } from "../services/service.mock";
import { Env, ParseEnvService } from "../../src/services/parse-env";
import {
GetUpmConfigPath,
RequiredEnvMissingError,
} from "../../src/io/upm-config-io";
import { LoginService } from "../../src/services/login";
import { makeMockLogger } from "./log.mock";
import { exampleRegistryUrl } from "../domain/data-registry";
import { unityRegistryUrl } from "../../src/domain/registry-url";
import { makeEditorVersion } from "../../src/domain/editor-version";
import { AuthenticationError } from "../../src/services/npm-login";

const defaultEnv = {
cwd: "/users/some-user/projects/SomeProject",
upstream: true,
registry: { url: exampleRegistryUrl, auth: null },
upstreamRegistry: { url: unityRegistryUrl, auth: null },
editorVersion: makeEditorVersion(2022, 2, 1, "f", 2),
} as Env;
const exampleUser = "user";
const examplePassword = "pass";
const exampleEmail = "user@email.com";
const exampleUpmConfigPath = "/user/home/.upmconfig.toml";

describe("cmd-login", () => {
function makeDependencies() {
const parseEnv = mockService<ParseEnvService>();
parseEnv.mockResolvedValue(Ok(defaultEnv));

const getUpmConfigPath = mockService<GetUpmConfigPath>();
getUpmConfigPath.mockReturnValue(Ok(exampleUpmConfigPath).toAsyncResult());

const login = mockService<LoginService>();
login.mockReturnValue(Ok(undefined).toAsyncResult());

const log = makeMockLogger();

const loginCmd = makeLoginCmd(parseEnv, getUpmConfigPath, login, log);
return { loginCmd, parseEnv, getUpmConfigPath, login, log } as const;
}

it("should fail if env could not be parsed", async () => {
const expected = new IOError();
const { loginCmd, parseEnv } = makeDependencies();
parseEnv.mockResolvedValue(Err(expected));

const result = await loginCmd({ _global: {} });

expect(result).toBeError((actual) => expect(actual).toEqual(expected));
});

// TODO: Add tests for prompting logic

it("should fail if upm config path could not be determined", async () => {
const expected = new RequiredEnvMissingError();
const { loginCmd, getUpmConfigPath } = makeDependencies();
getUpmConfigPath.mockReturnValue(Err(expected).toAsyncResult());

const result = await loginCmd({
username: exampleUser,
password: examplePassword,
email: exampleEmail,
_global: { registry: exampleRegistryUrl },
});

expect(result).toBeError((actual) => expect(actual).toEqual(expected));
});

it("should fail if login failed", async () => {
const expected = new RequiredEnvMissingError();
const { loginCmd, login } = makeDependencies();
login.mockReturnValue(Err(expected).toAsyncResult());

const result = await loginCmd({
username: exampleUser,
password: examplePassword,
email: exampleEmail,
_global: { registry: exampleRegistryUrl },
});

expect(result).toBeError((actual) => expect(actual).toEqual(expected));
});

it("should notify if unauthorized", async () => {
const { loginCmd, login, log } = makeDependencies();
login.mockReturnValue(
Err(new AuthenticationError(401, "oof")).toAsyncResult()
);

await loginCmd({
username: exampleUser,
password: examplePassword,
email: exampleEmail,
_global: { registry: exampleRegistryUrl },
});

expect(log.warn).toHaveBeenCalledWith(
"401",
"Incorrect username or password"
);
});

it("should notify of other login errors", async () => {
const { loginCmd, login, log } = makeDependencies();
login.mockReturnValue(
Err(new AuthenticationError(500, "oof")).toAsyncResult()
);

await loginCmd({
username: exampleUser,
password: examplePassword,
email: exampleEmail,
_global: { registry: exampleRegistryUrl },
});

expect(log.error).toHaveBeenCalledWith("500", "oof");
});

it("should notify of success", async () => {
const { loginCmd, log } = makeDependencies();

await loginCmd({
username: exampleUser,
password: examplePassword,
email: exampleEmail,
_global: { registry: exampleRegistryUrl },
});

expect(log.notice).toHaveBeenCalledWith(
"config",
expect.stringContaining("saved unity config")
);
});
});
Loading
Loading