Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement org-based write permissions in frontend #441

Merged
merged 3 commits into from
Sep 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
APP_SECRET_KEY="h(tz!4a)llr+gm9g%f7vo%@br8q5!ec$7vifheax267av3lnk+"
APP_DATABASE_URL="postgresql+asyncpg://user:pass@localhost:5432/${DB:-catalogage}"

TOOLS_PASSWORDS='{"admin@catalogue.data.gouv.fr": "admin"}'
TOOLS_PASSWORDS='{"admin@catalogue.data.gouv.fr": "admin", "admin.sante@catalogue.data.gouv.fr": "admin"}'
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

env:
APP_SECRET_KEY: "<testing>"
APP_DATABASE_URL: "postgresql+asyncpg://username:password@localhost:5432/catalogage"
TOOLS_PASSWORDS: '{"admin@catalogue.data.gouv.fr": "ci-admin"}'

steps:
- uses: actions/checkout@v3
Expand All @@ -45,6 +43,9 @@ jobs:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-node-${{ hashFiles('**/client/package-lock.json') }}

- name: "Setup .env"
run: cp .env.example .env

- name: "Install dependencies"
run: make install

Expand Down
23 changes: 2 additions & 21 deletions client/src/lib/components/Header/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,10 @@
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { logout, account } from "$lib/stores/auth";
import { navigationItems } from "$lib/stores/layout";
import paths from "$lib/paths";
import { Maybe } from "$lib/util/maybe";

type NavItem = {
label: string;
href: string;
};

const navigationItems: NavItem[] = [
{
label: "Accueil",
href: paths.home,
},
{
label: "Rechercher",
href: paths.datasetSearch,
},
{
label: "Contribuer",
href: paths.contribute,
},
];

$: path = $page.url.pathname;

const onClickLogout = async () => {
Expand Down Expand Up @@ -129,7 +110,7 @@
data-fr-js-navigation="true"
>
<ul class="fr-nav__list">
{#each navigationItems as { label, href }}
{#each $navigationItems as { label, href }}
<li class="fr-nav__item" data-fr-js-navigaton-item="true">
<a
{href}
Expand Down
24 changes: 24 additions & 0 deletions client/src/lib/permissions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getFakeAccount } from "src/tests/factories/accounts";
import { getFakeCatalogRecord } from "src/tests/factories/catalog_records";
import { getFakeDataset } from "src/tests/factories/dataset";
import { getFakeOrganization } from "src/tests/factories/organizations";
import { canEditDataset } from "./permissions";

describe("permissions", () => {
describe("canEditDataset", () => {
test("Account with same SIRET as dataset can edit", () => {
const organization = getFakeOrganization();
const dataset = getFakeDataset({
catalogRecord: getFakeCatalogRecord({ organization }),
});
const account = getFakeAccount({ organizationSiret: organization.siret });
expect(canEditDataset(dataset, account)).toBeTruthy();
});

test("Account with different SIRET than dataset cannot edit", () => {
const dataset = getFakeDataset();
const account = getFakeAccount();
expect(canEditDataset(dataset, account)).toBeFalsy();
});
});
});
6 changes: 6 additions & 0 deletions client/src/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Account } from "src/definitions/auth";
florimondmanca marked this conversation as resolved.
Show resolved Hide resolved
import type { Dataset } from "src/definitions/datasets";

export const canEditDataset = (dataset: Dataset, account: Account): boolean => {
return dataset.catalogRecord.organization.siret === account.organizationSiret;
};
52 changes: 52 additions & 0 deletions client/src/lib/stores/layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { derived, get, writable } from "svelte/store";
import type { Catalog } from "src/definitions/catalogs";
import { Maybe } from "$lib/util/maybe";
import { debounce } from "$lib/util/store";
import paths from "$lib/paths";
import { getCatalogBySiret } from "$lib/repositories/catalogs";
import { account, apiToken } from "../auth";

type NavItem = {
label: string;
href: string;
};

const currentCatalog = writable<Maybe<Catalog>>();

// XXX: Upon login, $account may be set a few times in a row.
// Make only one HTTP request once things have settled.
debounce(account, 100).subscribe((accountValue) => {
florimondmanca marked this conversation as resolved.
Show resolved Hide resolved
if (!Maybe.Some(accountValue)) {
currentCatalog.set(null);
return;
}

getCatalogBySiret({
fetch,
apiToken: Maybe.expect(get(apiToken), "$apiToken"),
siret: accountValue.organizationSiret,
}).then((catalog) => {
currentCatalog.set(catalog);
});
});

export const navigationItems = derived(currentCatalog, (catalog): NavItem[] => {
return [
{
label: "Accueil",
href: paths.home,
},
{
label: "Rechercher",
href: paths.datasetSearch,
},
...(Maybe.Some(catalog)
? [
{
label: "Contribuer",
href: paths.contribute,
},
]
: []),
];
});
Copy link
Collaborator Author

@florimondmanca florimondmanca Sep 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Cette partie mérite attention

Dans ce fichier, je rends les navigationItems réactifs par rapport au catalogue de l'utilisateur courant.

En effet, quand on se connecte avec un utilisateur dont l'organisation n'a pas de catalogue, le navItem "Contribuer" ne doit pas être visible.

Le besoin d'un debounce() me déçoit un peu (ce n'est pas la faute du refresh() : j'ai essayé de le commenter et sans debounce j'avais 2 requêtes identiques qui se lançaient), j'ai laissé un XXX pour l'instant.

