1
0
Fork 0
forked from TWS/kalkutago

Compare commits

..

2 commits

Author SHA1 Message Date
D. Scott Boggs 4fb02e704c add tests 2023-08-10 10:39:37 -04:00
D. Scott Boggs b4d524dccb Remove no-longer-existing Rust feature default_free_fn 2023-07-20 07:39:46 -04:00
19 changed files with 301 additions and 76 deletions

View file

@ -11,4 +11,5 @@ start-server: build-client
clean:
docker compose down
rm -r server/public/ client/dist/
-rm -r server/public/ client/dist/

View file

@ -26,6 +26,7 @@ class AppState {
tracks: Array<Track>
state: State
user?: LoggedInUser
source?: EventSource
constructor() {
this.tracks = new Array<Track>
@ -79,16 +80,22 @@ class AppState {
window.location = window.location
})
window.addEventListener('beforeunload', () => source.close())
this.source = source
}
async repopulate() {
if (!this.user) {
this.tracks = []
return
}
this.state = State.Fetching
this.tracks = await Track.fetchAll()
this.source?.close()
this.streamUpdatesFromServer()
this.state = State.Fetched
}
async populate() {
if (this.state != State.Unfetched) return
await this.repopulate()
this.streamUpdatesFromServer()
this.state = State.Fetched
}
async taskCompleted(track: Track, date: Date): Promise<Tick> {
const query = dateQuery(date)

View file

@ -1,4 +1,5 @@
import { error } from "./error"
import { Tick, ITick } from './ticks'
export interface ITrack {
id?: number
@ -97,4 +98,4 @@ export class Track implements ITrack {
}
return []
}
}
}

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { state } from '../state';
import router from '../router'
const name = ref("")
const password = ref("")
@ -9,10 +10,13 @@ async function signUp() {
const $name = name.value
const result = await fetch("/api/v1/auth", {
method: 'POST',
body: JSON.stringify({ name: $name, password: password.value })
body: JSON.stringify({ name: $name, password: password.value }),
headers: {'Content-Type': 'application/json'}
})
if (result.ok) {
state.user = { name: $name }
await state.repopulate()
router.push("/")
}
}
@ -20,10 +24,13 @@ async function login() {
const $name = name.value
const result = await fetch("/api/v1/auth", {
method: 'PUT',
body: JSON.stringify({ name: $name, password: password.value })
body: JSON.stringify({ name: $name, password: password.value }),
headers: {'Content-Type': 'application/json'}
})
if (result.ok) {
state.user = { name: $name }
await state.repopulate()
router.push("/")
}
}
@ -66,4 +73,4 @@ async function login() {
.button.submit {
margin-left: 10px;
}
</style>
</style>

View file

@ -1,5 +1,10 @@
<script setup lang="ts">
import Table from "../components/Table.vue";
import { state } from '../state.ts'
import router from '../router.ts'
if(!state.user) router.push('/login')
</script>
<template>

45
docker-compose_test.yml Normal file
View file

@ -0,0 +1,45 @@
version: "3.5"
services:
server:
build:
context: ./server
dockerfile: Dockerfile.test
networks:
- web
- internal
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago_TEST
POSTGRES_HOST: database
secrets: [ postgres-password, cookie-secret ]
depends_on: [ database ]
expose: [ 8000 ]
volumes:
- ./client/dist:/src/public:ro
labels:
traefik.enable: false
database:
image: postgres
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago_TEST
secrets: [ postgres-password ]
networks: [ internal ]
labels:
traefik.enable: false
secrets:
postgres-password:
file: ${PG_PW_FILE}
cookie-secret:
file: ${COOKIE_SECRET_FILE}
networks:
internal:
internal: true
web:
external: true

14
server/Cargo.lock generated
View file

@ -1177,6 +1177,7 @@ dependencies = [
"serde_json",
"thiserror",
"tokio",
"tokio-test",
]
[[package]]
@ -2637,6 +2638,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-util"
version = "0.7.8"

View file

@ -21,6 +21,7 @@ log = { version = "0.4.19", features = ["kv_unstable", "kv_unstable_serde"] }
sea-orm-migration = "0.11.3"
serde_json = "1.0.96"
thiserror = "1.0.40"
tokio-test = "0.4.2"
[dependencies.derive_builder]
version = "0.12.0"

View file

@ -1,8 +1,7 @@
use derive_deref::Deref;
use either::Either::{self, Right};
use log::{as_debug, as_serde, debug};
use rocket::{
http::{Cookie, CookieJar, Status},
outcome::IntoOutcome,
@ -11,7 +10,7 @@ use rocket::{
Request, State,
};
use sea_orm::{prelude::*, DatabaseConnection};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::{
api::error::ApiResult,
@ -21,10 +20,10 @@ use crate::{
use super::ErrorResponder;
#[derive(Clone, Deserialize)]
pub(super) struct LoginData {
name: String,
password: String,
#[derive(Clone, Deserialize, Serialize)]
pub struct LoginData {
pub name: String,
pub password: String,
}
#[put("/", data = "<user_data>", format = "application/json")]
@ -61,6 +60,7 @@ pub(super) async fn sign_up(
.insert(db as &DatabaseConnection)
.await
.map_err(Error::from)?;
debug!(user = as_serde!(user_data); "user added");
cookies.add_private(Cookie::new(
"user",
serde_json::to_string(&user_data).map_err(Error::from)?,
@ -73,6 +73,23 @@ pub(super) async fn sign_up(
#[derive(Deref)]
pub(super) struct Auth(users::Model);
#[derive(Deserialize)]
struct AuthData {
id: i32,
name: String,
password_hash: String,
}
impl From<AuthData> for Auth {
fn from(value: AuthData) -> Self {
Auth(users::Model {
id: value.id,
name: value.name,
password_hash: value.password_hash,
})
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Auth {
type Error = ();
@ -81,9 +98,13 @@ impl<'r> FromRequest<'r> for Auth {
let Some(user) = request.cookies().get_private("user") else {
return request::Outcome::Failure(unauthorized);
};
serde_json::from_str(user.value())
let user = user.value();
debug!(user = user; "user retreived from private cookie");
let result = serde_json::from_str(user)
.ok()
.map(Auth)
.into_outcome(unauthorized)
.map(|model: AuthData| model.into())
.into_outcome(unauthorized);
debug!(result = as_debug!(result); "auth FromRequest return value");
result
}
}

View file

@ -8,7 +8,7 @@ mod tracks;
pub(crate) mod update;
use std::{
default::default,
default::Default,
env, fs,
net::{IpAddr, Ipv4Addr},
};
@ -28,6 +28,8 @@ use tokio::sync::broadcast::{self, error::RecvError, Sender};
use self::{error::ApiResult, update::Update};
use log::{as_debug, as_serde, debug, trace};
pub use auth::LoginData;
#[get("/status")]
fn status() -> &'static str {
"Ok"
@ -74,7 +76,7 @@ fn get_secret() -> [u8; 32] {
data
}
pub(crate) fn start_server(db: DatabaseConnection) -> Rocket<Build> {
pub fn start_server(db: DatabaseConnection) -> Rocket<Build> {
use groups::*;
use ticks::*;
use tracks::*;
@ -83,7 +85,7 @@ pub(crate) fn start_server(db: DatabaseConnection) -> Rocket<Build> {
.configure(Config {
address: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
secret_key: SecretKey::derive_from(&get_secret()),
..default()
..Config::default()
})
.register("/", catchers![spa_index_redirect])
.manage(db)

View file

@ -1,11 +1,10 @@
use crate::api::auth::Auth;
use crate::api::{self, error::ApiResult};
use crate::entities::{prelude::*, *};
use crate::error::Error;
use either::Either::{self, Left, Right};
use log::as_debug;
use log::{as_serde, debug, warn};
use rocket::http::Status;
use rocket::{serde::json::Json, State};
use sea_orm::{prelude::*, DatabaseConnection, IntoActiveModel, Statement};
@ -78,6 +77,11 @@ pub(super) async fn insert_track(
track: Json<serde_json::Value>,
auth: Auth,
) -> Result<Json<tracks::Model>, Either<Status, ErrorResponder>> {
debug!(
user=as_serde!(*auth),
track=as_serde!(track.0);
"authenticated user making track insertion request"
);
fn bad() -> Either<Status, ErrorResponder> {
Left(Status::BadRequest)
}
@ -105,17 +109,28 @@ pub(super) async fn insert_track(
user_id, track_id
) select $1, ti.id
from track_insertion ti
join track_insertion using (id);"#,
join track_insertion using (id)
returning id;"#,
[
auth.id.into(),
track.get("name").ok_or_else(bad_value_for("name"))?.as_str().ok_or_else(bad_value_for("name"))?.into(),
track
.get("name")
.ok_or_else(bad_value_for("name"))?
.as_str()
.ok_or_else(bad_value_for("name"))?
.into(),
track
.get("description")
.ok_or_else(bad_value_for("description"))?
.as_str()
.ok_or_else(bad_value_for("description"))?
.into(),
track.get("icon").ok_or_else(bad_value_for("icon"))?.as_str().ok_or_else(bad_value_for("icon"))?.into(),
track
.get("icon")
.ok_or_else(bad_value_for("icon"))?
.as_str()
.ok_or_else(bad_value_for("icon"))?
.into(),
track.get("enabled").and_then(|it| it.as_i64()).into(),
track
.get("multiple_entries_per_day")
@ -126,17 +141,21 @@ pub(super) async fn insert_track(
],
))
.await
.map_err(|err| Right(Error::from(err).into()))? else {
return Err(Right("no value returned from track insertion query".into()));
};
.map_err(|err| Right(Error::from(err).into()))?
else {
return Err(Right("no value returned from track insertion query".into()));
};
trace!("query completed");
let track_id = track_id
.try_get_by_index(0)
.map_err(|err| Right(Error::from(err).into()))?;
trace!(track_id = track_id; "freshly inserted track ID");
let track = auth.authorized_track(track_id, db).await.ok_or_else(|| {
Right(format!("failed to fetch freshly inserted track with id {track_id}").into())
})?;
tx.send(Update::track_added(track.clone()))
.map_err(|err| Right(Error::from(err).into()))?;
if let Err(err) = tx.send(Update::track_added(track.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Json(track))
}
@ -157,8 +176,9 @@ pub(super) async fn update_track(
.update(db)
.await
.map_err(|err| Right(Error::from(err).into()))?;
tx.send(Update::track_changed(track.clone()))
.map_err(|err| Right(Error::from(err).into()))?;
if let Err(err) = tx.send(Update::track_changed(track.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Json(track))
}
@ -193,10 +213,10 @@ pub(super) async fn ticked(
let tick = tick
.insert(db as &DatabaseConnection)
.await
.map_err(|err| Right(Error::from(err).into()))?
;
tx.send(Update::tick_added(tick.clone()))
.map_err(|err| Right(Error::from(err).into()))?;
if let Err(err) = tx.send(Update::tick_added(tick.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Json(tick))
}
@ -221,10 +241,10 @@ pub(super) async fn ticked_on_date(
let tick = tick
.insert(db as &DatabaseConnection)
.await
.map_err(Error::from)?
;
tx.send(Update::tick_added(tick.clone()))
.map_err(Error::from)?;
if let Err(err) = tx.send(Update::tick_added(tick.clone())) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
Ok(Left(Json(tick)))
}
@ -250,7 +270,9 @@ pub(super) async fn clear_all_ticks(
.map_err(Error::from)?;
for tick in ticks.clone() {
tick.clone().delete(db).await.map_err(Error::from)?;
Update::tick_cancelled(tick).send(tx)?;
if let Err(err) = Update::tick_cancelled(tick).send(tx) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
}
Ok(Right(Json(ticks)))
}
@ -279,7 +301,9 @@ pub(super) async fn clear_all_ticks_on_day(
.map_err(Error::from)?;
for tick in ticks.clone() {
tick.clone().delete(db).await.map_err(Error::from)?;
Update::tick_cancelled(tick).send(tx)?;
if let Err(err) = Update::tick_cancelled(tick).send(tx) {
warn!(err = as_debug!(err); "error sending updates to subscribed channels");
}
}
Ok(Right(Json(ticks)))
}

View file

@ -91,8 +91,13 @@ impl Update {
}
pub fn send(self, tx: &Sender<Self>) -> Result<()> {
let count = tx.send(self.clone())?;
trace!(sent_to = count, update = as_serde!(self); "sent update to SSE channel");
let receiver_count = tx.receiver_count();
if receiver_count > 0 {
trace!(receiver_count = receiver_count, update = as_serde!(self); "sending update");
let count = tx.send(self.clone())?;
} else {
trace!("no update receivers, skipping message");
}
Ok(())
}
}

View file

@ -1,11 +1,15 @@
use crate::migrator::Migrator;
use sea_orm_migration::MigratorTrait;
use sea_orm_migration::SchemaManager;
use std::{
default::default,
env,
ffi::{OsStr, OsString},
fs::File,
io::Read,
};
use sea_orm::{Database, DatabaseConnection};
// from https://doc.rust-lang.org/std/ffi/struct.OsString.html
fn concat_os_strings(a: &OsStr, b: &OsStr) -> OsString {
let mut ret = OsString::with_capacity(a.len() + b.len()); // This will allocate
@ -30,7 +34,7 @@ fn get_env_var_or_file<A: AsRef<OsStr>>(key: A) -> Option<String> {
if let Some(path) = env::var_os(file_key) {
// open the file and read it
let mut file = File::open(&path).unwrap_or_else(|_| panic!("no such file at {path:?}"));
let mut val: String = default();
let mut val = String::new();
file.read_to_string(&mut val)
.unwrap_or_else(|_| panic!("reading file at {path:?}"));
Some(val)
@ -58,3 +62,31 @@ pub fn connection_url() -> String {
.unwrap_or(5432_u16);
format!("postgres://{user}:{password}@{host}:{port}/{db}")
}
pub async fn connection() -> DatabaseConnection {
Database::connect(connection_url())
.await
.expect("db connection")
}
pub async fn migrated() -> DatabaseConnection {
let db = connection().await;
let schema_manager = SchemaManager::new(&db);
Migrator::refresh(&db).await.expect("migration");
assert!(schema_manager
.has_table("tracks")
.await
.expect("fetch tracks table"));
assert!(schema_manager
.has_table("ticks")
.await
.expect("fetch ticks table"));
assert!(schema_manager
.has_table("groups")
.await
.expect("fetch groups table"));
assert!(schema_manager
.has_table("track2_groups")
.await
.expect("fetch track2groups table"));
db
}

View file

@ -1,6 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use std::default::default;
use std::default::Default;
use chrono::{Datelike, Timelike, Utc};
use sea_orm::entity::prelude::*;
@ -60,7 +60,7 @@ impl ActiveModel {
minute: Set(now.minute().try_into().ok()),
second: Set(now.second().try_into().ok()),
has_time_info: Set(Some(1)),
..default()
..Default::default()
}
}
pub(crate) fn on(date: Date, track_id: i32) -> Self {
@ -80,7 +80,7 @@ impl ActiveModel {
minute: Set(now.minute().try_into().ok()),
second: Set(now.second().try_into().ok()),
has_time_info: Set(Some(1)),
..default()
..Default::default()
}
}
}

View file

@ -1,6 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use std::default::default;
use std::default::Default;
use bcrypt::*;
// TODO Add option for argon2 https://docs.rs/argon2/latest/argon2/
@ -57,7 +57,7 @@ impl ActiveModel {
Ok(Self {
name,
password_hash,
..default()
..Default::default()
})
}
}

8
server/src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
#![feature(proc_macro_hygiene, decl_macro, never_type)]
#[macro_use]
extern crate rocket;
pub mod api;
pub mod db;
pub mod entities;
pub mod error;
mod migrator;

View file

@ -1,4 +1,4 @@
#![feature(default_free_fn, proc_macro_hygiene, decl_macro, never_type)]
#![feature(proc_macro_hygiene, decl_macro, never_type)]
#[macro_use]
extern crate rocket;
mod api;
@ -6,32 +6,9 @@ mod db;
mod entities;
mod error;
mod migrator;
use crate::migrator::Migrator;
use sea_orm::Database;
use sea_orm_migration::prelude::*;
#[launch]
async fn rocket_defines_the_main_fn() -> _ {
femme::with_level(femme::LevelFilter::Debug);
let url = db::connection_url();
let db = Database::connect(url).await.expect("db connection");
let schema_manager = SchemaManager::new(&db);
Migrator::refresh(&db).await.expect("migration");
assert!(schema_manager
.has_table("tracks")
.await
.expect("fetch tracks table"));
assert!(schema_manager
.has_table("ticks")
.await
.expect("fetch ticks table"));
assert!(schema_manager
.has_table("groups")
.await
.expect("fetch groups table"));
assert!(schema_manager
.has_table("track2_groups")
.await
.expect("fetch track2groups table"));
api::start_server(db)
femme::with_level(femme::LevelFilter::Trace);
api::start_server(db::migrated().await)
}

14
shell.nix Normal file
View file

@ -0,0 +1,14 @@
# DEVELOPMENT shell environment
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs.buildPackages; [
clang
yarn nodejs
openssl
python3
python3Packages.requests
python3Packages.ipython
];
}

61
test.py Normal file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env python3
#
# Quick script to test endpoints of kalkutago
from requests import get, post, put, patch
from time import gmtime as utc
credentials = {"name": "testuser", "password": "testpass"}
track = {"name": "test", "description": "test track", "icon": "", "enabled": 1}
def test_auth(method):
res = method(f'http://kalkutago/api/v1/auth', json=credentials)
assert 'user' in res.cookies.iterkeys(), \
f'no user cookie found. Cookies: {res.cookies.get_dict()}; body: ' + \
res.text
return res.cookies['user']
def test_create_user():
return test_auth(post)
def test_login():
return test_auth(put)
def test_track_creation(auth_cookie):
res = post('http://kalkutago/api/v1/tracks', json=track,
cookies={'user': auth_cookie})
print(res.text)
res.raise_for_status()
return res.json()
def test_get_track(auth_cookie, track):
res = get(f'http://kalkutago/api/v1/tracks/{track["id"]}',
cookies={'user': auth_cookie})
print(res.text)
res.raise_for_status()
retrieved = res.json()
assert track == retrieved, f'expected {track!r} to equal {retrieved!r}'
return retrieved
def test_tick(auth_cookie, track):
res = patch(f'http://kalkutago/api/v1/tracks/{track["id"]}/ticked',
cookies={'user': auth_cookie})
print(res.text)
res.raise_for_status()
retrieved = res.json()
# result:
# {"id":1,"track_id":6,"year":2023,"month":8,"day":10,"hour":13,"minute":7,"second":41,"has_time_info":1}
now = utc()
assert retrieved['track_id'] == track['id']
assert retrieved['year'] == now.tm_year
assert retrieved['month'] == now.tm_mon
assert retrieved['day'] == now.tm_mday
return retrieved
if __name__ == "__main__":
login_cookie = test_create_user()
test_login()
track = test_track_creation(login_cookie)
retrieved = test_get_track(login_cookie, track)
tick = test_tick(login_cookie, track)