115 lines
5.6 KiB
TypeScript
115 lines
5.6 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The account module's data-subject rights on the mariadb and mongodb profiles: the same store
|
|
// (modules/account/adapters/store.ts) over the profile's real data source, with the kernel's and the
|
|
// modules' committed dialect migrations applied first. A person's export holds their rows and nobody
|
|
// else's, and their deletion erases them and leaves another person's account as it was.
|
|
//
|
|
// It needs the engines: ACCOUNT_TEST_MARIADB_URL (MariaDB LTS) and ACCOUNT_TEST_MONGODB_URL (MongoDB 8 as
|
|
// a single-node replica set, AT-D15), each naming a database of its own that the test may fill. Without
|
|
// them each block is skipped by name; `scripts/vendor/test-template.sh app --profile <name>` starts both.
|
|
|
|
import { type DialectProfile, expectedDialectMigrations } from "@app/config"
|
|
import { sessions } from "@conusai-kit/auth"
|
|
import { buildRegistry } from "@conusai-kit/core"
|
|
import { entitiesOf } from "@conusai-kit/data"
|
|
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
|
|
import { orgMemberships, users } from "@conusai-kit/tenancy"
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
import type { DialectData } from "../../apps/worker/src/dialect/engine.js"
|
|
import { engineFor } from "../../apps/worker/src/dialect/runtime.js"
|
|
import { makeApp, type Person } from "../../modules/account/__tests__/harness.js"
|
|
import type { AccountExport, DeletionView } from "../../modules/account/index.js"
|
|
import { accountDeletions } from "../../modules/account/index.js"
|
|
import { freshDialectModules } from "./fixture.js"
|
|
|
|
const ENGINES: readonly [DialectProfile, string, string | undefined][] = [
|
|
["mariadb", "MARIADB_URL", process.env.ACCOUNT_TEST_MARIADB_URL],
|
|
["mongodb", "MONGODB_URL", process.env.ACCOUNT_TEST_MONGODB_URL],
|
|
]
|
|
|
|
/** What the migrate job of the profile applies (apps/worker/src/dialect/), on the engine under test. */
|
|
async function migrate(
|
|
profile: DialectProfile,
|
|
env: Record<string, string>,
|
|
registry: ReturnType<typeof buildRegistry>,
|
|
): Promise<string[]> {
|
|
const outcome = await engineFor(profile).migrate(env, registry)
|
|
return [...outcome.applied, ...outcome.skipped]
|
|
}
|
|
|
|
async function orgOf(person: Person, slug: string): Promise<string> {
|
|
const created = (await person.call().callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
|
return created.orgId
|
|
}
|
|
|
|
for (const [profile, variable, url] of ENGINES) {
|
|
const modules = freshDialectModules(profile)
|
|
const registry = buildRegistry(modules)
|
|
describe.skipIf(!url)(`account data-subject rights on ${profile} (${variable})`, () => {
|
|
let data: DialectData
|
|
const env = { [variable]: url ?? "" }
|
|
|
|
beforeAll(async () => {
|
|
// The migrate job's whole list applies on the engine, and it is the list readiness expects.
|
|
expect((await migrate(profile, env, registry)).sort()).toEqual(
|
|
(await expectedDialectMigrations(registry, profile)).sort(),
|
|
)
|
|
const entities = [...entitiesOf(registry, profile), ...KIT_JOBS_ENTITIES]
|
|
expect(entities.map(({ table }) => table)).toEqual(
|
|
expect.arrayContaining(["account_deletion", "account_session_revocation"]),
|
|
)
|
|
data = await engineFor(profile).connect(env, entities)
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
await data?.close()
|
|
})
|
|
|
|
it("exports the person's own rows and deletes the account, leaving another person's as it was", async () => {
|
|
const app = makeApp({ data, modules })
|
|
const tag = Math.random().toString(36).slice(2, 8)
|
|
const ada = await app.signUp(`ada-${tag}@example.test`)
|
|
const grace = await app.signUp(`grace-${tag}@example.test`)
|
|
const adaOrg = await orgOf(ada, `solo-${tag}`)
|
|
await orgOf(grace, `other-${tag}`)
|
|
|
|
const exported = (await ada.call().callByName("account.data.export", {})) as AccountExport
|
|
expect(exported).toMatchObject({ subject: ada.actor.userId, complete: true, failed: [] })
|
|
expect(exported.sections.account).toMatchObject({
|
|
status: "ok",
|
|
data: { profile: { userId: ada.actor.userId, email: ada.email }, organizations: [{ slug: `solo-${tag}` }] },
|
|
})
|
|
// Negative control: nothing of the other person is in it.
|
|
expect(JSON.stringify(exported)).not.toContain(grace.email)
|
|
expect(JSON.stringify(exported)).not.toContain(`other-${tag}`)
|
|
|
|
const view = (await ada.call().callByName("account.deletion.request", { confirm: ada.email })) as DeletionView
|
|
expect(view).toMatchObject({ state: "completed", pending: [], abandoned: [] })
|
|
|
|
expect(await app.data.global(users).get(ada.actor.userId)).toBeNull()
|
|
expect(
|
|
await app.data.forScope({ level: "org", orgId: adaOrg, actor: ada.actor }).of(orgMemberships).count(),
|
|
).toBe(0)
|
|
const adaSessions = (
|
|
await app.data.global(sessions).list({ where: [{ field: "userId", op: "eq", value: ada.actor.userId }] })
|
|
).items
|
|
expect(adaSessions).toEqual([])
|
|
const ledger = (
|
|
await app.data
|
|
.global(accountDeletions)
|
|
.list({ where: [{ field: "userId", op: "eq", value: ada.actor.userId }] })
|
|
).items
|
|
expect(ledger).toMatchObject([{ state: "completed" }])
|
|
expect(JSON.stringify(ledger)).not.toContain(ada.email)
|
|
|
|
// The other person keeps everything: their user row, their membership and their export.
|
|
expect((await app.data.global(users).get(grace.actor.userId))?.email).toBe(grace.email)
|
|
const graceExport = (await grace.call().callByName("account.data.export", {})) as AccountExport
|
|
expect(graceExport.sections.account).toMatchObject({
|
|
status: "ok",
|
|
data: { organizations: [{ slug: `other-${tag}` }] },
|
|
})
|
|
}, 60_000)
|
|
})
|
|
}
|