feat(profile): self-service name/email/password + desktop installers in CI
Self-service profile: any signed-in user edits their OWN fullName/email and changes their OWN password (proving the current one), without any user:* permission. New routes PUT /api/auth/profile + /api/auth/password act only on req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile reachable from the header username chip. email added to the session view + SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green. Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage on every push to dev/main and uploads them as unsigned workflow artifacts (per-commit test build). Signed/versioned release stays on release.yml (tag v*). Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI split), log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
|
||||
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
|
||||
// must prove the current password. Distinct from admin user-management (routes/users.ts).
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/profile (self-service)", () => {
|
||||
it("a permission-less user can edit their OWN name + email", async () => {
|
||||
// 'viewer' role with NO user:* permission — profile is not gated on it.
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "cashier", roleId: "viewer", permissions: [],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.fullName).toBe("Mon Kukaleshi");
|
||||
expect(body.email).toBe("mon@example.com");
|
||||
// Persisted to the caller's own row.
|
||||
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
|
||||
expect(row?.fullName).toBe("Mon Kukaleshi");
|
||||
expect(row?.email).toBe("mon@example.com");
|
||||
});
|
||||
|
||||
it('clears a field when sent ""', async () => {
|
||||
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
// First set a name…
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fullName: "Old Name" },
|
||||
});
|
||||
// …then clear it with whitespace (→ null).
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fullName: " " },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().fullName).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an empty patch (nothing to update)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("requires a session (401 without a token)", async () => {
|
||||
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/password (self-service)", () => {
|
||||
it("changes the password when the current one is correct, and the new one then logs in", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/password",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Old password no longer works; new one does.
|
||||
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||
expect(oldTry.statusCode).toBe(401);
|
||||
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
|
||||
expect(newTry.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/password",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
// Original password still works.
|
||||
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||
expect(still.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a too-short new password (400)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/password",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { currentPassword: password, newPassword: "short" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user