~james/core

core/e2e/specs/session.spec.ts -rw-r--r-- 9.2 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 17 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
import { type BrowserContext, expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";

import { type Account, authFile, config, seeded } from "../harness/config";
import {
	bobbin,
	component,
	type Ctx,
	flow,
	hydrant,
	knots,
	loginThen,
	page,
	pds,
	starId,
	target,
	testid,
	through,
	where,
	within
} from "../harness/flow";
import { parseBody, stamp } from "../harness/net";
import { signInHere } from "../harness/signin";
import { forgetTheLogin, recall, remember } from "../harness/shared";

const menuOf = (who: (ctx: Ctx) => Account) =>
	target("the account menu", (tab, ctx) =>
		tab.getByRole("banner").getByRole("button", { name: who(ctx).handle })
	);
const actorMenu = menuOf((ctx) => ctx.actor);
const logout = testid("signOut");

const otherOf = (ctx: Ctx) => recall(ctx, "other");

const signInFromHere = (who: (ctx: Ctx) => Account) => (ctx: Ctx) => signInHere(ctx.page, who(ctx));
const signInAsActor = signInFromHere((ctx) => ctx.actor);

const browserState = (ctx: Ctx) =>
	ctx.page.evaluate(() => ({
		current: localStorage.getItem("tangled.currentDid"),
		sessions: localStorage.getItem("atcute-oauth:sessions") ?? "",
		accounts: localStorage.getItem("tangled.accounts") ?? ""
	}));

// dids become "did" so two logins compare equal
const dropDids = (value: unknown): unknown => {
	if (Array.isArray(value)) return value.map(dropDids);
	if (!value || typeof value !== "object") return typeof value;
	return Object.fromEntries(
		Object.entries(value).map(([key, inner]) => [
			key.startsWith("did:") ? "did" : key,
			dropDids(inner)
		])
	);
};
const kept = (state: Awaited<ReturnType<BrowserContext["storageState"]>>) => ({
	cookies: state.cookies
		.map(
			(c) =>
				`${c.domain} ${c.name} ${c.httpOnly} ${c.secure} ${c.sameSite} ${c.expires === -1}`
		)
		.sort(),
	storage: state.origins.map(({ origin, localStorage }) => ({
		origin,
		items: Object.fromEntries(
			localStorage.map(({ name, value }) => [name, dropDids(parseBody(value))])
		)
	}))
});

const keptLikeALogin = async (ctx: Ctx) => {
	const login = JSON.parse(readFileSync(authFile(seeded.alice), "utf8"));
	expect(kept(await ctx.page.context().storageState())).toEqual(kept(login));
};

const firstStar = (name: string, options: { login?: "ui" } = {}) =>
	flow(name)
		.actor("owner")
		.repo()
		.actor("fan", options)

		.open(page.repo)
		.sees(actorMenu)
		.step("the browser keeps the session the way a login does", keptLikeALogin)
		.click(testid("star", { starred: "false" }))
		.sends(
			starId,
			pds,
			(ctx) => ({ record: { subject: { identity: ctx.repo.did } } }),
			"create"
		)
		.trace(starId, through(pds, hydrant, bobbin))
		.reload()
		.sees(testid("star", { starred: "true" }));

firstStar("a new account logged in through the login page stars a repo", { login: "ui" });
firstStar("a new account with a session minted from node stars a repo");

flow("logging out of one of two accounts in a browser keeps the other signed in")
	.actor("second")
	.step("remember the second account", remember("other"))
	.actor("first", { login: "ui" })

	.open(knots)
	.click(actorMenu)
	.click(testid("addAccount"))
	.at(loginThen(() => "/settings/knots"))
	.step("the second account signs in beside the first", signInFromHere(otherOf))
	.at(knots)
	.sees(menuOf(otherOf))

	.click(menuOf(otherOf))
	.sees(logout)
	.click(logout)
	.sees(actorMenu)
	.step("the browser keeps the first account and has let go of the second", async (ctx) => {
		const saw = await browserState(ctx);
		expect(saw.current).toBe(ctx.actor.did);
		expect(saw.sessions).toContain(ctx.actor.did);
		expect(saw.sessions).not.toContain(otherOf(ctx).did);
		expect(saw.accounts).not.toContain(otherOf(ctx).did);
	})
	.reload()
	.sees(actorMenu)
	.open(knots)
	.at(knots);

flow("logging out ends the session and a settings page asks for a login again")
	.actor("leaver", { login: "ui" })

	.open(page.profile)
	.step("the page is signed in", async (ctx) => {
		expect((await browserState(ctx)).current).toBe(ctx.actor.did);
	})
	.click(actorMenu)
	.click(logout)
	.sees(within(component("Topbar"), testid("login")))
	.step("the browser has forgotten the account", async (ctx) => {
		const saw = await browserState(ctx);
		expect(saw.current).toBeNull();
		expect(saw.sessions).not.toContain(ctx.actor.did);
		const cookies = await ctx.page.context().cookies();
		expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")).toBeUndefined();
	})
	.open(knots)
	.at(loginThen(() => "/settings/knots"));

flow("switching to the other account in the menu changes who the page is signed in as")
	.actor("second")
	.step("remember the second account", remember("other"))
	.actor("first", { login: "ui" })

	.open(knots)
	.click(actorMenu)
	.click(testid("addAccount"))
	.step("the second account signs in beside the first", signInFromHere(otherOf))
	.at(knots)
	.sees(menuOf(otherOf))

	.click(menuOf(otherOf))
	.click(
		target("the first account in the menu", (tab, ctx) =>
			tab.getByRole("menuitem").filter({ hasText: ctx.actor.handle })
		)
	)
	.sees(actorMenu)
	.step(
		"the browser says the first account is the one in use and still has both",
		async (ctx) => {
			const saw = await browserState(ctx);
			expect(saw.current).toBe(ctx.actor.did);
			expect(saw.sessions).toContain(otherOf(ctx).did);
			const cookies = await ctx.page.context().cookies();
			expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")?.value).toContain(
				ctx.actor.did.replace(/:/g, "%3A")
			);
		}
	)
	.reload()
	.sees(actorMenu);

