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
This commit is contained in:
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;
|
||||
|
6952
package-lock.json
generated
6952
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@ -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"
|
||||
|
6
src/back/collections/collections.test.ts
Normal file
6
src/back/collections/collections.test.ts
Normal file
@ -0,0 +1,6 @@
|
||||
describe("collections", () => {
|
||||
require("./password-reset-intents.subtest");
|
||||
require("./registration-intents.subtest");
|
||||
require("./user-roles.subtest");
|
||||
require("./users.subtest");
|
||||
});
|
89
src/back/collections/password-reset-intents.subtest.ts
Normal file
89
src/back/collections/password-reset-intents.subtest.ts
Normal file
@ -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>");
|
||||
}));
|
||||
});
|
39
src/back/collections/password-reset-intents.ts
Normal file
39
src/back/collections/password-reset-intents.ts
Normal file
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
48
src/back/collections/registration-intents.subtest.ts
Normal file
48
src/back/collections/registration-intents.subtest.ts
Normal file
@ -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");
|
||||
}));
|
||||
});
|
43
src/back/collections/registration-intents.ts
Normal file
43
src/back/collections/registration-intents.ts
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
12
src/back/collections/secrets.ts
Normal file
12
src/back/collections/secrets.ts
Normal file
@ -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();
|
71
src/back/collections/user-roles.subtest.ts
Normal file
71
src/back/collections/user-roles.subtest.ts
Normal file
@ -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);
|
||||
}));
|
||||
});
|
37
src/back/collections/user-roles.ts
Normal file
37
src/back/collections/user-roles.ts
Normal file
@ -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();
|
20
src/back/collections/users.subtest.ts
Normal file
20
src/back/collections/users.subtest.ts
Normal file
@ -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"));
|
||||
}));
|
||||
});
|
52
src/back/collections/users.ts
Normal file
52
src/back/collections/users.ts
Normal file
@ -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();
|
31
src/back/email-templates/password-reset.ts
Normal file
31
src/back/email-templates/password-reset.ts
Normal file
@ -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}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
19
src/back/email-templates/registration-intent.ts
Normal file
19
src/back/email-templates/registration-intent.ts
Normal file
@ -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);
|
||||
|
3
src/back/policy-types/policy-types.test.ts
Normal file
3
src/back/policy-types/policy-types.test.ts
Normal file
@ -0,0 +1,3 @@
|
||||
describe("policy-types", () => {
|
||||
require("./roles.subtest");
|
||||
});
|
58
src/back/policy-types/roles.subtest.ts
Normal file
58
src/back/policy-types/roles.subtest.ts
Normal file
@ -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(", ")])
|
||||
);
|
||||
}
|
||||
);
|
||||
}));
|
||||
});
|
55
src/back/policy-types/roles.ts
Normal file
55
src/back/policy-types/roles.ts
Normal file
@ -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(", ")])
|
||||
);
|
||||
}
|
||||
}
|
25
src/back/routes/account/account.routes.ts
Normal file
25
src/back/routes/account/account.routes.ts
Normal file
@ -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);
|
||||
};
|
6
src/back/routes/account/account.test.ts
Normal file
6
src/back/routes/account/account.test.ts
Normal file
@ -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");
|
||||
});
|
11
src/back/routes/account/confirm-password-reset.subtest.ts
Normal file
11
src/back/routes/account/confirm-password-reset.subtest.ts
Normal file
@ -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`
|
||||
);
|
||||
}));
|
||||
});
|
80
src/back/routes/account/confirm-password-reset.ts
Normal file
80
src/back/routes/account/confirm-password-reset.ts
Normal file
@ -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>
|
||||
`
|
||||
);
|
||||
}
|
41
src/back/routes/account/create/create.routes.ts
Normal file
41
src/back/routes/account/create/create.routes.ts
Normal file
@ -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";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
32
src/back/routes/account/create/create.views.ts
Normal file
32
src/back/routes/account/create/create.views.ts
Normal file
@ -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>`
|
||||
);
|
||||
}
|
65
src/back/routes/account/finalize-password-reset.subtest.ts
Normal file
65
src/back/routes/account/finalize-password-reset.subtest.ts
Normal file
@ -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");
|
||||
}
|
||||
);
|
||||
}));
|
||||
});
|
52
src/back/routes/account/finalize-password-reset.ts
Normal file
52
src/back/routes/account/finalize-password-reset.ts
Normal file
@ -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");
|
||||
}));
|
||||
});
|
53
src/back/routes/account/finalize-registration-intent.ts
Normal file
53
src/back/routes/account/finalize-registration-intent.ts
Normal file
@ -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;
|
49
src/back/routes/common/form.ts
Normal file
49
src/back/routes/common/form.ts
Normal file
@ -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()}
|
18
src/back/routes/common/navbar.ts
Normal file
18
src/back/routes/common/navbar.ts
Normal file
@ -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>`;
|
||||
}
|
5
src/back/routes/common/ui/input.scss
Normal file
5
src/back/routes/common/ui/input.scss
Normal file
@ -0,0 +1,5 @@
|
||||
.input {
|
||||
&__error {
|
||||
color: red;
|
||||
}
|
||||
}
|
38
src/back/routes/common/ui/input.ts
Normal file
38
src/back/routes/common/ui/input.ts
Normal file
@ -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) => {
|
||||
const task = await ctx.$app.collections.tasks.getByID(
|
||||
ctx.$context,
|
||||
ctx.params.task_id
|
||||
);
|
||||
await task.remove(ctx.$context);
|
||||
ctx.body = MainView(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"
|
69
src/back/test_utils/with-prod-app.ts
Normal file
69
src/back/test_utils/with-prod-app.ts
Normal file
@ -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";
|
72
test.js
72
test.js
@ -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…
x
Reference in New Issue
Block a user