feat(identity): add auth user import tool
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "018f4e6d-7a11-7000-8000-000000000001",
|
||||
"email": " Person@Example.com ",
|
||||
"email_confirmed_at": "2026-07-01T01:02:03.000Z",
|
||||
"created_at": "2026-06-01T01:02:03.000Z",
|
||||
"updated_at": "2026-07-02T01:02:03.000Z",
|
||||
"raw_user_meta_data": {
|
||||
"full_name": "Person One",
|
||||
"avatar_url": "https://example.com/person.png"
|
||||
},
|
||||
"encrypted_password": "must-not-be-imported",
|
||||
"last_sign_in_at": "2026-07-10T01:02:03.000Z"
|
||||
},
|
||||
{
|
||||
"id": "018f4e6d-7a11-7000-8000-000000000002",
|
||||
"email": "second@example.com",
|
||||
"email_confirmed_at": null,
|
||||
"created_at": "2026-06-02T01:02:03.000Z",
|
||||
"updated_at": "2026-06-02T01:02:03.000Z",
|
||||
"raw_user_meta_data": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyIdentityUsers,
|
||||
normalizeSupabaseUsers,
|
||||
} from "../scripts/import-supabase-auth-users.mjs";
|
||||
|
||||
const fixturePath = fileURLToPath(
|
||||
new URL("./fixtures/supabase-auth-users.json", import.meta.url),
|
||||
);
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL("../scripts/import-supabase-auth-users.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
test("Supabase user transform preserves portable identity fields only", () => {
|
||||
const source = JSON.parse(readFileSync(fixturePath, "utf8"));
|
||||
const users = normalizeSupabaseUsers(source);
|
||||
|
||||
assert.deepEqual(users, [
|
||||
{
|
||||
id: "018f4e6d-7a11-7000-8000-000000000001",
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
emailVerifiedAt: new Date("2026-07-01T01:02:03.000Z"),
|
||||
name: "Person One",
|
||||
image: "https://example.com/person.png",
|
||||
createdAt: new Date("2026-06-01T01:02:03.000Z"),
|
||||
updatedAt: new Date("2026-07-02T01:02:03.000Z"),
|
||||
},
|
||||
{
|
||||
id: "018f4e6d-7a11-7000-8000-000000000002",
|
||||
email: "second@example.com",
|
||||
emailVerified: false,
|
||||
emailVerifiedAt: null,
|
||||
name: "second",
|
||||
image: null,
|
||||
createdAt: new Date("2026-06-02T01:02:03.000Z"),
|
||||
updatedAt: new Date("2026-06-02T01:02:03.000Z"),
|
||||
},
|
||||
]);
|
||||
const serialized = JSON.stringify(users);
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/encrypted_password|last_sign_in_at|raw_user_meta_data|must-not-be-imported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Supabase user transform aborts duplicate canonical emails", () => {
|
||||
const source = JSON.parse(readFileSync(fixturePath, "utf8"));
|
||||
source.push({
|
||||
...source[1],
|
||||
id: "018f4e6d-7a11-7000-8000-000000000003",
|
||||
email: " SECOND@example.com ",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => normalizeSupabaseUsers(source),
|
||||
new Error("source contains duplicate canonical emails"),
|
||||
);
|
||||
});
|
||||
|
||||
test("identity user apply is transactional, parameterized, and rerunnable", async () => {
|
||||
const users = normalizeSupabaseUsers(
|
||||
JSON.parse(readFileSync(fixturePath, "utf8")),
|
||||
);
|
||||
const calls: Array<{ text: string; values?: unknown[] }> = [];
|
||||
const client = {
|
||||
async query(text: string, values?: unknown[]) {
|
||||
calls.push({ text, values });
|
||||
return { rowCount: 1 };
|
||||
},
|
||||
};
|
||||
|
||||
await applyIdentityUsers(client, users);
|
||||
await applyIdentityUsers(client, users);
|
||||
|
||||
assert.equal(calls.filter((call) => call.text === "BEGIN").length, 2);
|
||||
assert.equal(calls.filter((call) => call.text === "COMMIT").length, 2);
|
||||
const upserts = calls.filter((call) => /insert into identity\.users/.test(call.text));
|
||||
assert.equal(upserts.length, 4);
|
||||
assert.ok(upserts.every((call) => /on conflict \(id\) do update/.test(call.text)));
|
||||
assert.ok(upserts.every((call) => /\$1/.test(call.text)));
|
||||
assert.ok(upserts.every((call) => !call.text.includes("person@example.com")));
|
||||
});
|
||||
|
||||
test("CLI defaults to a redacted dry-run without a database URL", () => {
|
||||
const env = { ...process.env };
|
||||
delete env.IDENTITY_DATABASE_URL;
|
||||
const result = spawnSync(process.execPath, [scriptPath, fixturePath], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /"mode":"dry-run"/);
|
||||
assert.match(result.stdout, /"users":2/);
|
||||
assert.doesNotMatch(result.stdout, /person@example\.com|second@example\.com/);
|
||||
});
|
||||
|
||||
test("CLI apply mode requires an identity database URL without printing inputs", () => {
|
||||
const env = { ...process.env };
|
||||
delete env.IDENTITY_DATABASE_URL;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, fixturePath, "--apply"],
|
||||
{ encoding: "utf8", env },
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /IDENTITY_DATABASE_URL is required for --apply/);
|
||||
assert.doesNotMatch(result.stderr, /person@example\.com|must-not-be-imported/);
|
||||
});
|
||||
Reference in New Issue
Block a user