20 changes: 17 additions & 3 deletions client/src/lib/util/array.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { range } from "./array";

describe("range", () => {
const cases: [number, number, number[]][] = [
const startEndCases: [number, number, number[]][] = [
[1, 1, []],
[2, 1, []],
[2, 2, []],
Expand All @@ -11,7 +11,21 @@ describe("range", () => {
[-3, 2, [-3, -2, -1, 0, 1]],
];

test.each(cases)("when start=%s and end=%s", (start, end, expected) => {
expect(range(start, end)).toStrictEqual(expected);
test.each(startEndCases)(
"when start=%s and end=%s",
(start, end, expected) => {
expect(range(start, end)).toStrictEqual(expected);
}
);

const endCases: [number, number[]][] = [
[0, []],
[1, [0]],
[4, [0, 1, 2, 3]],
[-3, []],
];

test.each(endCases)("when end=%s", (end, expected) => {
expect(range(end)).toStrictEqual(expected);
});
});
9 changes: 8 additions & 1 deletion client/src/lib/util/array.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export const range = (start: number, end: number): number[] => {
/**
* @returns An array of integers from `start` (included) to `end` (excluded).
*/
export const range = (start: number, end?: number): number[] => {
if (end === undefined) {
end = start;
start = 0;
}
const length = end - start;
return Array.from({ length }, (_, idx) => start + idx);
};
Expand Down
6 changes: 6 additions & 0 deletions client/src/lib/util/random.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @returns A random integer between `start` (included) and `end` (excluded)
*/
export const randint = (start: number, end: number): number => {
return Math.floor(start + Math.random() * (end - start));
};
21 changes: 21 additions & 0 deletions client/src/lib/util/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { writable, type Readable } from "svelte/store";

/**
* @returns A new store that sends a value once `store` has not been called for `delay` milliseconds.
*
* Inspired by: https://docs-lodash.com/v4/debounce/
*/
export const debounce = <T>(store: Readable<T>, delay: number): Readable<T> => {
const newStore = writable<T>();

let timer: NodeJS.Timeout;

store.subscribe((value) => {
clearTimeout(timer);
timer = setTimeout(() => {
newStore.set(value);
}, delay);
});

return newStore;
};
20 changes: 0 additions & 20 deletions client/src/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,11 @@ import path from "path";
import dotenv from "dotenv";
import type { PlaywrightTestConfig } from "@playwright/test";
import type { AppTestArgs } from "src/tests/e2e/fixtures";
import { ADMIN_EMAIL } from "./tests/e2e/constants.js";

dotenv.config({
path: path.resolve("..", ".env"),
});

const getAdminTestPassword = (): string => {
const passwords = JSON.parse(process.env.TOOLS_PASSWORDS || "{}");
const adminPassword = passwords[ADMIN_EMAIL];
if (!adminPassword) {
throw new Error(
`Password for ${ADMIN_EMAIL} not defined in TOOLS_PASSWORDS`
);
}
return adminPassword;
};

const config: PlaywrightTestConfig<AppTestArgs> = {
testDir: "./tests/e2e/",
globalSetup: "./tests/e2e/global-setup.ts",
Expand All @@ -30,14 +18,6 @@ const config: PlaywrightTestConfig<AppTestArgs> = {
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "default",
use: {
adminTestPassword: getAdminTestPassword(),
},
},
],
Comment on lines -33 to -40
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

J'en ai profité pour simplifier cette utilisation de projects en définissant directement une constante ADMIN_PASSWORD, comme pour le reste.

À l'époque, je crois que j'avais fait ça parce qu'on ne pouvait pas vraiment importer du code dans les fichiers dans tests/e2e. Avec la dernière version de Playwright ça passe crème.

};

export default config;
22 changes: 13 additions & 9 deletions client/src/routes/(app)/fiches/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import paths from "$lib/paths";
import { UPDATE_FREQUENCY_LABELS } from "src/constants";
import { formatFullDate, splitParagraphs } from "src/lib/util/format";
import { account } from "$lib/stores/auth";
import { Maybe } from "$lib/util/maybe";
import { canEditDataset } from "$lib/permissions";
import AsideItem from "./_AsideItem.svelte";
import ExtraFieldsList from "./_ExtraFieldsList.svelte";

Expand Down Expand Up @@ -45,15 +47,17 @@
<ul
class="fr-grid-row fr-grid-row--right fr-btns-group fr-btns-group--inline fr-btns-group--icon-right fr-my-5w"
>
<li>
<a
href={editUrl}
class="fr-btn fr-btn--secondary fr-icon-edit-fill"
title="Modifier ce jeu de données"
>
Modifier
</a>
</li>
{#if canEditDataset(dataset, Maybe.expect($account, "$account"))}
<li>
<a
href={editUrl}
class="fr-btn fr-btn--secondary fr-icon-edit-fill"
title="Modifier ce jeu de données"
>
Modifier
</a>
</li>
{/if}
<li>
<a
class="fr-btn fr-btn--secondary fr-icon-mail-line"
Expand Down
16 changes: 11 additions & 5 deletions client/src/routes/(app)/fiches/[id]/edit/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ import { getDatasetByID } from "$lib/repositories/datasets";
import { getTags } from "src/lib/repositories/tags";
import { getLicenses } from "src/lib/repositories/licenses";
import { getDatasetFiltersInfo } from "src/lib/repositories/datasetFilters";
import { apiToken as apiTokenStore, account } from "$lib/stores/auth";
import { apiToken as apiTokenStore } from "$lib/stores/auth";
import { Maybe } from "$lib/util/maybe";

export const load: PageLoad = async ({ fetch, params }) => {
const apiToken = get(apiTokenStore);
const siret = Maybe.expect(get(account), "$account").organizationSiret;

const [catalog, dataset, tags, licenses, filtersInfo] = await Promise.all([
getCatalogBySiret({ fetch, apiToken, siret }),
getDatasetByID({ fetch, apiToken, id: params.id }),
const dataset = await getDatasetByID({ fetch, apiToken, id: params.id });

const [catalog, tags, licenses, filtersInfo] = await Promise.all([
Maybe.map(dataset, (dataset) =>
getCatalogBySiret({
fetch,
apiToken,
siret: dataset.catalogRecord.organization.siret,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ici il y avait un bug : on récupérait le SIRET de l'utilisateur courant, mais en fait dans le contexte de l'édition d'un dataset, c'est le catalogue du jeu de données qui nous intéresse !

})
),
getTags({ fetch, apiToken }),
getLicenses({ fetch, apiToken }),
getDatasetFiltersInfo({ fetch, apiToken }),
Expand Down
26 changes: 22 additions & 4 deletions client/src/routes/(app)/fiches/[id]/page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import type {
ExtraField,
ExtraFieldValue,
} from "src/definitions/catalogs";
import { login, logout } from "src/lib/stores/auth";
import { getFakeOrganization } from "src/tests/factories/organizations";
import { getFakeCatalogRecord } from "src/tests/factories/catalog_records";
import { getFakeAccount } from "src/tests/factories/accounts";

const organization = getFakeOrganization();

const catalog: Catalog = {
organization: {
siret: "<siret>",
name: "Org 1",
},
organization,
extraFields: [],
};

Expand All @@ -27,10 +30,19 @@ const dataset = getFakeDataset({
formats: ["other"],
producerEmail: "service@mydomain.org",
contactEmails: ["service@mydomain.org"],
catalogRecord: getFakeCatalogRecord({
organization,
}),
});

const data = { catalog, dataset };

beforeAll(() =>
login(getFakeAccount({ organizationSiret: organization.siret }), "abcd1234")
);

afterAll(() => logout());

describe("Dataset detail page header", () => {
test("The dataset title is present", () => {
const { getByRole } = render(index, { data });
Expand All @@ -45,6 +57,12 @@ describe("Dataset detail page action buttons", () => {
expect(modifyButton).toBeInTheDocument();
expect(modifyButton.getAttribute("href")).toContain(dataset.id);
});
test("The button to modify the dataset is absent if user does not belong to organization", async () => {
login(getFakeAccount({ organizationSiret: "<other_siret>" }), "1234");
const { queryByText } = render(index, { data });
const modifyButton = queryByText("Modifier");
expect(modifyButton).not.toBeInTheDocument();
});
test("The button to contact the author is present", () => {
const { getByText } = render(index, { data });
const contactButton = getByText("Contacter le producteur");
Expand Down
Loading