// 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 ` 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, registry: ReturnType, ): Promise { const outcome = await engineFor(profile).migrate(env, registry) return [...outcome.applied, ...outcome.skipped] } async function orgOf(person: Person, slug: string): Promise { 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) }) }