Set up client tests
These are definitely temporary tests, as testing the lcient API will become more difficult and require more infrastructure as real data starts entering the system.
This commit is contained in:
parent
182020e136
commit
0f99283c86
visions
@ -10,3 +10,7 @@ tasks:
|
||||
- npm install typescript
|
||||
- typeshare --lang typescript --output-file gen/types.ts ../server/src
|
||||
- npx tsc
|
||||
|
||||
test:
|
||||
cmds:
|
||||
- npx jest src/
|
||||
|
7
visions/client/jest.config.js
Normal file
7
visions/client/jest.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
};
|
7379
visions/client/package-lock.json
generated
7379
visions/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,8 @@
|
||||
"tabWidth": 4
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.5.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.7.3"
|
||||
|
97
visions/client/src/client.test.ts
Normal file
97
visions/client/src/client.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { Connection } from './client'
|
||||
|
||||
describe('what happens in an authentication', () => {
|
||||
it('handles a successful response', async () => {
|
||||
let client = new Connection(new URL('http://127.0.0.1:8001'))
|
||||
let response = await client.auth('vakarian', 'aoeu')
|
||||
expect(response).toEqual({
|
||||
status: 'ok',
|
||||
content: 'vakarian-session-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles an authentication failure', async () => {
|
||||
let client = new Connection(new URL('http://127.0.0.1:8001'))
|
||||
{
|
||||
let response = await client.auth('vakarian', '')
|
||||
expect(response).toEqual({ status: 'unauthorized' })
|
||||
}
|
||||
|
||||
{
|
||||
let response = await client.auth('grunt', '')
|
||||
expect(response).toEqual({ status: 'unauthorized' })
|
||||
}
|
||||
})
|
||||
|
||||
it('handles a password-reset condition', async () => {
|
||||
let client = new Connection(new URL('http://127.0.0.1:8001'))
|
||||
{
|
||||
let response = await client.auth('shephard', 'aoeu')
|
||||
expect(response).toEqual({
|
||||
status: 'password-reset',
|
||||
content: 'shephard-session-id',
|
||||
})
|
||||
}
|
||||
{
|
||||
let response = await client.auth('shephard', '')
|
||||
expect(response).toEqual({ status: 'unauthorized' })
|
||||
}
|
||||
})
|
||||
|
||||
it('lists users on an authenticated connection', async () => {
|
||||
let client = new Connection(new URL('http://127.0.0.1:8001'))
|
||||
{
|
||||
let authResponse = await client.auth('vakarian', 'aoeu')
|
||||
if (authResponse.status === 'ok') {
|
||||
let sessionId = authResponse.content
|
||||
let response = await client.listUsers(sessionId)
|
||||
expect(response).toEqual({
|
||||
status: 'ok',
|
||||
content: [
|
||||
{
|
||||
id: 'vakarian-id',
|
||||
name: 'vakarian',
|
||||
status: { type: 'ok', content: undefined },
|
||||
},
|
||||
{
|
||||
id: 'shephard-id',
|
||||
name: 'shephard',
|
||||
status: {
|
||||
type: 'password-reset',
|
||||
content: '2050-01-01 00:00:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tali-id',
|
||||
name: 'tali',
|
||||
status: { type: 'locked', content: undefined },
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
throw new Error('authorization should have been ok')
|
||||
}
|
||||
}
|
||||
{
|
||||
let authResponse = await client.auth('shephard', 'aoeu')
|
||||
if (authResponse.status === 'password-reset') {
|
||||
let sessionId = authResponse.content
|
||||
let response = await client.listUsers(sessionId)
|
||||
expect(response).toEqual({ status: 'unauthorized' })
|
||||
} else {
|
||||
throw new Error('authorization should have been password-reset')
|
||||
}
|
||||
}
|
||||
/*
|
||||
{
|
||||
let response = await client.listUsers('');
|
||||
expect(response).toEqual({ status: 'unauthorized' })
|
||||
}
|
||||
{
|
||||
let authResponse = await client.auth('shephard', 'aoeu')
|
||||
let response = await client.listUsers(authResponse.content);
|
||||
expect(response).toEqual({ status: 'password-reset' })
|
||||
}
|
||||
*/
|
||||
})
|
||||
})
|
@ -1,20 +1,22 @@
|
||||
import { VResponse, SessionId } from '../gen/types'
|
||||
import { VResponse, SessionId, UserOverview } from '../gen/types'
|
||||
|
||||
export interface Client {
|
||||
auth: (
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<ClientResponse<SessionId>>
|
||||
|
||||
listUsers: (sessionId: SessionId) => Promise<ClientResponse<UserOverview[]>>
|
||||
}
|
||||
|
||||
export type ClientResponse<A> =
|
||||
| { status: 'ok'; content: VResponse<A> }
|
||||
| { status: 'ok'; content: A }
|
||||
| { status: 'password-reset'; content: SessionId }
|
||||
| { status: 'unauthorized' }
|
||||
| { status: 'unexpected'; code: number }
|
||||
|
||||
export class Connection implements Client {
|
||||
private base: URL
|
||||
// private sessionId: string | undefined;
|
||||
|
||||
constructor(baseUrl: URL) {
|
||||
this.base = baseUrl
|
||||
@ -25,14 +27,46 @@ export class Connection implements Client {
|
||||
password: string,
|
||||
): Promise<ClientResponse<SessionId>> {
|
||||
const url = new URL(this.base)
|
||||
url.pathname = `/api/v1/auth`
|
||||
url.pathname = `/api/test/auth`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: [['Content-Type', 'application/json']],
|
||||
body: JSON.stringify({ username: username, password: password }),
|
||||
})
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
let resp = await response.json()
|
||||
switch (resp.type) {
|
||||
case 'success':
|
||||
return { status: 'ok', content: resp.content }
|
||||
case 'password-reset':
|
||||
return { status: 'password-reset', content: resp.content }
|
||||
}
|
||||
return { status: 'ok', content: resp }
|
||||
} else if (response.status == 401) {
|
||||
return { status: 'unauthorized' }
|
||||
} else {
|
||||
return { status: 'unexpected', code: response.status }
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers(
|
||||
sessionId: SessionId,
|
||||
): Promise<ClientResponse<UserOverview[]>> {
|
||||
const url = new URL(this.base)
|
||||
url.pathname = `/api/test/list-users`
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: [['Authorization', `Bearer ${sessionId}`]],
|
||||
})
|
||||
if (response.ok) {
|
||||
let resp = await response.json()
|
||||
switch (resp.type) {
|
||||
case 'success':
|
||||
return { status: 'ok', content: resp.content }
|
||||
case 'password-reset':
|
||||
return { status: 'password-reset', content: resp.content }
|
||||
}
|
||||
return { status: 'ok', content: resp }
|
||||
} else if (response.status == 401) {
|
||||
return { status: 'unauthorized' }
|
||||
} else {
|
||||
|
@ -10,3 +10,5 @@ tokio = { version = "1.43.0", features = ["full", "rt"] }
|
||||
tower-http = { version = "0.6.2", features = ["cors"] }
|
||||
typeshare = "1.0.4"
|
||||
uuid = { version = "1.13.1", features = ["v4"] }
|
||||
result-extended = { path = "../../result-extended" }
|
||||
thiserror = "2.0.11"
|
||||
|
@ -1,19 +1,39 @@
|
||||
use axum::{http::{Method, StatusCode}, routing::{get, post}, Json, Router};
|
||||
use std::future::Future;
|
||||
|
||||
use axum::{
|
||||
http::{
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
HeaderMap, Method, StatusCode,
|
||||
},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use result_extended::{error, ok, ResultExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "content", rename_all = "kebab-case")]
|
||||
#[typeshare]
|
||||
enum AccountStatus {
|
||||
Ok,
|
||||
PasswordReset(String),
|
||||
Locked,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[typeshare]
|
||||
struct AuthRequest {
|
||||
username: String,
|
||||
password: String
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[typeshare]
|
||||
pub struct SessionId(String);
|
||||
struct SessionId(String);
|
||||
|
||||
impl SessionId {
|
||||
pub fn new() -> Self {
|
||||
@ -37,42 +57,183 @@ impl From<String> for SessionId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[typeshare]
|
||||
struct UserId(String);
|
||||
|
||||
impl UserId {
|
||||
pub fn new() -> Self {
|
||||
Self(format!("{}", Uuid::new_v4().hyphenated()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for UserId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for UserId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
#[typeshare]
|
||||
struct UserOverview {
|
||||
id: UserId,
|
||||
name: String,
|
||||
status: AccountStatus,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "content", rename_all = "kebab-case")]
|
||||
#[typeshare]
|
||||
enum VResponse<A> {
|
||||
Success(A),
|
||||
PasswordReset(A),
|
||||
PasswordReset(SessionId),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn check_password(request: Json<AuthRequest>) -> (StatusCode, Json<Option<VResponse<SessionId>>>) {
|
||||
async fn check_password(
|
||||
request: Json<AuthRequest>,
|
||||
) -> (StatusCode, Json<Option<VResponse<SessionId>>>) {
|
||||
let Json(request) = request;
|
||||
if request.username == "vakarian" && request.password == "aoeu" {
|
||||
(StatusCode::OK, Json(Some(VResponse::Success("vakarian-session-id".into()))))
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(Some(VResponse::Success("vakarian-session-id".into()))),
|
||||
)
|
||||
} else if request.username == "shephard" && request.password == "aoeu" {
|
||||
(StatusCode::OK, Json(Some(VResponse::PasswordReset("shephard-session-id".into()))))
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(Some(VResponse::PasswordReset("shephard-session-id".into()))),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, Json(None))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum AppError {
|
||||
#[error("no user authorized")]
|
||||
Unauthorized,
|
||||
#[error("bad request")]
|
||||
BadRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum FatalError {
|
||||
#[error("on unknown fatal error occurred")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl result_extended::FatalError for FatalError {}
|
||||
|
||||
fn parse_session_header(headers: HeaderMap) -> ResultExt<Option<SessionId>, AppError, FatalError> {
|
||||
match headers.get("Authorization") {
|
||||
Some(token) => {
|
||||
match token
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(" ")
|
||||
.collect::<Vec<&str>>()
|
||||
.as_slice()
|
||||
{
|
||||
[_schema, token] => ok(Some(SessionId::from(*token))),
|
||||
_ => error(AppError::BadRequest),
|
||||
}
|
||||
}
|
||||
None => ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_required<B, F, Fut>(headers: HeaderMap, f: F) -> (StatusCode, Json<VResponse<B>>)
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = (StatusCode, B)>,
|
||||
{
|
||||
match parse_session_header(headers) {
|
||||
ResultExt::Ok(Some(session_id)) => {
|
||||
if session_id == "vakarian-session-id".into() {
|
||||
let (code, result) = f().await;
|
||||
(code, Json(VResponse::Success(result)))
|
||||
} else if session_id == "shephard-id".into() {
|
||||
(StatusCode::OK, Json(VResponse::PasswordReset("shephard-session-id".into())))
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, Json(VResponse::Nothing))
|
||||
}
|
||||
}
|
||||
ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(VResponse::Nothing)),
|
||||
ResultExt::Err(AppError::Unauthorized) => (StatusCode::UNAUTHORIZED, Json(VResponse::Nothing)),
|
||||
ResultExt::Err(AppError::BadRequest) => (StatusCode::BAD_REQUEST, Json(VResponse::Nothing)),
|
||||
ResultExt::Fatal(err) => {
|
||||
panic!("{}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/health",
|
||||
"/api/test/health",
|
||||
get(|| async { (StatusCode::OK, Json(None::<String>)) }),
|
||||
).layer(
|
||||
)
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_methods([Method::GET]).allow_origin(Any),
|
||||
.allow_methods([Method::GET])
|
||||
.allow_origin(Any),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/auth",
|
||||
"/api/test/auth",
|
||||
post(|req: Json<AuthRequest>| check_password(req)),
|
||||
).layer(
|
||||
)
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_methods([Method::POST]).allow_origin(Any),
|
||||
.allow_methods([Method::POST])
|
||||
.allow_origin(Any),
|
||||
)
|
||||
.route(
|
||||
"/api/test/list-users",
|
||||
get(|headers: HeaderMap| {
|
||||
auth_required(headers, || async {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Some(vec![
|
||||
UserOverview {
|
||||
id: "vakarian-id".into(),
|
||||
name: "vakarian".to_owned(),
|
||||
status: AccountStatus::Ok,
|
||||
},
|
||||
UserOverview {
|
||||
id: "shephard-id".into(),
|
||||
name: "shephard".to_owned(),
|
||||
status: AccountStatus::PasswordReset(
|
||||
"2050-01-01 00:00:00".to_owned(),
|
||||
),
|
||||
},
|
||||
UserOverview {
|
||||
id: "tali-id".into(),
|
||||
name: "tali".to_owned(),
|
||||
status: AccountStatus::Locked,
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_headers([AUTHORIZATION])
|
||||
.allow_methods([Method::GET])
|
||||
.allow_origin(Any),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:8001")
|
||||
.await
|
||||
|
@ -1,53 +0,0 @@
|
||||
import { Client, ReqResponse, SessionId } from "./client";
|
||||
|
||||
class MockClient implements Client {
|
||||
users: { [_: string]: string }
|
||||
|
||||
constructor() {
|
||||
this.users = { 'vakarian': 'aoeu', 'shephard': 'aoeu' }
|
||||
}
|
||||
|
||||
async auth(username: string, password: string): Promise<ReqResponse<SessionId>> {
|
||||
if (this.users[username] == password) {
|
||||
if (username == 'shephard') {
|
||||
return { type: 'password-reset' }
|
||||
}
|
||||
return { type: "ok", content: "auth-successful" }
|
||||
} else {
|
||||
return { type: "error", content: 401 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("what happens in an authentication", () => {
|
||||
it("handles a successful response", async () => {
|
||||
let client = new MockClient()
|
||||
let response = await client.auth("vakarian", "aoeu")
|
||||
expect(response).toEqual({ type: "ok", content: "auth-successful" })
|
||||
})
|
||||
|
||||
it("handles an authentication failure", async () => {
|
||||
let client = new MockClient();
|
||||
{
|
||||
let response = await client.auth("vakarian", "")
|
||||
expect(response).toEqual({ type: "error", content: 401 });
|
||||
}
|
||||
|
||||
{
|
||||
let response = await client.auth("grunt", "")
|
||||
expect(response).toEqual({ type: "error", content: 401 });
|
||||
}
|
||||
})
|
||||
|
||||
it("handles a password-reset condition", async () => {
|
||||
let client = new MockClient();
|
||||
{
|
||||
let response = await client.auth("shephard", "aoeu")
|
||||
expect(response).toEqual({ type: "password-reset" });
|
||||
}
|
||||
{
|
||||
let response = await client.auth("shephard", "")
|
||||
expect(response).toEqual({ type: "error", content: 401 });
|
||||
}
|
||||
})
|
||||
})
|
@ -1,11 +0,0 @@
|
||||
export type UserId = string;
|
||||
export type SessionId = string;
|
||||
|
||||
export type ReqResponse<A> = { type: "ok", content: A } | { type: "password-reset" } | { type: "error", content: number }
|
||||
|
||||
export interface Client {
|
||||
auth: (username: string, password: string) => Promise<ReqResponse<SessionId>>
|
||||
// createUser: (sessionId: SessionId, username: string) => Promise<ReqResponse<UserId>>
|
||||
// deleteUser: (sessionId: SessionId, userId: string) => Promise<ReqResponse<void>>
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user