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/D1217master
parent
942c9b443d
commit
9b9f1df7a8
File diff suppressed because it is too large
Load Diff
@ -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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
@ -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 { BaseContext } from "koa";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import { tempstream } from "tempstream";
|
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 {
|
export function MainView(ctx: BaseContext): Readable {
|
||||||
return html(
|
return html(
|
||||||
ctx,
|
ctx,
|
||||||
tempstream/* HTML */ ` <title>My Own ToDo App</title>
|
tempstream/* HTML */ ` <title>My Own ToDo App</title>
|
||||||
<body>
|
<body>
|
||||||
|
${navbar(ctx)}
|
||||||
<h1>My ToDo App (with esbuild!)</h1>
|
<h1>My ToDo App (with esbuild!)</h1>
|
||||||
|
|
||||||
${TaskList(ctx.$context)} ${NewTask()}
|
${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>`;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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…
Reference in New Issue