From 9faf6a594121d2fa677b80083722665fdb3597b3 Mon Sep 17 00:00:00 2001 From: pjdotson Date: Fri, 13 Sep 2024 15:12:34 -0700 Subject: [PATCH] feat(client/ts): Add support for changing user name, user first and last name, retrieving users by keys, and more to TypeScript client --- client/py/synnax/user/client.py | 4 +- client/ts/src/access/access.spec.ts | 14 +- client/ts/src/auth/auth.ts | 64 ++++++- client/ts/src/user/client.ts | 82 ++++++-- client/ts/src/user/payload.ts | 15 +- client/ts/src/user/retriever.ts | 41 ++++ client/ts/src/user/user.spec.ts | 281 ++++++++++++++++++++++++++++ client/ts/src/user/writer.ts | 96 ++++++++++ 8 files changed, 556 insertions(+), 41 deletions(-) create mode 100644 client/ts/src/user/retriever.ts create mode 100644 client/ts/src/user/user.spec.ts create mode 100644 client/ts/src/user/writer.ts diff --git a/client/py/synnax/user/client.py b/client/py/synnax/user/client.py index 82fd4fed65..d7da8a42a1 100644 --- a/client/py/synnax/user/client.py +++ b/client/py/synnax/user/client.py @@ -11,7 +11,7 @@ from synnax.auth import InsecureCredentials, TokenResponse from synnax.user.payload import UserPayload -_REGISTER_ENDPOINT = "/user/register" +_CREATE_ENDPOINT = "/user/create" class UserClient: @@ -26,7 +26,7 @@ def __init__( def register(self, username: str, password: str) -> UserPayload: return send_required( self.client, - _REGISTER_ENDPOINT, + _CREATE_ENDPOINT, InsecureCredentials(username=username, password=password), TokenResponse, ).user diff --git a/client/ts/src/access/access.spec.ts b/client/ts/src/access/access.spec.ts index 13db5bf2eb..841aa75c2d 100644 --- a/client/ts/src/access/access.spec.ts +++ b/client/ts/src/access/access.spec.ts @@ -21,9 +21,7 @@ import { SchematicOntologyType } from "@/workspace/schematic/payload"; const client = newClient(); -const sortByKey = (a: any, b: any) => { - return a.key.localeCompare(b.key); -}; +const sortByKey = (a: any, b: any) => a.key.localeCompare(b.key); describe("Policy", () => { describe("create", () => { @@ -274,7 +272,7 @@ describe("Policy", () => { describe("Registration", async () => { test("register a user", async () => { const username = id.id(); - await client.user.register(username, "pwd1"); + await client.user.create({ username, password: "pwd1" }); new Synnax({ host: HOST, port: PORT, @@ -284,14 +282,16 @@ describe("Registration", async () => { }); test("duplicate username", async () => { const username = id.id(); - await client.user.register(username, "pwd1"); - await expect(client.user.register(username, "pwd1")).rejects.toThrow(AuthError); + await client.user.create({ username, password: "pwd1" }); + await expect(client.user.create({ username, password: "pwd1" })).rejects.toThrow( + AuthError, + ); }); }); describe("privilege", async () => { test("new user", async () => { const username = id.id(); - const user2 = await client.user.register(username, "pwd1"); + const user2 = await client.user.create({ username, password: "pwd1" }); expect(user2).toBeDefined(); const client2 = new Synnax({ host: HOST, diff --git a/client/ts/src/auth/auth.ts b/client/ts/src/auth/auth.ts index c468355112..e3b2331efd 100644 --- a/client/ts/src/auth/auth.ts +++ b/client/ts/src/auth/auth.ts @@ -7,36 +7,51 @@ // License, use of this software will be governed by the Apache License, Version 2.0, // included in the file licenses/APL.txt. -import { type Middleware, type UnaryClient } from "@synnaxlabs/freighter"; +import { type Middleware, sendRequired, type UnaryClient } from "@synnaxlabs/freighter"; import { z } from "zod"; import { InvalidTokenError } from "@/errors"; import { user } from "@/user"; -export const insecureCredentialsZ = z.object({ +const insecureCredentialsZ = z.object({ username: z.string(), password: z.string(), }); -export type InsecureCredentials = z.infer; +type InsecureCredentials = z.infer; -export const tokenResponseZ = z.object({ +const tokenResponseZ = z.object({ token: z.string(), - user: user.payloadZ, + user: user.userZ, }); -export type TokenResponse = z.infer; - const LOGIN_ENDPOINT = "/auth/login"; const MAX_RETRIES = 3; +const CHANGE_USERNAME_ENDPOINT = "/auth/change-username"; +const CHANGE_PASSWORD_ENDPOINT = "/auth/change-password"; + +const changeUsernameReqZ = z.object({ + username: z.string(), + password: z.string(), + newUsername: z.string().min(1), +}); +const changeUsernameResZ = z.object({}); + +const changePasswordReqZ = z.object({ + username: z.string(), + password: z.string(), + newPassword: z.string().min(1), +}); +const changePasswordResZ = z.object({}); + export class Client { token: string | undefined; private readonly client: UnaryClient; private readonly credentials: InsecureCredentials; private authenticating: Promise | undefined; authenticated: boolean; - user: user.Payload | undefined; + user: user.User | undefined; private retryCount: number; constructor(client: UnaryClient, credentials: InsecureCredentials) { @@ -46,6 +61,39 @@ export class Client { this.retryCount = 0; } + async changeUsername(newUsername: string): Promise { + if (!this.authenticated || this.user == null) throw new Error("Not authenticated"); + await sendRequired( + this.client, + CHANGE_USERNAME_ENDPOINT, + { + username: this.credentials.username, + password: this.credentials.password, + newUsername, + }, + changeUsernameReqZ, + changeUsernameResZ, + ); + this.credentials.username = newUsername; + this.user.username = newUsername; + } + + async changePassword(newPassword: string): Promise { + if (!this.authenticated) throw new Error("Not authenticated"); + await sendRequired( + this.client, + CHANGE_PASSWORD_ENDPOINT, + { + username: this.credentials.username, + password: this.credentials.password, + newPassword, + }, + changePasswordReqZ, + changePasswordResZ, + ); + this.credentials.password = newPassword; + } + middleware(): Middleware { const mw: Middleware = async (reqCtx, next) => { if (!this.authenticated && !reqCtx.target.endsWith(LOGIN_ENDPOINT)) { diff --git a/client/ts/src/user/client.ts b/client/ts/src/user/client.ts index 68a5d2a549..4741a6ca14 100644 --- a/client/ts/src/user/client.ts +++ b/client/ts/src/user/client.ts @@ -7,31 +7,75 @@ // License, use of this software will be governed by the Apache License, Version 2.0, // included in the file licenses/APL.txt. -import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter"; +import { type UnaryClient } from "@synnaxlabs/freighter"; +import { toArray } from "@synnaxlabs/x"; -import { insecureCredentialsZ, tokenResponseZ } from "@/auth/auth"; -import { Payload } from "@/user/payload"; - -const REGISTER_ENDPOINT = "/user/register"; +import { MultipleFoundError, NotFoundError } from "@/errors"; +import { type Key, type NewUser, type User } from "@/user/payload"; +import { Retriever } from "@/user/retriever"; +import { Writer } from "@/user/writer"; export class Client { - private readonly client: UnaryClient; + private readonly reader: Retriever; + private readonly writer: Writer; constructor(client: UnaryClient) { - this.client = client; + this.writer = new Writer(client); + this.reader = new Retriever(client); + } + + async create(user: NewUser): Promise; + + async create(users: NewUser[]): Promise; + + async create(users: NewUser | NewUser[]): Promise { + const isMany = Array.isArray(users); + const res = await this.writer.create(users); + return isMany ? res : res[0]; } - async register(username: string, password: string): Promise { - const { user } = await sendRequired< - typeof insecureCredentialsZ, - typeof tokenResponseZ - >( - this.client, - REGISTER_ENDPOINT, - { username: username, password: password }, - insecureCredentialsZ, - tokenResponseZ, - ); - return user; + async changeUsername(key: Key, newUsername: string): Promise { + await this.writer.changeUsername(key, newUsername); + } + + async retrieve(key: Key): Promise; + + async retrieve(keys: Key[]): Promise; + + async retrieve(keys: Key | Key[]): Promise { + const isMany = Array.isArray(keys); + const res = await this.reader.retrieve({ keys: toArray(keys) }); + if (isMany) return res; + if (res.length === 0) throw new NotFoundError(`No user with key ${keys} found`); + if (res.length > 1) + throw new MultipleFoundError(`Multiple users found with key ${keys}`); + return res[0]; + } + + async retrieveByName(username: string): Promise; + + async retrieveByName(usernames: string[]): Promise; + + async retrieveByName(usernames: string | string[]): Promise { + const isMany = Array.isArray(usernames); + const res = await this.reader.retrieve({ usernames: toArray(usernames) }); + if (isMany) return res; + if (res.length === 0) + throw new NotFoundError(`No user with username ${usernames} found`); + if (res.length > 1) + throw new MultipleFoundError(`Multiple users found with username ${usernames}`); + return res[0]; + } + + async changeName(key: Key, firstName?: string, lastName?: string): Promise { + await this.writer.changeName(key, firstName, lastName); + } + + async delete(key: Key): Promise; + + async delete(keys: Key[]): Promise; + + async delete(keys: Key | Key[]): Promise { + await this.writer.delete(toArray(keys)); } } diff --git a/client/ts/src/user/payload.ts b/client/ts/src/user/payload.ts index e6a0c51073..b7793de25f 100644 --- a/client/ts/src/user/payload.ts +++ b/client/ts/src/user/payload.ts @@ -12,15 +12,20 @@ import { z } from "zod"; import { ontology } from "@/ontology"; export const keyZ = z.string().uuid(); - export type Key = z.infer; -export const payloadZ = z.object({ - key: z.string(), - username: z.string(), +export const userZ = z.object({ + key: keyZ, + username: z.string().min(1), + firstName: z.string().optional(), + lastName: z.string().optional(), }); +export type User = z.infer; -export type Payload = z.infer; +export const newUserZ = userZ + .omit({ key: true }) + .extend({ password: z.string().min(1) }); +export type NewUser = z.infer; export const UserOntologyType = "user" as ontology.ResourceType; diff --git a/client/ts/src/user/retriever.ts b/client/ts/src/user/retriever.ts new file mode 100644 index 0000000000..3021545996 --- /dev/null +++ b/client/ts/src/user/retriever.ts @@ -0,0 +1,41 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in the file +// licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business Source +// License, use of this software will be governed by the Apache License, Version 2.0, +// included in the file licenses/APL.txt. + +import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter"; +import { z } from "zod"; + +import { keyZ, type User, userZ } from "@/user/payload"; +import { nullableArrayZ } from "@/util/zod"; + +const reqZ = z.object({ + keys: keyZ.array().optional(), + usernames: z.string().array().optional(), +}); +type Request = z.infer; +const resZ = z.object({ users: nullableArrayZ(userZ) }); +const RETRIEVE_ENDPOINT = "/user/retrieve"; + +export class Retriever { + private readonly client: UnaryClient; + + constructor(client: UnaryClient) { + this.client = client; + } + + async retrieve(req: Request): Promise { + const res = await sendRequired( + this.client, + RETRIEVE_ENDPOINT, + req, + reqZ, + resZ, + ); + return res.users; + } +} diff --git a/client/ts/src/user/user.spec.ts b/client/ts/src/user/user.spec.ts new file mode 100644 index 0000000000..ff509d6f57 --- /dev/null +++ b/client/ts/src/user/user.spec.ts @@ -0,0 +1,281 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in the file +// licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business Source +// License, use of this software will be governed by the Apache License, Version 2.0, +// included in the file licenses/APL.txt. + +import { id } from "@synnaxlabs/x"; +import { describe, expect, test } from "vitest"; + +import { AuthError, NotFoundError } from "@/errors"; +import { newClient } from "@/setupspecs"; +import { type user } from "@/user"; + +type TestingUser = user.NewUser & { key?: string }; + +type SortType = { username: string }; + +const sort = (a: SortType, b: SortType) => a.username.localeCompare(b.username); + +const client = newClient(); + +const userOne: TestingUser = { + username: id.id(), + password: "test", + firstName: "George", + lastName: "Washington", +}; + +const userTwo: TestingUser = { username: id.id(), password: "test" }; + +const userThree: TestingUser = { + username: id.id(), + password: "test", + firstName: "John", + lastName: "Adams", +}; + +const userArray: TestingUser[] = [ + { username: id.id(), password: "secondTest", firstName: "Steve" }, + { username: id.id(), password: "testArray" }, +].sort(sort); + +describe("User", () => { + describe("Create", () => { + describe("One", () => { + test("with a name", async () => { + const res = await client.user.create(userOne); + expect(res.username).toEqual(userOne.username); + expect(res.key).not.toEqual(""); + expect(res.firstName).toEqual(userOne.firstName); + expect(res.lastName).toEqual(userOne.lastName); + userOne.key = res.key; + }); + test("with no name", async () => { + const res = await client.user.create(userTwo); + expect(res.username).toEqual(userTwo.username); + expect(res.key).not.toEqual(""); + userTwo.key = res.key; + expect(res.firstName).toEqual(""); + expect(res.lastName).toEqual(""); + }); + test("Repeated username", async () => + await expect( + client.user.create({ username: userOne.username, password: "test" }), + ).rejects.toThrow(AuthError)); + }); + describe("Many", () => { + test("array empty", async () => { + const res = await client.user.create([]); + expect(res).toHaveLength(0); + }); + test("array is one", async () => { + const res = await client.user.create([userThree]); + expect(res).toHaveLength(1); + expect(res[0].username).toEqual(userThree.username); + expect(res[0].key).not.toEqual(""); + userThree.key = res[0].key; + expect(res[0].firstName).toEqual(userThree.firstName); + expect(res[0].lastName).toEqual(userThree.lastName); + }); + test("array not empty", async () => { + const res = await client.user.create(userArray); + expect(res).toHaveLength(2); + userArray.forEach((u, i) => { + expect(res[i].username).toEqual(u.username); + expect(res[i].key).not.toEqual(""); + userArray[i].key = res[i].key; + expect(res[i].firstName).toEqual(u.firstName ?? ""); + expect(res[i].lastName).toEqual(u.lastName ?? ""); + }); + }); + test("Repeated username", async () => + await expect(client.user.create([userOne, userTwo])).rejects.toThrow( + AuthError, + )); + }); + }); + describe("Retrieve", () => { + describe("by name", () => { + describe("one", () => { + test("found", async () => { + const res = await client.user.retrieveByName(userOne.username); + expect(res.username).toEqual(userOne.username); + expect(res.key).toEqual(userOne.key); + expect(res.firstName).toEqual(userOne.firstName); + expect(res.lastName).toEqual(userOne.lastName); + }); + test("not found", async () => + await expect(client.user.retrieveByName(id.id())).rejects.toThrow( + NotFoundError, + )); + }); + describe("many", () => { + test("found", async () => { + const res = await client.user.retrieveByName( + userArray.map((u) => u.username), + ); + expect(res.sort(sort)).toHaveLength(2); + res.forEach((u, i) => { + expect(u.username).toEqual(userArray[i].username); + expect(u.key).toEqual(userArray[i].key); + expect(u.firstName).toEqual(userArray[i].firstName ?? ""); + expect(u.lastName).toEqual(userArray[i].lastName ?? ""); + }); + }); + test("not found", async () => { + const res = await client.user.retrieveByName([id.id()]); + expect(res).toEqual([]); + }); + test("extra names getting deleted", async () => { + const res = await client.user.retrieveByName([ + ...userArray.map((u) => u.username), + id.id(), + ]); + expect(res.sort(sort)).toHaveLength(2); + res.forEach((u, i) => { + expect(u.username).toEqual(userArray[i].username); + expect(u.key).toEqual(userArray[i].key); + expect(u.firstName).toEqual(userArray[i].firstName ?? ""); + expect(u.lastName).toEqual(userArray[i].lastName ?? ""); + }); + }); + test("calling with no names", async () => { + const res = await client.user.retrieveByName([]); + const usernames = res.map((u) => u.username); + expect(usernames).toContain(userOne.username); + expect(usernames).toContain(userTwo.username); + expect(usernames).toContain(userThree.username); + userArray.forEach((u) => expect(usernames).toContain(u.username)); + }); + }); + }); + describe("by key", () => { + describe("one", () => { + test("found", async () => { + const res = await client.user.retrieve(userOne.key as string); + expect(res.username).toEqual(userOne.username); + expect(res.key).toEqual(userOne.key); + expect(res.firstName).toEqual(userOne.firstName); + expect(res.lastName).toEqual(userOne.lastName); + }); + test("not found", async () => { + await expect( + client.user.delete(userOne.key as string), + ).resolves.toBeUndefined(); + await expect(client.user.retrieve(userOne.key as string)).rejects.toThrow( + NotFoundError, + ); + const u = await client.user.create(userOne); + userOne.key = u.key; + }); + }); + describe("many", () => { + test("found", async () => { + const res = await client.user.retrieve(userArray.map((u) => u.key as string)); + expect(res.sort(sort)).toHaveLength(2); + res.forEach((u, i) => { + expect(u.username).toEqual(userArray[i].username); + expect(u.key).toEqual(userArray[i].key); + expect(u.firstName).toEqual(userArray[i].firstName ?? ""); + expect(u.lastName).toEqual(userArray[i].lastName ?? ""); + }); + }); + test("not found", async () => { + for (const u of userArray) { + await expect(client.user.delete(u.key as string)).resolves.toBeUndefined(); + } + await expect( + client.user.retrieve(userArray.map((u) => u.key as string)), + ).rejects.toThrow(NotFoundError); + // cleanup + const users = await client.user.create(userArray); + users.forEach((u, i) => (userArray[i].key = u.key)); + }); + test("all", async () => { + const res = await client.user.retrieve([]); + const usernames = res.map((u) => u.username); + expect(usernames).toContain(userOne.username); + expect(usernames).toContain(userTwo.username); + expect(usernames).toContain(userThree.username); + userArray.forEach((u) => expect(usernames).toContain(u.username)); + }); + }); + }); + }); + describe("Change Username", () => { + test("Successful", async () => { + const newUsername = id.id(); + await expect( + client.user.changeUsername(userOne.key as string, newUsername), + ).resolves.toBeUndefined(); + const res = await client.user.retrieveByName(newUsername); + expect(res.username).toEqual(newUsername); + expect(res.key).not.toEqual(""); + expect(res.firstName).toEqual(userOne.firstName); + expect(res.lastName).toEqual(userOne.lastName); + userOne.username = newUsername; + }); + test("Unsuccessful", async () => + await expect( + client.user.changeUsername(userTwo.key as string, userOne.username), + ).rejects.toThrow(AuthError)); + }); + describe("Change Name", () => { + test("Successful", async () => { + await expect( + client.user.changeName(userOne.key as string, "Thomas", "Jefferson"), + ).resolves.toBeUndefined(); + const res = await client.user.retrieve(userOne.key as string); + expect(res.username).toEqual(userOne.username); + expect(res.key).toEqual(userOne.key); + expect(res.firstName).toEqual("Thomas"); + expect(res.lastName).toEqual("Jefferson"); + userOne.firstName = "Thomas"; + userOne.lastName = "Jefferson"; + }); + test("Only one name", async () => { + await expect( + client.user.changeName(userOne.key as string, "James"), + ).resolves.toBeUndefined(); + const res = await client.user.retrieve(userOne.key as string); + expect(res.username).toEqual(userOne.username); + expect(res.key).toEqual(userOne.key); + expect(res.firstName).toEqual("James"); + expect(res.lastName).toEqual(userOne.lastName); + userOne.firstName = "James"; + }); + }); + describe("Delete", () => { + test("one that exists", async () => { + await expect(client.user.delete(userOne.key as string)).resolves.toBeUndefined(); + await expect(client.user.retrieve(userOne.key as string)).rejects.toThrow( + NotFoundError, + ); + }); + test("many that exist", async () => { + await expect( + client.user.delete(userArray.map((u) => u.key as string)), + ).resolves.toBeUndefined(); + await expect( + client.user.retrieve(userArray.map((u) => u.key as string)), + ).rejects.toThrow(NotFoundError); + }); + // Skipping this test because errors currently get thrown on this function - not + // idempotent on server-side + test.skip("one that doesn't exist", async () => { + await expect(client.user.delete(userOne.key as string)).resolves.toBeUndefined(); + }); + test.skip("many where some don't exist", async () => { + await expect( + client.user.delete([userOne.key as string, userTwo.key as string]), + ).resolves.toBeUndefined(); + await expect(client.user.retrieve(userTwo.key as string)).rejects.toThrow( + NotFoundError, + ); + }); + }); +}); diff --git a/client/ts/src/user/writer.ts b/client/ts/src/user/writer.ts new file mode 100644 index 0000000000..80adb2d7c8 --- /dev/null +++ b/client/ts/src/user/writer.ts @@ -0,0 +1,96 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in the file +// licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business Source +// License, use of this software will be governed by the Apache License, Version 2.0, +// included in the file licenses/APL.txt. + +import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter"; +import { toArray } from "@synnaxlabs/x/toArray"; +import { z } from "zod"; + +import { + type Key, + keyZ, + type NewUser, + newUserZ, + type User, + userZ, +} from "@/user/payload"; + +const createReqZ = z.object({ users: newUserZ.array() }); +const createResZ = z.object({ users: userZ.array() }); + +const changeUsernameReqZ = z.object({ + key: keyZ, + username: z.string().min(1), +}); +const changeUsernameResZ = z.object({}); + +const changeNameReqZ = z.object({ + key: keyZ, + firstName: z.string().optional(), + lastName: z.string().optional(), +}); +const changeNameResZ = z.object({}); + +const deleteReqZ = z.object({ + keys: keyZ.array(), +}); +const deleteResZ = z.object({}); + +const CREATE_ENDPOINT = "/user/create"; +const CHANGE_USERNAME_ENDPOINT = "/user/change-username"; +const CHANGE_NAME_ENDPOINT = "/user/change-name"; +const DELETE_ENDPOINT = "/user/delete"; + +export class Writer { + private readonly client: UnaryClient; + + constructor(client: UnaryClient) { + this.client = client; + } + + async create(users: NewUser | NewUser[]): Promise { + const res = await sendRequired( + this.client, + CREATE_ENDPOINT, + { users: toArray(users) }, + createReqZ, + createResZ, + ); + return res.users; + } + + async changeUsername(key: Key, newUsername: string): Promise { + await sendRequired( + this.client, + CHANGE_USERNAME_ENDPOINT, + { key, username: newUsername }, + changeUsernameReqZ, + changeUsernameResZ, + ); + } + + async changeName(key: Key, firstName?: string, lastName?: string): Promise { + await sendRequired( + this.client, + CHANGE_NAME_ENDPOINT, + { key, firstName, lastName }, + changeNameReqZ, + changeNameResZ, + ); + } + + async delete(keys: Key[]): Promise { + await sendRequired( + this.client, + DELETE_ENDPOINT, + { keys }, + deleteReqZ, + deleteResZ, + ); + } +}