added collections back to playground

Summary: Ref <T2485>

Reviewers: #reviewers, Etoo

Subscribers: kuba-orlik, jenkins-user

Maniphest Tasks: T2485

Differential Revision: https://hub.sealcode.org/D1217
master
Kuba Orlik 3 years ago
parent 942c9b443d
commit 9b9f1df7a8

@ -4,5 +4,5 @@
"load": ["arcanist-linters", "arc-unit-mocha/src"],
"unit.engine": "MochaEngine",
"unit.mocha.include": ["./lib/**/*.test.js"],
"unit.mocha.dockerRoot": "/opt/sealious"
"unit.mocha.dockerRoot": "/opt/sealious-playground"
}

@ -19,7 +19,7 @@ module.exports = {
"@typescript-eslint/require-await": 0,
/* "jsdoc/require-description": 2, */
"no-await-in-loop": 2,
"with-tsc-error/all": ["warn", {}],
"@typescript-eslint/consistent-type-assertions": [2, { assertionStyle: "never" }],
},
settings: { jsdoc: { mode: "typescript" } },
overrides: [

@ -2,6 +2,7 @@
"useTabs": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 90,
"overrides": [
{
"files": "*.yml",

@ -3,15 +3,15 @@ services:
db:
image: mongo:4.4-bionic
ports:
- "127.0.0.1:${PORT:-2072}4:27017"
- "127.0.0.1:20725:27017"
test:
image: sealious-test:latest
image: sealious-playground:latest
build:
context: .
dockerfile: test.Dockerfile
context: ./docker
dockerfile: ./test.Dockerfile
volumes:
- ./:/opt/sealious/
- ~/.npm_cacache:/opt/sealious/.npm_cacache
- ./:/opt/sealious-playground/
- ~/.npm_cacache:/opt/sealious-playground/.npm_cacache
user: ${UID:-1000}:${GID:-1000}
mailcatcher:
image: schickling/mailcatcher:latest

@ -3,7 +3,7 @@ LABEL maintainer="Jakub Pieńkowski <jakski@sealcode.org>"
ENV UID=node \
GID=node \
HOME=/opt/sealious
HOME=/opt/sealious-playground
RUN sed -i 's/http\:\/\/dl-cdn.alpinelinux.org/https\:\/\/mirrors.dotsrc.org/g' /etc/apk/repositories
# Tini will ensure that any orphaned processes get reaped properly.

@ -2,7 +2,7 @@ const { build } = require("esbuild");
const { sassPlugin } = require("esbuild-sass-plugin");
const glob = require("tiny-glob");
const watch = process.argv.at(-1) === "--watch";
const watch = process.argv.includes("--watch");
(async () => {
const entryPoints = await glob("./src/back/**/*.ts");
@ -17,7 +17,7 @@ const watch = process.argv.at(-1) === "--watch";
format: "cjs",
});
build({
entryPoints: ["./src/front/main.scss"],
entryPoints: ["./src/main.scss"],
sourcemap: true,
outfile: "./public/dist/style.css",
logLevel: "info",

@ -15,11 +15,11 @@ export SEALIOUS_BASE_URL
# when the docker image is being built with root:root as the owner.
mkdir -p ~/.npm_cacache
# Create .npm directory in the container, since it is not yet present and we need it for next step.
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious/.npm
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-playground/.npm
# Link the host-bound npm cache directory into the container's npm cache directory.
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious/.npm_cacache /opt/sealious/.npm/_cacache
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-playground/.npm_cacache /opt/sealious-playground/.npm/_cacache
docker-compose up -d db
./npm.sh ci
./npm.sh run build:back;
./npm.sh run build;
rm -f log.html

@ -4,6 +4,11 @@ export SEALIOUS_PORT=$PORT
SEALIOUS_BASE_URL=$(cat .base_url)
export SEALIOUS_BASE_URL
./npm.sh run typecheck:front;
./npm.sh run typecheck:back;
docker-compose run --user="$UID"\
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
@ -12,4 +17,3 @@ docker-compose run --user="$UID"\
-e "SEALIOUS_SANITY=true" \
test
./npm.sh run build:front;

6760
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,18 +1,17 @@
{
"name": "sealious-playground",
"version": "1.0.1",
"version": "1.1.0",
"description": "",
"main": "./dist/index.js",
"scripts": {
"start": "docker-compose up -d db && node .",
"test-cmd": "node test.js",
"test": "./npm.sh run test-cmd -- ",
"typecheck:back": "tsc --noEmit -p src/back",
"typecheck:front": "tsc --noEmit -p src/front",
"build": "node ./esbuild.js",
"watch": "multiple-scripts-tmux \"npm run typecheck:back -- --watch\" \"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps .\" \"npm run build -- --watch\" \"npm run typecheck:front -- --watch\" ",
"test-reports": "npm run build && rm -fr .xunit coverage && docker-compose up -d db mailcatcher && npm run test -- --cover --test-report",
"cover-html": "npm run test-reports -- --cover-html && xdg-open coverage/lcov-report/index.html"
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/**/*.test.ts",
"coverage": "nyc npm run test --",
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit"
},
"author": "",
"license": "ISC",
@ -20,16 +19,24 @@
"@babel/core": "^7.12.10",
"@hotwired/turbo": "^7.1.0",
"@koa/router": "^10.0.0",
"@sealcode/ts-predicates": "^0.1.1",
"esbuild-node-tsc": "^1.8.2",
"locreq": "^2.0.2",
"multiple-scripts-tmux": "^1.0.4",
"nodemon": "^2.0.7",
"sealious": "^0.13.52",
"sealious": "^0.14.2",
"source-map-support": "^0.5.21",
"stimulus": "^2.0.0",
"tempstream": "^0.0.7"
"tempstream": "^0.0.7",
"wtfnode": "^0.9.1"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@sealcode/ansi-html-stream": "^1.0.1",
"@types/koa__router": "^8.0.4",
"@types/mocha": "^9.1.0",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"axios": "^0.24.0",
"esbuild": "^0.14.10",
"esbuild-sass-plugin": "^2.0.0",
"eslint": "^7.19.0",
@ -43,6 +50,26 @@
"prettier": "^2.2.1",
"tiny-glob": "^0.2.9",
"ts-loader": "^8.0.14",
"ts-node": "^10.4.0",
"typescript": "^4.1.3"
},
"nyc": {
"extends": "@istanbuljs/nyc-config-typescript",
"check-coverage": false,
"all": true,
"include": [
"src/**/!(*.test.*).[tj]s?(x)"
],
"exclude": [
"src/_tests_/**/*.*"
],
"reporter": [
"html",
"lcov",
"clover",
"text",
"text-summary"
],
"report-dir": "coverage"
}
}

@ -2,15 +2,19 @@ import _locreq from "locreq";
import { resolve } from "path";
import { App, LoggerMailer, SMTPMailer } from "sealious";
import tasks from "./collections/tasks";
import users from "./collections/users";
import PasswordResetIntents from "./collections/password-reset-intents";
import RegistrationIntents from "./collections/registration-intents";
import { UserRoles } from "./collections/user-roles";
import { LoggerLevel } from "sealious/@types/src/app/logger";
import { Secrets } from "./collections/secrets";
const locreq = _locreq(__dirname);
const PORT = process.env.SEALIOUS_PORT
? parseInt(process.env.SEALIOUS_PORT)
: 8080;
const PORT = process.env.SEALIOUS_PORT ? parseInt(process.env.SEALIOUS_PORT) : 8080;
const base_url = process.env.SEALIOUS_BASE_URL || `http://localhost:${PORT}`;
const MONGO_PORT = process.env.SEALIOUS_MONGO_PORT
? parseInt(process.env.SEALIOUS_MONGO_PORT)
: 20724;
: 20725;
const MONGO_HOST = process.env.SEALIOUS_MONGO_HOST || "127.0.0.1";
export default class TheApp extends App {
@ -26,7 +30,7 @@ export default class TheApp extends App {
from_name: "Sealious playground app",
},
logger: {
level: <const>"info",
level: "info" as LoggerLevel,
},
"www-server": {
port: PORT,
@ -37,7 +41,7 @@ export default class TheApp extends App {
};
manifest = {
name: "Sealious Playground",
logo: resolve(__dirname, "../assets/logo.png"),
logo: locreq.resolve("assets/logo.png"),
version: "0.0.1",
default_language: "en",
base_url,
@ -48,7 +52,12 @@ export default class TheApp extends App {
};
collections = {
...App.BaseCollections,
users,
"registration-intents": new RegistrationIntents(),
"password-reset-intents": new PasswordResetIntents(),
"user-roles": new UserRoles(),
tasks,
secrets: new Secrets(),
};
mailer =
process.env.SEALIOUS_MAILER === "mailcatcher"

@ -0,0 +1,6 @@
describe("collections", () => {
require("./password-reset-intents.subtest");
require("./registration-intents.subtest");
require("./user-roles.subtest");
require("./users.subtest");
});

@ -0,0 +1,89 @@
import axios from "axios";
import assert from "assert";
import TheApp from "../app";
import { withProdApp } from "../test_utils/with-prod-app";
describe("password-reset-intents", () => {
async function createAUser(app: TheApp) {
await app.collections.users.suCreate({
username: "user",
email: "user@example.com",
password: "password",
roles: [],
});
}
it("tells you if the email address doesn't exist", async () =>
withProdApp(async ({ app, base_url }) => {
const email = "fake@example.com";
try {
await axios.post(
`${base_url}/api/v1/collections/password-reset-intents`,
{
email: email,
}
);
} catch (e) {
assert.equal(
e.response.data.data.field_messages.email.message,
app.i18n("invalid_existing_value", ["users", "email", email])
);
return;
}
throw new Error("it didn't throw");
}));
it("allows anyone to create an intent, if the email exists", async () =>
withProdApp(async ({ app, base_url }) => {
await createAUser(app);
const { email, token } = (
await axios.post(
`${base_url}/api/v1/collections/password-reset-intents`,
{
email: "user@example.com",
}
)
).data;
assert.deepEqual(
{ email, token },
{
email: "user@example.com",
token: "it's a secret to everybody",
}
);
}));
it("tells you if the email address is malformed", async () =>
withProdApp(async ({ app, base_url }) => {
const email = "incorrect-address";
try {
await axios.post(
`${base_url}/api/v1/collections/password-reset-intents`,
{
email: email,
}
);
} catch (e) {
assert.equal(
e.response.data.data.field_messages.email.message,
app.i18n("invalid_email", [email])
);
return;
}
throw new Error("it didn't throw");
}));
it("sends an email with the reset password link", async () =>
withProdApp(async ({ app, base_url, mail_api }) => {
await createAUser(app);
await axios.post(`${base_url}/api/v1/collections/password-reset-intents`, {
email: "user@example.com",
});
const messages = (await mail_api.getMessages()).filter(
(message) => message.recipients[0] == "<user@example.com>"
);
assert.equal(messages.length, 1);
assert.equal(messages[0].recipients.length, 1);
assert.equal(messages[0].recipients[0], "<user@example.com>");
}));
});

@ -0,0 +1,39 @@
import { App, Collection, CollectionItem, Context, FieldTypes, Policies } from "sealious";
import PasswordResetTemplate from "../email-templates/password-reset";
import TheApp from "../app";
export default class PasswordResetIntents extends Collection {
name = "password-reset-intents";
fields = {
email: new FieldTypes.ValueExistingInCollection({
field: "email",
collection: "users",
include_forbidden: true,
}),
token: new FieldTypes.SecretToken(),
};
policies = {
create: new Policies.Public(),
edit: new Policies.Noone(),
};
defaultPolicy: Policies.Super;
async init(app: App, name: string) {
const theApp = app as TheApp;
await super.init(app, name);
app.collections["password-reset-intents"].on(
"after:create",
async ([context, intent]: [
Context,
CollectionItem<PasswordResetIntents>,
any
]) => {
const intent_as_super = await intent.fetchAs(new app.SuperContext());
const message = await PasswordResetTemplate(theApp, {
email_address: intent.get("email") as string,
token: intent_as_super.get("token") as string,
});
await message.send(app);
}
);
}
}

@ -0,0 +1,48 @@
import axios from "axios";
import assert from "assert";
import { TestUtils, Policies } from "sealious";
import { withProdApp } from "../test_utils/with-prod-app";
describe("registration-intents", () => {
it("doesn't allow setting a role for registration intention when the user in context can't create user-roles", async () =>
withProdApp(async ({ app, base_url }) => {
app.collections["user-roles"].setPolicy("create", new Policies.Noone());
await TestUtils.assertThrowsAsync(
() =>
axios.post(`${base_url}/api/v1/collections/registration-intents`, {
email: "cunning@fox.com",
role: "admin",
}),
(e: any) => {
assert.equal(
e.response.data.data.field_messages.role.message,
app.i18n("policy_users_who_can_deny", [
"create",
"user-roles",
app.i18n("policy_noone_deny"),
])
);
}
);
}));
it("allows setting a role for registration intention when the user in context can create user-roles", async () =>
withProdApp(async ({ app, base_url }) => {
app.collections["user-roles"].setPolicy("create", new Policies.Public());
const intent = (
await axios.post(`${base_url}/api/v1/collections/registration-intents`, {
email: "genuine@fox.com",
role: "admin",
})
).data;
assert.equal(intent.role, "admin");
const role = (
await app.collections["registration-intents"].suGetByID(
intent.id as string
)
).get("role");
assert.equal(role, "admin");
}));
});

@ -0,0 +1,43 @@
import { App, Collection, FieldTypes, Policies } from "sealious";
import RegistrationIntentTemplate from "../email-templates/registration-intent";
export default class RegistrationIntents extends Collection {
fields = {
email: new FieldTypes.ValueNotExistingInCollection({
collection: "users",
field: "email",
include_forbidden: true,
}),
token: new FieldTypes.SecretToken(),
role: new FieldTypes.SettableBy(
new FieldTypes.Enum((app: App) => app.ConfigManager.get("roles")),
new Policies.UsersWhoCan(["create", "user-roles"])
),
};
policies = {
create: new Policies.Public(),
edit: new Policies.Noone(),
};
defaultPolicy = new Policies.Super();
async init(app: App, name: string) {
await super.init(app, name);
this.on("after:create", async ([context, intent]) => {
await intent.decode(context);
const {
items: [item],
} = await app.collections["registration-intents"]
.suList()
.ids([intent.id])
.fetch();
const token = item.get("token") as string;
const message = await RegistrationIntentTemplate(app, {
email_address: intent.get("email") as string,
token,
});
await message.send(app);
});
}
}

@ -0,0 +1,12 @@
import { Collection, FieldTypes } from "sealious";
import { Roles } from "../policy-types/roles";
/* For testing the Roles policy */
export class Secrets extends Collection {
fields = {
content: new FieldTypes.Text(),
};
defaultPolicy = new Roles(["admin"]);
}
export default new Secrets();

@ -0,0 +1,71 @@
import assert from "assert";
import axios from "axios";
import { CollectionItem, TestUtils } from "sealious";
import { Users } from "./users";
import TheApp from "../app";
import { withProdApp } from "../test_utils/with-prod-app";
function createAUser(app: TheApp, username: string) {
return app.collections.users.suCreate({
username,
email: `${username}@example.com`,
password: "password",
roles: [],
});
}
type Unpromisify<T> = T extends Promise<infer R> ? R : T;
async function createAdmin(
app: TheApp,
rest_api: TestUtils.MockRestApi
): Promise<[CollectionItem<Users>, Unpromisify<ReturnType<typeof rest_api.login>>]> {
const user = await createAUser(app, "super_user");
await app.collections["user-roles"].suCreate({
user: user.id,
role: "admin",
});
const session = await rest_api.login({
username: "super_user",
password: "password",
});
return [user, session];
}
describe("user-roles", () => {
it("rejects when given an empty role", async () =>
withProdApp(async ({ app, rest_api }) => {
const [user, session] = await createAdmin(app, rest_api);
await TestUtils.assertThrowsAsync(
async () => {
return rest_api.post(
`/api/v1/collections/user-roles`,
{
user: user.id,
},
session
);
},
(e: any) => {
assert.equal(
e?.response.data.data.field_messages.role?.message,
"Missing value for field 'role'."
);
}
);
}));
it("accepts correct dataset", async () =>
withProdApp(async ({ app, base_url, rest_api }) => {
const [user, session] = await createAdmin(app, rest_api);
const response = await axios.post(
`${base_url}/api/v1/collections/user-roles`,
{
user: user.id,
role: "admin",
},
session
);
assert.equal(response.status, 201);
}));
});

@ -0,0 +1,37 @@
import { ActionName, App, Collection, FieldTypes, Policies, Policy } from "sealious";
import { Roles } from "../policy-types/roles";
export class UserRoles extends Collection {
name = "user-roles";
fields = {
role: new FieldTypes.Enum((app: App) =>
app.ConfigManager.get("roles")
).setRequired(true),
user: new FieldTypes.SingleReference("users"),
};
policies = {
create: new Roles(["admin"]),
delete: new Policies.Public(),
show: new Policies.UserReferencedInField("user"),
edit: new Policies.Noone(),
} as { [policy: string]: Policy }; // this `as` statement allows the policies to be overwritten;
async init(app: App, collection_name: string) {
await super.init(app, collection_name);
app.on("started", async () => {
const roles = app.collections["user-roles"];
for (const action of ["create", "delete"] as ActionName[]) {
const policy = roles.getPolicy(action);
if (policy instanceof Policies.Public) {
app.Logger.warn(
"USER POLICY",
`<user-roles> collection is using <public> access strategy for ${action} action. Anyone can change anyone elses role. This is the default behavior and you should overwrite it with <set_policy>`
);
}
}
});
}
}
export default new UserRoles();

@ -0,0 +1,20 @@
import assert from "assert";
import { withProdApp } from "../test_utils/with-prod-app";
describe("users", () => {
it.skip("should properly handle route to account creation", async () =>
withProdApp(async ({ app, rest_api }) => {
const sealious_response = await app.collections["registration-intents"]
.suList()
.filter({ email: app.manifest.admin_email })
.fetch();
const { email, token } = sealious_response.items[0].serializeBody();
const response = await rest_api.get(
`/account-creation-details?token=${token as string}&email=${
email as string
}`
);
assert(response.includes("Please fill in the details of your account"));
}));
});

@ -0,0 +1,52 @@
import { App, Collections, FieldTypes } from "sealious";
import TheApp from "../app";
export class Users extends Collections.users {
fields = {
...App.BaseCollections.users.fields,
email: new FieldTypes.Email().setRequired(true),
roles: new FieldTypes.ReverseSingleReference({
referencing_collection: "user-roles",
referencing_field: "user",
}),
};
async init(app: TheApp, name: string) {
await super.init(app, name);
app.on("started", async () => {
const users = await app.collections.users
.suList()
.filter({ email: app.manifest.admin_email })
.fetch();
if (users.empty) {
app.Logger.warn(
"ADMIN",
`Creating an admin account for ${app.manifest.admin_email}`
);
await app.collections["registration-intents"].suCreate({
email: app.manifest.admin_email,
role: "admin",
token: "",
});
}
});
}
async populate(): Promise<void> {
if (await this.app.Metadata.get("my_collection_populated")) {
return;
}
const app = this.app as TheApp;
await app.collections.users.suCreate({
email: "admin@example.com",
roles: [],
username: "admin",
password: "password",
});
await this.app.Metadata.set("my_collection_populated", "true");
}
}
export default new Users();

@ -0,0 +1,31 @@
import { App, EmailTemplates, Errors } from "sealious";
import TheApp from "../app";
export default async function PasswordResetTemplate(
app: TheApp,
{ email_address, token }: { email_address: string; token: string }
) {
const matching_users = await app.collections["users"]
.suList()
.filter({ email: email_address })
.fetch();
if (!matching_users.items.length) {
throw new Errors.NotFound("No user with that email");
}
const username = matching_users.items[0].get("username");
return EmailTemplates.Simple(app, {
subject: app.i18n("password_reset_email_subject", [app.manifest.name]),
to: `${username}<${email_address}>`,
text: `
${app.i18n("password_reset_email_text", [app.manifest.name, username])}`,
buttons: [
{
text: app.i18n("password_reset_cta"),
href: `${app.manifest.base_url}/confirm-password-reset?token=${token}&email=${email_address}`,
},
],
});
}

@ -0,0 +1,19 @@
import { App, EmailTemplates } from "sealious";
export default async function RegistrationIntentTemplate(
app: App,
{ email_address, token }: { email_address: string; token: string }
) {
return EmailTemplates.Simple(app, {
subject: app.i18n("registration_intent_email_subject", [app.manifest.name]),
to: email_address,
text: `
${app.i18n("registration_intent_email_text", [app.manifest.name])}`,
buttons: [
{
text: app.i18n("registration_intent_cta"),
href: `${app.manifest.base_url}/account/confirm-registration-email?token=${token}&email=${email_address}`,
},
],
});
}

@ -16,7 +16,7 @@ const app = new TheApp();
app.start()
.then(async () => {
//populate scripts go here
await app.collections.users.populate();
if (process.env.SEALIOUS_SANITY === "true") {
console.log("Exiting with error code 0");
process.exit(0);

@ -0,0 +1,3 @@
describe("policy-types", () => {
require("./roles.subtest");
});

@ -0,0 +1,58 @@
import assert from "assert";
import { TestUtils } from "sealious";
import { withProdApp } from "../test_utils/with-prod-app";
const ALLOWED_ROLES = ["admin"];
describe("roles", () => {
it("allows access to users with designated role and denies access to users without it", async () =>
withProdApp(async ({ app, rest_api }) => {
await app.collections.users.suCreate({
username: "regular-user",
password: "password",
email: "regular@example.com",
roles: [],
});
const admin = await app.collections.users.suCreate({
username: "admin",
password: "admin-password",
email: "admin@example.com",
roles: [],
});
await app.collections["user-roles"].suCreate({
user: admin.id,
role: "admin",
});
await app.collections.secrets.suCreate({
content: "It's a secret to everybody",
});
const admin_session = await rest_api.login({
username: "admin",
password: "admin-password",
});
const { items: admin_response } = await rest_api.get(
"/api/v1/collections/secrets",
admin_session
);
assert.equal(admin_response.length, 1);
const user_session = await rest_api.login({
username: "regular-user",
password: "password",
});
await TestUtils.assertThrowsAsync(
() => rest_api.get("/api/v1/collections/secrets", user_session),
(error) => {
assert.equal(
error.response.data.message,
app.i18n("policy_roles_deny", [ALLOWED_ROLES.join(", ")])
);
}
);
}));
});

@ -0,0 +1,55 @@
import { Context, Policy, QueryTypes } from "sealious";
export class Roles extends Policy {
static type_name = "roles";
allowed_roles: string[];
constructor(allowed_roles: string[]) {
super(allowed_roles);
this.allowed_roles = allowed_roles;
}
async countMatchingRoles(context: Context) {
const user_id = context.user_id;
context.app.Logger.debug2("ROLES", "Checking the roles for user", user_id);
const user_roles = await context.app.collections["user-roles"]
.list(context)
.filter({ user: user_id })
.fetch();
const roles = user_roles.items.map((user_role) => user_role.get("role"));
return this.allowed_roles.filter((allowed_role) => roles.includes(allowed_role))
.length;
}
async _getRestrictingQuery(context: Context) {
if (context.is_super) {
return new QueryTypes.AllowAll();
}
if (context.user_id === null) {
return new QueryTypes.DenyAll();
}
const matching_roles_count = await this.countMatchingRoles(context);
return matching_roles_count > 0
? new QueryTypes.AllowAll()
: new QueryTypes.DenyAll();
}
async checkerFunction(context: Context) {
if (context.user_id === null) {
return Policy.deny(context.app.i18n("policy_logged_in_deny"));
}
const matching_roles_count = await this.countMatchingRoles(context);
return matching_roles_count > 0
? Policy.allow(
context.app.i18n("policy_roles_allow", [
this.allowed_roles.join(", "),
])
)
: Policy.deny(
context.app.i18n("policy_roles_deny", [this.allowed_roles.join(", ")])
);
}
}

@ -0,0 +1,25 @@
import Router from "@koa/router";
import { Middlewares } from "sealious";
import finalizePasswordReset from "./finalize-password-reset";
import confirmPasswordReset from "./confirm-password-reset";
import finalizeRegistrationIntent from "./finalize-registration-intent";
import createRouter from "./create/create.routes";
import { confirmRegistrationRouter } from "./confirm-registration-email/confirm-registration-email.routes";
export const accountsRouter = (router: Router): void => {
router.post(
"/account/finalize-registration-intent",
Middlewares.parseBody,
finalizeRegistrationIntent
);
router.post(
"/account/finalize-password-reset",
Middlewares.parseBody,
finalizePasswordReset
);
router.get("/account/confirm-password-reset", confirmPasswordReset);
createRouter(router);
confirmRegistrationRouter(router);
};

@ -0,0 +1,6 @@
describe("routes", () => {
// require("./finalize-registration-intent.subtest");
// require("./finalize-password-reset.subtest");
require("./confirm-password-reset.subtest");
require("./account-creation-details.subtest");
});

@ -0,0 +1,11 @@
import axios from "axios";
import { withProdApp } from "../../test_utils/with-prod-app";
describe("confirm-password-reset", () => {
it("displays an html form", async () =>
withProdApp(async ({ base_url }) => {
await axios.get(
`${base_url}/confirm-password-reset?token=kupcia&email=dupcia`
);
}));
});

@ -0,0 +1,80 @@
import { Middleware } from "@koa/router";
import * as assert from "assert";
import { App } from "sealious";
const render_form = async (app: App, token: string, email: string) => /* HTML */ `
<!DOCTYPE html>
<html>
<style>
html {
background-color: #edeaea;
}
body {
max-width: 21cm;
margin: 1cm auto;
font-family: sans-serif;
background-color: white;
padding: 1cm;
box-sizing: border-box;
}
.reveal-button {
margin-left: -0.5rem;
}
</style>
<meta charset="utf-8" />
<title>${app.i18n("password_reset_cta")}</title>
<img src="/api/v1/logo" alt="${app.manifest.name} - logo" />
<h1>${app.i18n("password_reset_cta")}</h1>
<form method="POST" action="/finalize-password-reset">
<input type="hidden" name="token" value="${token}" />
<input type="hidden" name="email" value="${email}" />
<fieldset>
<legend>${app.i18n("password_reset_input_cta", [email])}</legend>
<input id="pwd" name="password" type="password" size="32" />
<button
id="reveal"
class="reveal-button"
onclick="toggle(event)"
title="${app.i18n("reveal_password")}"
>
🙈
</button>
<br />
<input type="submit" value="${app.i18n("password_reset_cta")}" />
</fieldset>
</form>
<script>
function toggle(event) {
event.preventDefault();
if (pwd.type == "password") {
pwd.type = "text";
reveal.textContent = "👀";
} else {
pwd.type = "password";
reveal.textContent = "🙈";
}
return null;
}
</script>
</html>
`;
const confirmPasswordReset: Middleware = async (ctx) => {
assert.ok(ctx.request.query.token);
assert.ok(ctx.request.query.email);
if (typeof ctx.request.query.token !== "string") {
throw new Error("Token isn't a string or is missing");
}
if (typeof ctx.request.query.email !== "string") {
throw new Error("Email isn't a string or is missing");
}
ctx.body = await render_form(
ctx.$app,
ctx.request.query.token,
ctx.request.query.email
);
};
export default confirmPasswordReset;

@ -0,0 +1,68 @@
import Router from "@koa/router";
import { Errors, Middlewares } from "sealious";
import { formHasAllFields, formHasSomeFields } from "../../common/form";
import { accountCreationDetailsForm } from "./confirm-registration-email.views";
export const confirmRegistrationRouter = (router: Router): void => {
router.get(
"/account/confirm-registration-email",
Middlewares.extractContext(),
async (ctx) => {
if (!formHasAllFields(ctx, <const>["email", "token"], ctx.query)) return;
ctx.body = await accountCreationDetailsForm(ctx, { values: ctx.query });
}
);
router.post(
"/account/confirm-registration-email",
Middlewares.extractContext(),
Middlewares.parseBody(),
async (ctx) => {
if (
!formHasSomeFields(ctx, <const>["username", "password"], ctx.$body) ||
!formHasAllFields(ctx, <const>["token", "email"], ctx.$body)
)
return;
try {
const { items: matching_intents } = await ctx.$app.collections[
"registration-intents"
]
.suList()
.filter({ token: ctx.$body.token })
.fetch();
if (matching_intents.length !== 1) {
ctx.status = 403;
return;
}
await ctx.$app.collections.users.suCreateUnsafe({
username: ctx.$body.username,
email: matching_intents[0].get("email"),
password: ctx.$body.password,
});
await (
await ctx.$app.collections["registration-intents"].getByID(
new ctx.$app.SuperContext(),
matching_intents[0].id
)
).delete(new ctx.$app.SuperContext());
ctx.status = 303;
ctx.redirect("account-created");
} catch (e) {
console.log("error", e);
if (Errors.FieldsError.isFieldsError(ctx.$app.collections.users, e)) {
ctx.status = 422;
ctx.body = await accountCreationDetailsForm(ctx, {
values: {
username: ctx.$body.username,
email: ctx.$body.email,
token: ctx.$body.token,
},
errors: e.getSimpleMessages(),
});
return;
}
}
}
);
};

@ -0,0 +1,24 @@
import axios from "axios";
import assert from "assert";
import { TestUtils } from "sealious";
import { withProdApp } from "../../../test_utils/with-prod-app";
describe("account-creation-details", () => {
it("throws when no token/email is present", () =>
withProdApp(({ base_url }) =>
TestUtils.assertThrowsAsync(
async () => {
await axios.get(`${base_url}/account-creation-details`);
},
async function () {}
)
));
it("displays an html form after the positive flow", () =>
withProdApp(async ({ base_url }) => {
const resp = await axios.get(
`${base_url}/account-creation-details?token=oieajgoiea&email=ababab@ok.pl`
);
assert.deepEqual(resp.status, 200);
assert(resp.data.length);
}));
});

@ -0,0 +1,55 @@
import { BaseContext } from "koa";
import html from "../../../html";
import navbar from "../../common/navbar";
import input from "../../common/ui/input";
export async function accountCreationDetailsForm(
ctx: BaseContext,
{
values,
errors,
}: {
values: { token: string; email: string; username?: string };
errors?: { email?: string; username?: string; password?: string };
}
) {
errors = errors || {};
return html(
ctx,
/* HTML */ `
${navbar(ctx)}
<h1>${ctx.$app.i18n("registration_intent_cta")}</h1>
<form method="POST" id="form" action="/account/confirm-registration-email">
<input type="hidden" name="token" value="${values.token || ""}" />
<fieldset>
<legend>
${ctx.$app.i18n("registration_intent_form_description")}
</legend>
${input({
name: "email",
type: "email",
value: values.email || "",
readonly: true,
error: "",
})}
${input({
name: "username",
value: values.username,
error: errors.username || "",
type: "text",
})}
${input({
name: "password",
value: "",
error: errors.password || "",
type: "password",
})}
<input
type="submit"
value="${ctx.$app.i18n("registration_intent_cta")}"
/>
</fieldset>
</form>
`
);
}

@ -0,0 +1,41 @@
import Router from "@koa/router";
import { Errors, Middlewares } from "sealious";
import html from "../../../html";
import { formHasSomeFields } from "../../common/form";
import { createAccountForm } from "./create.views";
export default function createRouter(router: Router) {
router.use("/account/create", Middlewares.extractContext());
router.get("/account/create", (ctx) => {
console.log({ ctx });
ctx.body = createAccountForm(ctx);
});
router.get(
"/account/create/email-sent",
(ctx) => (ctx.body = html(ctx, `Registration email sent`))
);
router.post("/account/create", Middlewares.parseBody(), async (ctx) => {
const registrationIntents = ctx.$app.collections["registration-intents"];
// the line below enables typescript to deduce the type of ctx.$body and
// avoid type assertions
if (!formHasSomeFields(ctx, <const>["email"], ctx.$body)) return;
try {
await registrationIntents.create(ctx.$context, ctx.$body);
ctx.status = 303;
ctx.redirect("/account/create/email-sent");
} catch (e) {
if (Errors.FieldsError.isFieldsError(registrationIntents, e)) {
ctx.status = 422;
ctx.body = createAccountForm(ctx, {
values: { email: ctx.$body.email },
errors: e,
});
} else {
ctx.body = "error";
}
}
});
}

@ -0,0 +1,32 @@
import { BaseContext } from "koa";
import { Errors } from "sealious";
import RegistrationIntents from "../../../collections/registration-intents";
import html from "../../../html";
import { CollectionTiedFormData } from "../../common/form";
import navbar from "../../common/navbar";
import input from "../../common/ui/input";
export function createAccountForm(
ctx: BaseContext,
{ values, errors }: CollectionTiedFormData<RegistrationIntents> = {
values: {},
}
) {
errors =
errors ||
new Errors.FieldsError(ctx.$app.collections["registration-intents"], {}); // empty error;
return html(
ctx,
/* HTML */ `<title>Sign up</title>${navbar(ctx)}
<h1>Register</h1>
<form action="/account/create" method="POST">
${input({
name: "email",
value: values.email,
type: "email",
error: errors.getErrorForField("email"),
})}
<input type="submit" value="register" />
</form>`
);
}

@ -0,0 +1,65 @@
import assert from "assert";
import { AxiosError } from "axios";
import { TestUtils } from "sealious";
import TheApp from "../../app";
import { withProdApp } from "../../test_utils/with-prod-app";
describe.only("finalize password reset", () => {
async function createAUser(app: TheApp) {
await app.collections.users.suCreate({
username: "user",
email: "user@example.com",
password: "password",
roles: [],
});
}
it("allows to change a password (entire flow)", async () =>
withProdApp(async ({ app, mail_api, rest_api }) => {
await createAUser(app);
const options = await rest_api.login({
username: "user",
password: "password",
});
await rest_api.delete("/api/v1/collections/sessions/current", options);
await rest_api.post("/api/v1/collections/password-reset-intents", {
email: "user@example.com",
});
const message_metadata = (await mail_api.getMessages()).filter(
(message) => message.recipients[0] == "<user@example.com>"
)[0];
assert(message_metadata.subject);
const message = await mail_api.getMessageById(message_metadata.id);
const matches = /token=([^?&]+)/.exec(message);
if (!matches) {
throw new Error("token not found in the message");
}
const token = matches[1];
await rest_api.post("/finalize-password-reset", {
email: "user@example.com",
token,
password: "new-password",
});
await rest_api.post(
"/api/v1/sessions",
{ username: "user", password: "new-password" },
options
);
await TestUtils.assertThrowsAsync(
async () =>
rest_api.post("/finalize-password-reset", {
email: "user@example.com",
token,
password: "using the same token twice hehehehhee",
}),
(e: AxiosError) => {
assert.strictEqual(e?.response?.data?.message, "Incorrect token");
}
);
}));
});

@ -0,0 +1,52 @@
import { Middleware } from "@koa/router";
import { URL } from "url";
import { Errors } from "sealious";
import { hasShape, predicates } from "@sealcode/ts-predicates";
const finalizePasswordReset: Middleware = async (ctx) => {
if (
!hasShape(
{
redirect: predicates.or(predicates.string, predicates.undefined),
token: predicates.string,
password: predicates.string,
},
ctx.$body
)
) {
throw new Error("Wrong parameters. Needed: token, password. Optional: redirect.");
}
const intent_response = await ctx.$app.collections["password-reset-intents"]
.suList()
.filter({ token: ctx.$body.token })
.fetch();
if (intent_response.empty) {
throw new Errors.BadContext("Incorrect token");
}
const intent = intent_response.items[0];
const user_response = await ctx.$app.collections.users
.suList()
.filter({ email: intent.get("email") as string })
.fetch();
if (user_response.empty) {
throw new Error("No user with this email address.");
}
user_response.items[0].set("password", ctx.$body.password);
await user_response.items[0].save(new ctx.$app.SuperContext());
await intent.remove(new ctx.$app.SuperContext());
if (
ctx.$body.redirect &&
new URL(ctx.$app.manifest.base_url).origin == new URL(ctx.$body.redirect).origin
) {
ctx.redirect(ctx.$body.redirect);
} else {
ctx.body = "Password reset successful";
}
};
export default finalizePasswordReset;

@ -0,0 +1,43 @@
import * as assert from "assert";
import { withProdApp } from "../../test_utils/with-prod-app";
describe("finalize registration", () => {
it("allows to register an account (entire flow)", async () =>
withProdApp(async ({ app, mail_api, rest_api }) => {
app.ConfigManager.set("roles", ["admin"]);
await rest_api.post("/api/v1/collections/registration-intents", {
email: "user@example.com",
role: "admin",
});
const message_metadata = (await mail_api.getMessages()).filter(
(message) => message.recipients[0] == "<user@example.com>"
)[0];
assert.ok(message_metadata?.subject);
const message = await mail_api.getMessageById(message_metadata.id);
const match_result = /token=([^?&]+)/.exec(message);
if (!match_result) {
throw new Error("Didn't find a token");
}
const token = match_result[1];
await rest_api.post("/finalize-registration-intent", {
email: "user@example.com",
token,
password: "password",
username: "user",
});
const options = await rest_api.login({
username: "user",
password: "password",
});
const response = await rest_api.get(
"/api/v1/collections/users/me?attachments[roles]=true",
options
);
assert.equal(response.items[0].roles.length, 1);
assert.equal(response.attachments[response.items[0].roles[0]].role, "admin");
}));
});

@ -0,0 +1,53 @@
import { Middleware } from "@koa/router";
import { hasShape, predicates } from "@sealcode/ts-predicates";
import assert from "assert";
const finalizeRegistrationIntent: Middleware = async (ctx) => {
if (
!hasShape(
{
token: predicates.string,
username: predicates.string,
password: predicates.string,
},
ctx.$body
)
) {
throw new Error("Missing attributes. Required: token, username, password");
}
const intents = await ctx.$app.collections["registration-intents"]
.suList()
.filter({ token: ctx.$body.token })
.fetch();
if (intents.empty) {
throw new Error("Incorrect token");
}
const intent = intents.items[0];
const user = await ctx.$app.collections.users.suCreate({
password: ctx.$body.password,
username: ctx.$body.username,
email: intent.get("email") as string,
roles: [],
});
if (intent.get("role")) {
await ctx.$app.collections["user-roles"].suCreate({
user: user.id,
role: intent.get("role") as string,
});
}
await intent.remove(new ctx.$app.SuperContext());
const target_path = ctx.$app.ConfigManager.get("accout_creation_success_path");
if (target_path) {
assert.strictEqual(
target_path[0],
"/",
"'accout_creation_success_path' set, but doesn't start with a '/'"
);
ctx.body = `<meta http-equiv="refresh" content="0; url=${target_path}" />`;
}
ctx.body = "Account creation successful";
ctx.status = 201;
};
export default finalizeRegistrationIntent;

@ -0,0 +1,49 @@
import { BaseContext } from "koa";
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
import { Collection, Errors } from "sealious";
import { ItemFields } from "sealious/@types/src/chip-types/collection-item-body";
export interface CollectionTiedFormData<C extends Collection> {
values: Partial<{ [field in keyof ItemFields<C>]: string }>;
errors?: Errors.FieldsError<C>;
}
export function formHasAllFields<Fields extends readonly string[]>(
ctx: BaseContext,
fields: Fields,
obj: unknown
): obj is { [field in Fields[number]]: string } {
const valid =
is(obj, predicates.object) &&
hasShape(
Object.fromEntries(fields.map((field) => [field, predicates.string])),
obj
);
if (!valid) {
ctx.status = 422;
if (is(obj, predicates.object)) {
ctx.body = `Missing params: ${fields
.filter((field) => !Object.keys(obj).includes(field))
.join(", ")}`;
}
}
return valid;
}
export function formHasSomeFields<Fields extends readonly string[]>(
ctx: BaseContext,
fields: Fields,
obj: unknown
): obj is Partial<{ [field in Fields[number]]: string }> {
const valid =
is(obj, predicates.object) &&
hasShape(
Object.fromEntries(fields.map((field) => [field, predicates.string])),
obj
);
if (!valid) {
ctx.status = 422;
ctx.body = "Wrong type of params, expected string or undefined";
}
return valid;
}

@ -1,14 +1,16 @@
import html from "../html";
import html from "../../html";
import { BaseContext } from "koa";
import { Readable } from "stream";
import { tempstream } from "tempstream";
import { NewTask, TaskList } from "../views/tasks";
import navbar from "./navbar";
import { NewTask, TaskList } from "../tasks/tasks.views";
export function MainView(ctx: BaseContext): Readable {
return html(
ctx,
tempstream/* HTML */ ` <title>My Own ToDo App</title>
<body>
${navbar(ctx)}
<h1>My ToDo App (with esbuild!)</h1>
${TaskList(ctx.$context)} ${NewTask()}

@ -0,0 +1,18 @@
import { BaseContext } from "koa";
export default function navbar(ctx: BaseContext) {
return /* HTML */ ` <nav>
<a href="/" style="display: flex; align-items: center">
<img
src="/assets/logo"
alt="${ctx.$app.manifest.name} - logo"
width="50"
height="50"
/>
Sealious Playground
</a>
<ul>
<li><a href="/account/create">Register</a></li>
</ul>
</nav>`;
}

@ -0,0 +1,5 @@
.input {
&__error {
color: red;
}
}

@ -0,0 +1,38 @@
export default function input({
name,
id,
label,
type,
value,
placeholder,
error,
readonly,
}: {
name: string;
id?: string;
label?: string;
type?: string;
value?: string;
placeholder?: string;
readonly?: boolean;
error: string;
}) {
id = id || name;
label = label || name;
type = type || "text";
value = value || "";
placeholder = placeholder || type;
readonly = readonly || false;
return /* HTML */ `<div class="input">
<label for="${id}">${label}</label>
<input
id="${id}"
type="${type}"
name="${name}"
value="${value}"
placeholder="${placeholder}"
${readonly ? "readonly" : ""}
/>
${error ? `<div class="input__error">${error}</div>` : ""}
</div>`;
}

@ -1,8 +1,9 @@
import Router from "@koa/router";
import { Middlewares } from "sealious";
import { loginRouter } from "./login/index.js";
import { MainView } from "./main-view.js";
import { tasksRouter } from "./tasks/index.js";
import { accountsRouter } from "./account/account.routes";
import { MainView } from "./common/main-view";
import { loginRouter } from "./login/login.routes";
import { tasksRouter } from "./tasks/tasks.routes";
export const mainRouter = (router: Router): void => {
router.get("/", Middlewares.extractContext(), async (ctx) => {
@ -11,4 +12,5 @@ export const mainRouter = (router: Router): void => {
loginRouter(router);
tasksRouter(router);
accountsRouter(router);
};

@ -27,10 +27,7 @@ export const loginRouter = (router: Router): void => {
ctx.status = 422;
ctx.body = html(
ctx,
LoginForm(
ctx.$body.username as string,
(e as Error).message
)
LoginForm(ctx.$body.username as string, (e as Error).message)
);
}
}

@ -1,6 +1,6 @@
import Router from "@koa/router";
import { Middlewares } from "sealious";
import { MainView } from "../main-view";
import { MainView } from "../common/main-view";
export const tasksRouter = (router: Router): void => {
router.post(
@ -18,16 +18,12 @@ export const tasksRouter = (router: Router): void => {
}
);
router.delete(
"/tasks/:task_id",
Middlewares.extractContext(),
async (ctx) => {
router.delete("/tasks/:task_id", Middlewares.extractContext(), async (ctx) => {
const task = await ctx.$app.collections.tasks.getByID(
ctx.$context,
ctx.params.task_id
);
await task.remove(ctx.$context);
ctx.body = MainView(ctx);
}
);
});
};

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { CollectionItem, Context } from "sealious";
import frame from "../frame";
import frame from "../../frame";
export function Task(task: CollectionItem<never>): string {
return frame(
@ -14,11 +14,7 @@ export function Task(task: CollectionItem<never>): string {
${task.get("done") ? "checked" : ""}
/>
${task.get("title")}
<form
method="DELETE"
action="/tasks/${task.id}"
data-turbo-frame="task-list"
>
<form method="DELETE" action="/tasks/${task.id}" data-turbo-frame="task-list">
<input class="delete-button" type="submit" value="🗑" />
</form>
</li>`
@ -26,9 +22,7 @@ export function Task(task: CollectionItem<never>): string {
}
export async function TaskList(context: Context): Promise<string> {
const { items: tasks } = await context.app.collections.tasks
.list(context)
.fetch();
const { items: tasks } = await context.app.collections.tasks.list(context).fetch();
return frame(
"task-list",
/* HTML */ `
@ -42,11 +36,7 @@ export async function TaskList(context: Context): Promise<string> {
export function NewTask(): string {
return frame(
"new-task",
/* HTML */ `<form
method="POST"
action="/tasks"
data-turbo-frame="task-list"
>
/* HTML */ `<form method="POST" action="/tasks" data-turbo-frame="task-list">
<input
id="new-task-title"
type="text"

@ -0,0 +1,69 @@
import TheApp from "../app";
import { mainRouter } from "../routes";
import _locreq from "locreq";
const locreq = _locreq(__dirname);
import Sealious, { SMTPMailer } from "sealious";
import { TestUtils } from "sealious";
declare module "koa" {
interface BaseContext {
$context: Sealious.Context;
$app: TheApp;
$body: Record<string, unknown>;
}
}
export async function withProdApp(
callback: (args: {
app: TheApp;
base_url: string;
rest_api: TestUtils.MockRestApi;
mail_api: TestUtils.MailcatcherAPI;
}) => Promise<void>
) {
const app = new TheApp();
const port = 9999;
app.config["www-server"].port = port;
app.config.datastore_mongo = {
host: "db",
port: 27017,
db_name: "sealious-playground-test",
};
app.config.logger.level = <const>"none";
app.mailer = new SMTPMailer({
host: "mailcatcher",
port: 1025,
user: "any",
password: "any",
});
await app.start();
mainRouter(app.HTTPServer.router);
app.HTTPServer.addStaticRoute("/", locreq.resolve("public"));
const base_url = `http://127.0.0.1:${port}`;
const mail_api = new TestUtils.MailcatcherAPI("http://mailcatcher:1080", app);
await mail_api.deleteAllInstanceEmails();
async function stop() {
await app.removeAllData();
await app.stop();
}
try {
await callback({
app,
base_url,
rest_api: new TestUtils.MockRestApi(base_url),
mail_api,
});
await stop();
} catch (e) {
await stop();
console.error(e);
throw e;
}
}

@ -26,3 +26,7 @@ body {
line-height: 0;
padding: 0.5rem;
}
// === Views ===
@import "back/routes/common/ui/input.scss";

@ -1,72 +0,0 @@
const mri = require("mri");
const { spawn } = require("child_process");
const argv = process.argv.slice(2);
const args = mri(argv);
const bin_dir = "./node_modules/.bin/";
const mocha = bin_dir + "mocha";
let mocha_options = [
"--recursive",
"--timeout=10000",
"--require",
"source-map-support/register",
];
if (args["test-report"]) {
mocha_options = [
...mocha_options,
// "--require",
// "ts-node/register",
// "--require",
// "./src/http/type-overrides.ts",
"--reporter",
"xunit",
"--reporter-option",
"output=.xunit",
];
}
const mocha_files = ["dist/**/*.test.js"];
let command = [mocha, ...mocha_options, ...mocha_files];
if (args.cover) {
const nyc = [
bin_dir + "nyc",
"-all",
"--exclude",
"src/front",
"--exclude",
"dist",
"--source-map",
"false",
];
if (args["cover-html"]) {
nyc.push("--reporter", "lcov");
} else {
nyc.push("--reporter", "clover");
}
command = [...nyc, ...command];
}
if (args.debug) {
command = ["node", "inspect", ...command];
}
console.log("spawning mocha...", command);
const proc = spawn(command[0], command.slice(1), {
stdio: "inherit",
env: process.env,
});
proc.on("exit", function (code) {
if (args["test-report"]) {
process.exit(0);
} else {
process.exit(code);
}
});
Loading…
Cancel
Save