const followeeProfile = where("the followee's profile", (ctx) => `/${otherOf(ctx).handle}`);

flow("a visitor who clicks Follow is sent to log in and comes back to that profile")
	.actor("star")
	.step("remember who is followed", remember("other"))
	.actor("fan", { login: "ui" })

	.open(followeeProfile)
	.step("signs out", forgetTheLogin)
	.open(followeeProfile)
	.click(testid("follow", { following: "false" }))
	.at(loginThen((ctx) => `/${otherOf(ctx).handle}`))
	.step("signs back in", signInAsActor)
	.at(followeeProfile)
	.sees(testid("follow", { following: "false" }));

flow("a visitor who clicks Star is sent to log in and comes back to that repo")
	.actor("owner")
	.repo()
	.actor("reader", { login: "ui" })

	.open(page.repo)
	.step("signs out", forgetTheLogin)
	.open(page.repo)
	.click(testid("star", { starred: "false" }))
	.at(loginThen((ctx) => ctx.repo.path))
	.step("signs back in", signInAsActor)
	.at(page.repo)
	.sees(testid("star", { starred: "false" }));

flow("a visitor who opens a repo's settings is sent to log in and comes back to them")
	.actor("owner", { login: "ui" })
	.repo()

	.open(page.repo)
	.step("signs out", forgetTheLogin)
	.open(page.settings)
	.at(loginThen((ctx) => `${ctx.repo.path}/settings`))
	.step("signs back in", signInAsActor)
	.at(page.settings)
	.sees(testid("settingsSectionTitle", { name: "danger" }));

const knotsWithQuery = where("the knots settings with a query", () => "/settings/knots?from=email");

flow("a visitor who opens the knot settings is sent to log in and comes back to them")
	.actor("reader", { login: "ui" })

	.open(knots)
	.step("signs out", forgetTheLogin)
	.open(knotsWithQuery)
	.at(loginThen(() => "/settings/knots?from=email"))
	.step("signs back in", signInAsActor)
	.at(knotsWithQuery)
	.sees(testid("settingsHeaderTitle", { name: "knots" }));

const inviteHost = "knot.invited.example.test";
const invite = where("the invite page", (ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`);
const codes = new WeakMap<Ctx, string>();
// the page only reads the code, so a made up one is fine until Join
const pickCode = async (ctx: Ctx) => {
	codes.set(ctx, `e2e-${stamp()}`);
};
const code = (ctx: Ctx) => codes.get(ctx)!;

flow("an invite link opened signed out logs you in and lands on the join page")
	.actor("guest", { login: "ui" })
	.step("pick a code", pickCode)

	.open(page.profile)
	.step("signs out", forgetTheLogin)
	.open(invite)
	.at(loginThen((ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`))
	.step("signs in", signInAsActor)
	.at(invite)
	.sees(testid("joinHeading", { knot: inviteHost }))
	.sees(testid("join"));

test("the login page shows an error when it is opened with one", async ({ page: tab }) => {
	await tab.goto("/login?error=session", { waitUntil: "networkidle" });
	await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible();
});

test("a handle that does not exist is told so on the login page and stays there", async ({
	page: tab
}) => {
	await tab.goto("/login", { waitUntil: "networkidle" });
	const handle = tab.getByTestId("identifier");
	await handle.fill(`nobody-${stamp()}.pds.tngl.boltless.dev`);
	await handle.press("Enter");
	await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible();
	await expect(tab.locator('[data-testid="loginErrorMessage"]')).toBeVisible();
	await expect(tab).toHaveURL(`${config.webUrl}/login`);
});

const guarded = [
	"/welcome",
	"/strings/new",
	"/organizations/new",
	"/settings/profile/delete?step=1",
	"/settings/keys/new"
];

for (const path of guarded) {
	test(`a visitor opening ${path} is sent to log in with that page to come back to`, async ({
		page: tab
	}) => {
		await tab.goto(path, { waitUntil: "networkidle" });
		await expect(tab).toHaveURL(
			`${config.webUrl}/login?return_url=${encodeURIComponent(path)}`
		);
	});
}